- Adicionado estrutura completa do projeto - Configurado MCP server para Premiere Pro - Adicionado documentação e skills - Configurado Gitignore para o projeto
312 lines
13 KiB
TypeScript
Executable File
312 lines
13 KiB
TypeScript
Executable File
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { createHash } from "node:crypto";
|
|
import { mkdtempSync, rmSync, writeFileSync, existsSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
|
|
const { mockExecFile } = vi.hoisted(() => ({
|
|
mockExecFile: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("node:child_process", () => ({ execFile: mockExecFile }));
|
|
vi.mock("node:util", async (importOriginal) => {
|
|
const original = await importOriginal<typeof import("node:util")>();
|
|
return {
|
|
...original,
|
|
promisify: () => (...args: any[]) => new Promise((resolve, reject) => {
|
|
mockExecFile(...args, (error: Error | null, stdout: string, stderr: string) => {
|
|
if (error) reject(error);
|
|
else resolve({ stdout, stderr });
|
|
});
|
|
}),
|
|
};
|
|
});
|
|
vi.mock("../../src/bridge/file-bridge.js", () => ({
|
|
sendCommand: vi.fn(),
|
|
}));
|
|
|
|
import { sendCommand } from "../../src/bridge/file-bridge.js";
|
|
import { getAudioTools } from "../../src/tools/audio.js";
|
|
import { confirmationToken, getEditPlanTools, validateEditPlan } from "../../src/tools/edit-plans.js";
|
|
import {
|
|
getExportTools,
|
|
inspectExportPresetFile,
|
|
MAX_CAPTURE_FRAME_BYTES,
|
|
verifyDeliveryFile,
|
|
} from "../../src/tools/export.js";
|
|
|
|
const mockedSendCommand = vi.mocked(sendCommand);
|
|
const temporaryDirectories: string[] = [];
|
|
const bridgeOptions = { tempDir: "/tmp/coverage-tools", timeoutMs: 500 };
|
|
|
|
function temporaryPath(name: string, contents = "test content"): string {
|
|
const directory = mkdtempSync(join(tmpdir(), "premiere-tool-coverage-"));
|
|
temporaryDirectories.push(directory);
|
|
const path = join(directory, name);
|
|
writeFileSync(path, contents);
|
|
return path;
|
|
}
|
|
|
|
function temporaryDirectory(): string {
|
|
const directory = mkdtempSync(join(tmpdir(), "premiere-tool-coverage-"));
|
|
temporaryDirectories.push(directory);
|
|
return directory;
|
|
}
|
|
|
|
function execSucceeds(stderr = ""): void {
|
|
mockExecFile.mockImplementationOnce((...args: any[]) => {
|
|
args.at(-1)(null, "", stderr);
|
|
});
|
|
}
|
|
|
|
function execFails(error: Error): void {
|
|
mockExecFile.mockImplementationOnce((...args: any[]) => {
|
|
args.at(-1)(error, "", "");
|
|
});
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mockedSendCommand.mockResolvedValue({ success: true, data: { host: "ok" } });
|
|
});
|
|
|
|
afterEach(() => {
|
|
for (const directory of temporaryDirectories.splice(0)) {
|
|
rmSync(directory, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
describe("audio tool analysis coverage", () => {
|
|
it("resolves a project item, runs ffmpeg, and returns silent and retained ranges", async () => {
|
|
const mediaPath = temporaryPath("speech.mp4");
|
|
mockedSendCommand.mockResolvedValueOnce({
|
|
success: true,
|
|
data: { mediaPath, name: "Interview" },
|
|
});
|
|
execSucceeds();
|
|
execSucceeds([
|
|
"Duration: 00:00:12.00, start: 0.000000",
|
|
"[silencedetect] silence_start: 2",
|
|
"[silencedetect] silence_end: 4.5 | silence_duration: 2.5",
|
|
"[silencedetect] silence_start: 10",
|
|
].join("\n"));
|
|
|
|
const result = await getAudioTools(bridgeOptions).detect_silence.handler({
|
|
project_item_id: "interview-item",
|
|
noise_threshold_db: -42,
|
|
min_duration_seconds: 0.75,
|
|
});
|
|
|
|
expect(mockedSendCommand).toHaveBeenCalledOnce();
|
|
expect(mockedSendCommand.mock.calls[0][0]).toContain("getMediaPath");
|
|
expect(mockExecFile).toHaveBeenCalledTimes(2);
|
|
expect(result).toMatchObject({
|
|
success: true,
|
|
data: {
|
|
mediaPath,
|
|
projectItemName: "Interview",
|
|
noiseThresholdDb: -42,
|
|
minDurationSeconds: 0.75,
|
|
totalDurationSeconds: 12,
|
|
silenceIntervals: [
|
|
{ start: 2, end: 4.5, duration: 2.5 },
|
|
{ start: 10, end: 12, duration: 2 },
|
|
],
|
|
segments: [
|
|
{ start: 0, end: 2, duration: 2 },
|
|
{ start: 4.5, end: 10, duration: 5.5 },
|
|
],
|
|
silentSeconds: 4.5,
|
|
},
|
|
});
|
|
});
|
|
|
|
it("reports missing ffmpeg, timeouts, and useful analyser failure details", async () => {
|
|
const mediaPath = temporaryPath("speech.mp4");
|
|
const tools = getAudioTools(bridgeOptions);
|
|
|
|
execFails(new Error("ENOENT"));
|
|
await expect(tools.detect_silence.handler({ media_path: mediaPath }))
|
|
.resolves.toMatchObject({ success: false, error: expect.stringContaining("ffmpeg was not found") });
|
|
|
|
execSucceeds();
|
|
execFails(Object.assign(new Error("slow"), { killed: true }));
|
|
await expect(tools.detect_silence.handler({ media_path: mediaPath }))
|
|
.resolves.toMatchObject({ success: false, error: expect.stringContaining("timed out after 300s") });
|
|
|
|
execSucceeds();
|
|
execFails(Object.assign(new Error("failed"), {
|
|
stderr: "first line\n\nsecond line\nthird line\nfourth line",
|
|
}));
|
|
await expect(tools.detect_silence.handler({ media_path: mediaPath }))
|
|
.resolves.toMatchObject({ success: false, error: expect.stringContaining("second line third line fourth line") });
|
|
});
|
|
|
|
it("renders audio adjustment, keyframe, and unmute options into the bridge script", async () => {
|
|
const tools = getAudioTools(bridgeOptions);
|
|
|
|
await tools.adjust_audio_levels.handler({ node_id: "clip\"id", level_db: -6 });
|
|
// Premiere's Volume > Level uses +15 dB as its normalized maximum.
|
|
expect(mockedSendCommand.mock.calls.at(-1)?.[0]).toContain("0.08912509381337455");
|
|
|
|
await tools.add_audio_keyframes.handler({
|
|
node_id: "clip",
|
|
keyframes: [{ time_seconds: 1.25, level_db: -Infinity }],
|
|
});
|
|
expect(mockedSendCommand.mock.calls.at(-1)?.[0]).toContain("1e-7");
|
|
|
|
await tools.mute_track.handler({ track_index: 3, muted: false });
|
|
expect(mockedSendCommand.mock.calls.at(-1)?.[0]).toContain("track.setMute(0)");
|
|
});
|
|
});
|
|
|
|
describe("edit plan validation and apply coverage", () => {
|
|
it.each([
|
|
[null, "plan must be an object"],
|
|
[{}, "plan.operations must contain at least one operation"],
|
|
[{ operations: Array.from({ length: 101 }, () => ({ type: "remove_clip", node_id: "clip" })) }, "limited to 100"],
|
|
[{ operations: [null] }, "operation 0 must be an object"],
|
|
[{ operations: [{ type: "insert_clip", start_seconds: 0 }] }, "requires item_id"],
|
|
[{ operations: [{ type: "insert_clip", item_id: "item", start_seconds: Number.NaN }] }, "non-negative number"],
|
|
[{ operations: [{ type: "insert_clip", item_id: "item", start_seconds: 0, video_track_index: -1 }] }, "video_track_index"],
|
|
[{ operations: [{ type: "insert_clip", item_id: "item", start_seconds: 0, audio_track_index: 1.5 }] }, "audio_track_index"],
|
|
[{ operations: [{ type: "remove_clip", node_id: "" }] }, "requires node_id"],
|
|
])("rejects %j", (value, message) => {
|
|
expect(() => validateEditPlan(value)).toThrow(message);
|
|
});
|
|
|
|
it("previews destructive and non-destructive work with stable confirmation", async () => {
|
|
const plan = {
|
|
operations: [
|
|
{ type: "insert_clip" as const, item_id: "source", start_seconds: 2 },
|
|
{ type: "remove_clip" as const, node_id: "old-clip", ripple: true },
|
|
],
|
|
};
|
|
const tools = getEditPlanTools(bridgeOptions, {
|
|
capabilities: { capabilities: new Set(["inspect"]), source: "explicit" },
|
|
operationIdFactory: () => "preview-mixed",
|
|
});
|
|
|
|
const result = await tools.preview_edit_plan.handler({ plan });
|
|
expect(result).toMatchObject({
|
|
success: true,
|
|
data: {
|
|
operationId: "preview-mixed",
|
|
confirmationToken: confirmationToken(plan),
|
|
changes: [
|
|
{ type: "insert_clip", target: "source", destructive: false },
|
|
{ type: "remove_clip", target: "old-clip", destructive: true },
|
|
],
|
|
},
|
|
});
|
|
expect(confirmationToken({ ...plan, sequence_id: "other" })).not.toBe(confirmationToken(plan));
|
|
});
|
|
|
|
it("builds a remove plan against a named sequence and decorates bridge failures", async () => {
|
|
const plan = {
|
|
sequence_id: "seq\"one",
|
|
operations: [
|
|
{ type: "remove_clip" as const, node_id: "old-clip", ripple: true },
|
|
{ type: "insert_clip" as const, item_id: "new-item", start_seconds: 3, video_track_index: 2, audio_track_index: 4 },
|
|
],
|
|
};
|
|
const auditSink = vi.fn();
|
|
mockedSendCommand.mockResolvedValueOnce({ success: false, error: "Premiere rejected the edit" });
|
|
const tools = getEditPlanTools(bridgeOptions, {
|
|
capabilities: { capabilities: new Set(["inspect", "edit"]), source: "explicit" },
|
|
auditSink,
|
|
operationIdFactory: () => "apply-remove",
|
|
});
|
|
|
|
const result = await tools.apply_edit_plan.handler({
|
|
plan,
|
|
confirmation_token: confirmationToken(plan),
|
|
});
|
|
const script = String(mockedSendCommand.mock.calls[0][0]);
|
|
|
|
expect(script).toContain('__findSequence("seq\\"one")');
|
|
expect(script).toContain("function __planFindClip");
|
|
expect(script).toContain("found0.remove(true, true)");
|
|
expect(result).toEqual({ success: false, error: "Premiere rejected the edit (operation apply-remove)" });
|
|
expect(auditSink.mock.calls.map(([event]) => event.outcome)).toEqual(["started", "failed"]);
|
|
});
|
|
});
|
|
|
|
describe("export verification and host-result coverage", () => {
|
|
it("validates a SHA-512 delivery and rejects malformed size constraints", async () => {
|
|
const delivery = temporaryPath("delivery.mov", "delivery data");
|
|
const checksum = createHash("sha512").update("delivery data").digest("hex");
|
|
|
|
await expect(verifyDeliveryFile(delivery, { minimumSizeBytes: -1 })).rejects.toThrow("minimum_size_bytes");
|
|
await expect(verifyDeliveryFile(delivery, { expectedSizeBytes: 1.5 })).rejects.toThrow("expected_size_bytes");
|
|
await expect(verifyDeliveryFile(temporaryDirectory())).rejects.toThrow("not a regular file");
|
|
|
|
await expect(verifyDeliveryFile(delivery, {
|
|
algorithm: "sha512",
|
|
expectedChecksum: ` ${checksum.toUpperCase()} `,
|
|
expectedSizeBytes: 13,
|
|
minimumSizeBytes: 0,
|
|
})).resolves.toMatchObject({
|
|
checksum: { algorithm: "sha512", value: checksum },
|
|
matchesExpectedChecksum: true,
|
|
matchesExpectedSize: true,
|
|
valid: true,
|
|
});
|
|
});
|
|
|
|
it("surfaces local preset and delivery errors, then merges a host-validated preset response", async () => {
|
|
const tools = getExportTools(bridgeOptions);
|
|
const invalid = temporaryPath("preset.xml", "preset");
|
|
await expect(tools.validate_export_preset.handler({ preset_path: invalid }))
|
|
.resolves.toMatchObject({ success: false, error: expect.stringContaining(".epr") });
|
|
expect(mockedSendCommand).not.toHaveBeenCalled();
|
|
|
|
const preset = temporaryPath("preset.epr", "preset");
|
|
mockedSendCommand.mockResolvedValueOnce({ success: false, error: "No active sequence" });
|
|
await expect(tools.validate_export_preset.handler({ preset_path: preset }))
|
|
.resolves.toEqual({ success: false, error: "No active sequence" });
|
|
|
|
mockedSendCommand.mockResolvedValueOnce({ success: true, data: { outputExtension: ".mov" } });
|
|
await expect(tools.validate_export_preset.handler({ preset_path: preset }))
|
|
.resolves.toMatchObject({ success: true, data: { path: preset, outputExtension: ".mov", hostValidated: true } });
|
|
expect(inspectExportPresetFile(preset).sizeBytes).toBe(6);
|
|
|
|
await expect(tools.verify_delivery_file.handler({ output_path: join(tmpdir(), "missing-delivery.mov") }))
|
|
.resolves.toMatchObject({ success: false, error: expect.stringContaining("does not exist") });
|
|
});
|
|
|
|
it("reads a host-exported frame, reports unreadable output, and covers proxy script choices", async () => {
|
|
const tools = getExportTools(bridgeOptions);
|
|
const frame = temporaryPath("frame.png", "png bytes");
|
|
mockedSendCommand.mockResolvedValueOnce({ success: true, data: { outputPath: frame } });
|
|
await expect(tools.capture_frame.handler({ time_seconds: 3 })).resolves.toMatchObject({
|
|
success: true,
|
|
data: { captured: true, mimeType: "image/png", base64: Buffer.from("png bytes").toString("base64") },
|
|
});
|
|
expect(existsSync(frame)).toBe(false);
|
|
|
|
const oversizedFrame = temporaryPath("oversized-frame.png", "x".repeat(MAX_CAPTURE_FRAME_BYTES + 1));
|
|
mockedSendCommand.mockResolvedValueOnce({ success: true, data: { outputPath: oversizedFrame } });
|
|
await expect(tools.capture_frame.handler({})).resolves.toMatchObject({
|
|
success: false,
|
|
error: expect.stringContaining("inline response limit"),
|
|
});
|
|
expect(existsSync(oversizedFrame)).toBe(false);
|
|
|
|
mockedSendCommand.mockResolvedValueOnce({ success: true, data: { outputPath: temporaryDirectory() } });
|
|
await expect(tools.capture_frame.handler({})).resolves.toMatchObject({
|
|
success: false,
|
|
error: expect.stringContaining("Failed to read captured frame"),
|
|
});
|
|
|
|
await tools.manage_proxies.handler({ item_id: "item", action: "create" });
|
|
expect(mockedSendCommand.mock.calls.at(-1)?.[0]).toContain("output_path is required");
|
|
await tools.manage_proxies.handler({ item_id: "item", action: "create", output_path: "proxy.mov" });
|
|
expect(mockedSendCommand.mock.calls.at(-1)?.[0]).toContain("__findProxyPreset()");
|
|
await tools.manage_proxies.handler({ item_id: "item", action: "create", output_path: "proxy.mov", preset_path: "proxy.epr" });
|
|
expect(mockedSendCommand.mock.calls.at(-1)?.[0]).toContain('var presetPath = "proxy.epr"');
|
|
await tools.manage_proxies.handler({ item_id: "item", action: "attach" });
|
|
expect(mockedSendCommand.mock.calls.at(-1)?.[0]).toContain("proxy_path is required for attach action");
|
|
});
|
|
});
|