feat: initial commit - Jhonny Editor
- Adicionado estrutura completa do projeto - Configurado MCP server para Premiere Pro - Adicionado documentação e skills - Configurado Gitignore para o projeto
This commit is contained in:
Executable
+259
@@ -0,0 +1,259 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user