- Adicionado estrutura completa do projeto - Configurado MCP server para Premiere Pro - Adicionado documentação e skills - Configurado Gitignore para o projeto
260 lines
8.8 KiB
TypeScript
Executable File
260 lines
8.8 KiB
TypeScript
Executable File
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { readFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
|
|
vi.mock("../../src/bridge/file-bridge.js", () => ({
|
|
sendCommand: vi.fn().mockResolvedValue({ success: true, data: {} }),
|
|
}));
|
|
|
|
import { sendCommand } from "../../src/bridge/file-bridge.js";
|
|
import {
|
|
getAudioTools,
|
|
invertToSegments,
|
|
mapSilenceIntervalsToTimeline,
|
|
parseDurationSeconds,
|
|
parseSilenceDetectOutput,
|
|
} from "../../src/tools/audio.js";
|
|
|
|
const mockedSendCommand = vi.mocked(sendCommand);
|
|
const tools = getAudioTools({ tempDir: "/tmp/test", timeoutMs: 1000 });
|
|
|
|
beforeEach(() => vi.clearAllMocks());
|
|
|
|
describe("parseSilenceDetectOutput", () => {
|
|
it("pairs each silence_start with its silence_end", () => {
|
|
const stderr = [
|
|
"[silencedetect @ 0x1] silence_start: 2.5",
|
|
"[silencedetect @ 0x1] silence_end: 6.25 | silence_duration: 3.75",
|
|
"[silencedetect @ 0x1] silence_start: 10",
|
|
"[silencedetect @ 0x1] silence_end: 12.5 | silence_duration: 2.5",
|
|
].join("\n");
|
|
|
|
expect(parseSilenceDetectOutput(stderr, 20)).toEqual([
|
|
{ start: 2.5, end: 6.25, duration: 3.75 },
|
|
{ start: 10, end: 12.5, duration: 2.5 },
|
|
]);
|
|
});
|
|
|
|
it("closes a silence that runs to the end of the file at the media duration", () => {
|
|
// ffmpeg emits no silence_end when the file ends mid-silence.
|
|
const stderr = [
|
|
"[silencedetect @ 0x1] silence_start: 2",
|
|
"[silencedetect @ 0x1] silence_end: 5 | silence_duration: 3",
|
|
"[silencedetect @ 0x1] silence_start: 17.5",
|
|
].join("\n");
|
|
|
|
expect(parseSilenceDetectOutput(stderr, 20)).toEqual([
|
|
{ start: 2, end: 5, duration: 3 },
|
|
{ start: 17.5, end: 20, duration: 2.5 },
|
|
]);
|
|
});
|
|
|
|
it("drops an unterminated silence when the duration is unknown", () => {
|
|
const stderr = "[silencedetect @ 0x1] silence_start: 17.5";
|
|
expect(parseSilenceDetectOutput(stderr, null)).toEqual([]);
|
|
});
|
|
|
|
it("clamps a negative silence_start to zero", () => {
|
|
const stderr = [
|
|
"[silencedetect @ 0x1] silence_start: -0.001",
|
|
"[silencedetect @ 0x1] silence_end: 4 | silence_duration: 4",
|
|
].join("\n");
|
|
expect(parseSilenceDetectOutput(stderr, 20)[0].start).toBe(0);
|
|
});
|
|
|
|
it("returns nothing for output containing no silence markers", () => {
|
|
expect(parseSilenceDetectOutput("frame= 480 fps=0.0 q=-0.0", 20)).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("parseDurationSeconds", () => {
|
|
it("reads the duration banner", () => {
|
|
expect(parseDurationSeconds(" Duration: 00:00:20.00, start: 0.000000")).toBe(20);
|
|
});
|
|
|
|
it("handles hours and fractional seconds", () => {
|
|
expect(parseDurationSeconds(" Duration: 01:02:03.50, bitrate: 1000 kb/s")).toBeCloseTo(3723.5, 3);
|
|
});
|
|
|
|
it("returns null when no duration is present", () => {
|
|
expect(parseDurationSeconds("no banner here")).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("invertToSegments", () => {
|
|
it("returns the ranges between silences", () => {
|
|
const silences = [
|
|
{ start: 2, end: 5, duration: 3 },
|
|
{ start: 10, end: 12, duration: 2 },
|
|
];
|
|
expect(invertToSegments(silences, 20)).toEqual([
|
|
{ start: 0, end: 2, duration: 2 },
|
|
{ start: 5, end: 10, duration: 5 },
|
|
{ start: 12, end: 20, duration: 8 },
|
|
]);
|
|
});
|
|
|
|
it("omits a leading segment when the media opens on silence", () => {
|
|
const silences = [{ start: 0, end: 4, duration: 4 }];
|
|
expect(invertToSegments(silences, 20)).toEqual([
|
|
{ start: 4, end: 20, duration: 16 },
|
|
]);
|
|
});
|
|
|
|
it("omits a trailing segment when the media ends on silence", () => {
|
|
const silences = [{ start: 16, end: 20, duration: 4 }];
|
|
expect(invertToSegments(silences, 20)).toEqual([
|
|
{ start: 0, end: 16, duration: 16 },
|
|
]);
|
|
});
|
|
|
|
it("returns the whole media when there is no silence", () => {
|
|
expect(invertToSegments([], 20)).toEqual([
|
|
{ start: 0, end: 20, duration: 20 },
|
|
]);
|
|
});
|
|
|
|
it("returns nothing usable when the duration is unknown", () => {
|
|
expect(invertToSegments([], null)).toEqual([]);
|
|
});
|
|
|
|
it("collapses fully silent media to no segments", () => {
|
|
const silences = [{ start: 0, end: 20, duration: 20 }];
|
|
expect(invertToSegments(silences, 20)).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("mapSilenceIntervalsToTimeline", () => {
|
|
it("clips source ranges and maps the retained intervals onto a 1x placement", () => {
|
|
expect(mapSilenceIntervalsToTimeline([
|
|
{ start: 0, end: 4, duration: 4 },
|
|
{ start: 7, end: 12, duration: 5 },
|
|
], {
|
|
sourceInSeconds: 2,
|
|
sourceOutSeconds: 10,
|
|
timelineStartSeconds: 100,
|
|
maxCandidates: 50,
|
|
})).toEqual({
|
|
totalCandidateCount: 2,
|
|
truncated: false,
|
|
candidates: [
|
|
{
|
|
sourceStartSeconds: 2,
|
|
sourceEndSeconds: 4,
|
|
durationSeconds: 2,
|
|
timelineStartSeconds: 100,
|
|
timelineEndSeconds: 102,
|
|
},
|
|
{
|
|
sourceStartSeconds: 7,
|
|
sourceEndSeconds: 10,
|
|
durationSeconds: 3,
|
|
timelineStartSeconds: 105,
|
|
timelineEndSeconds: 108,
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
it("keeps counting bounded candidates after its response limit", () => {
|
|
const result = mapSilenceIntervalsToTimeline([
|
|
{ start: 1, end: 2, duration: 1 },
|
|
{ start: 3, end: 4, duration: 1 },
|
|
], {
|
|
sourceInSeconds: 0,
|
|
sourceOutSeconds: 10,
|
|
timelineStartSeconds: 0,
|
|
maxCandidates: 1,
|
|
});
|
|
expect(result.totalCandidateCount).toBe(2);
|
|
expect(result.truncated).toBe(true);
|
|
expect(result.candidates).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
describe("detect_silence argument handling", () => {
|
|
it("requires either a media path or a project item", async () => {
|
|
const result = await tools.detect_silence.handler({});
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toContain("media_path or project_item_id");
|
|
expect(mockedSendCommand).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects a positive noise threshold", async () => {
|
|
const result = await tools.detect_silence.handler({
|
|
media_path: "/tmp/whatever.mp4",
|
|
noise_threshold_db: 12,
|
|
});
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toContain("dBFS");
|
|
});
|
|
|
|
it("rejects a non-positive minimum duration", async () => {
|
|
const result = await tools.detect_silence.handler({
|
|
media_path: "/tmp/whatever.mp4",
|
|
min_duration_seconds: 0,
|
|
});
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toContain("greater than 0");
|
|
});
|
|
|
|
it("reports a missing file before shelling out to ffmpeg", async () => {
|
|
const result = await tools.detect_silence.handler({
|
|
media_path: "/tmp/definitely-not-here-9f3a2b.mp4",
|
|
});
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toContain("not found on disk");
|
|
});
|
|
|
|
it("resolves a project item's media path through the bridge", async () => {
|
|
mockedSendCommand.mockResolvedValueOnce({
|
|
success: false,
|
|
error: "Project item not found: missing-clip",
|
|
});
|
|
const result = await tools.detect_silence.handler({
|
|
project_item_id: "missing-clip",
|
|
});
|
|
expect(mockedSendCommand).toHaveBeenCalledTimes(1);
|
|
expect(mockedSendCommand.mock.calls[0][0]).toContain("getMediaPath");
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toContain("missing-clip");
|
|
});
|
|
});
|
|
|
|
describe("plan_silence_review_markers argument handling", () => {
|
|
const plan = tools.plan_silence_review_markers;
|
|
|
|
it("rejects invalid placement or response bounds before decoding media", async () => {
|
|
await expect(plan.handler({
|
|
media_path: "/tmp/does-not-matter.mov",
|
|
timeline_start_seconds: -1,
|
|
})).resolves.toMatchObject({ success: false, error: expect.stringContaining("timeline_start_seconds") });
|
|
await expect(plan.handler({
|
|
media_path: "/tmp/does-not-matter.mov",
|
|
timeline_start_seconds: 0,
|
|
source_in_seconds: 5,
|
|
source_out_seconds: 5,
|
|
})).resolves.toMatchObject({ success: false, error: expect.stringContaining("source_out_seconds") });
|
|
await expect(plan.handler({
|
|
media_path: "/tmp/does-not-matter.mov",
|
|
timeline_start_seconds: 0,
|
|
max_candidates: 201,
|
|
})).resolves.toMatchObject({ success: false, error: expect.stringContaining("max_candidates") });
|
|
expect(mockedSendCommand).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("detect_silence distribution prerequisites", () => {
|
|
it("installs ffmpeg in the production container", () => {
|
|
const dockerfile = readFileSync(join(process.cwd(), "Dockerfile"), "utf8");
|
|
expect(dockerfile).toMatch(/apk add --no-cache ffmpeg/);
|
|
});
|
|
|
|
it("documents the local ffmpeg prerequisite", () => {
|
|
const readme = readFileSync(join(process.cwd(), "README.md"), "utf8");
|
|
expect(readme).toContain("`detect_silence`");
|
|
expect(readme).toContain("winget install Gyan.FFmpeg");
|
|
expect(readme).toContain("brew install ffmpeg");
|
|
});
|
|
});
|