- Adicionado estrutura completa do projeto - Configurado MCP server para Premiere Pro - Adicionado documentação e skills - Configurado Gitignore para o projeto
223 lines
11 KiB
TypeScript
Executable File
223 lines
11 KiB
TypeScript
Executable File
import { describe, expect, it, vi } from "vitest";
|
|
import type { UxpWebSocketBridge } from "../../src/bridge/uxp-websocket-bridge.js";
|
|
import {
|
|
normalizeTranscriptDeletionRanges,
|
|
planTranscriptRoughCut,
|
|
previewTranscriptEdit,
|
|
transcriptRevision,
|
|
} from "../../src/tools/transcript-edits.js";
|
|
import { getUxpTools } from "../../src/tools/uxp.js";
|
|
|
|
describe("transcript edit planning", () => {
|
|
it("creates stable transcript revisions", () => {
|
|
expect(transcriptRevision('{"segments":[]}')).toMatch(/^sha256:[a-f0-9]{64}$/);
|
|
expect(transcriptRevision('{"segments":[]}')).toBe(transcriptRevision('{"segments":[]}'));
|
|
expect(transcriptRevision('{"segments":[1]}')).not.toBe(transcriptRevision('{"segments":[]}'));
|
|
});
|
|
|
|
it("sorts, adds handles, and merges overlapping deletion ranges", () => {
|
|
expect(normalizeTranscriptDeletionRanges([
|
|
{ start_seconds: 5, end_seconds: 7 },
|
|
{ start_seconds: 1, end_seconds: 3 },
|
|
{ start_seconds: 2.9, end_seconds: 5.1 },
|
|
], 0.1)).toEqual([{ start_seconds: 0.9, end_seconds: 7.1 }]);
|
|
});
|
|
|
|
it("rejects invalid or excessive deletion ranges", () => {
|
|
expect(() => normalizeTranscriptDeletionRanges([])).toThrow("at least one");
|
|
expect(() => normalizeTranscriptDeletionRanges([{ start_seconds: 2, end_seconds: 1 }])).toThrow("greater than");
|
|
expect(() => normalizeTranscriptDeletionRanges([{ start_seconds: 0, end_seconds: 1 }], 11)).toThrow("between 0 and 10");
|
|
expect(() => normalizeTranscriptDeletionRanges(Array.from({ length: 101 }, (_, i) => ({ start_seconds: i, end_seconds: i + 0.5 })))).toThrow("limited to 100");
|
|
});
|
|
|
|
it("revision-locks previews and returns a deterministic confirmation token", () => {
|
|
const json = '{"segments":[{"text":"remove this"}]}';
|
|
const revision = transcriptRevision(json);
|
|
const first = previewTranscriptEdit(json, revision, [
|
|
{ start_seconds: 4, end_seconds: 5 },
|
|
{ start_seconds: 1, end_seconds: 2.5 },
|
|
]);
|
|
const second = previewTranscriptEdit(json, revision, [
|
|
{ start_seconds: 1, end_seconds: 2.5 },
|
|
{ start_seconds: 4, end_seconds: 5 },
|
|
]);
|
|
expect(first).toMatchObject({ deletedSeconds: 2.5, applied: false });
|
|
expect(first.confirmationToken).toBe(second.confirmationToken);
|
|
expect(() => previewTranscriptEdit(json, transcriptRevision("different"), [{ start_seconds: 1, end_seconds: 2 }])).toThrow("does not match");
|
|
});
|
|
|
|
it("maps transcript deletions to descending verified 1x timeline cut instructions", () => {
|
|
const json = '{"segments":[]}';
|
|
const preview = previewTranscriptEdit(json, transcriptRevision(json), [
|
|
{ start_seconds: 2, end_seconds: 4 },
|
|
{ start_seconds: 8, end_seconds: 12 },
|
|
]);
|
|
const plan = planTranscriptRoughCut(preview, [{
|
|
placement_id: "clip-1",
|
|
track_type: "video",
|
|
track_index: 0,
|
|
source_in_seconds: 0,
|
|
source_out_seconds: 10,
|
|
timeline_start_seconds: 20,
|
|
timeline_end_seconds: 30,
|
|
}]);
|
|
expect(plan).toMatchObject({
|
|
applied: false,
|
|
requiresDuplicateSequence: true,
|
|
requiresPostMutationRequery: true,
|
|
ripple: true,
|
|
unmappedDeletionRanges: [],
|
|
});
|
|
expect(plan.steps.map((step) => step.timeline_range)).toEqual([
|
|
{ start_seconds: 28, end_seconds: 30 },
|
|
{ start_seconds: 22, end_seconds: 24 },
|
|
]);
|
|
});
|
|
|
|
it("rejects retimed placements instead of guessing source-to-timeline mapping", () => {
|
|
const json = '{"segments":[]}';
|
|
const preview = previewTranscriptEdit(json, transcriptRevision(json), [{ start_seconds: 1, end_seconds: 2 }]);
|
|
expect(() => planTranscriptRoughCut(preview, [{
|
|
placement_id: "retimed",
|
|
track_type: "video",
|
|
track_index: 0,
|
|
source_in_seconds: 0,
|
|
source_out_seconds: 10,
|
|
timeline_start_seconds: 0,
|
|
timeline_end_seconds: 5,
|
|
}])).toThrow("retimed");
|
|
});
|
|
|
|
it("fails closed for malformed placements and reports unmapped non-ripple ranges", () => {
|
|
const json = '{"segments":[]}';
|
|
const preview = previewTranscriptEdit(json, transcriptRevision(json), [{ start_seconds: 20, end_seconds: 21 }]);
|
|
const valid = {
|
|
placement_id: "clip-1", track_type: "audio", track_index: 0,
|
|
source_in_seconds: 0, source_out_seconds: 10,
|
|
timeline_start_seconds: 0, timeline_end_seconds: 10,
|
|
} as const;
|
|
|
|
expect(() => planTranscriptRoughCut(preview, [])).toThrow("at least one");
|
|
expect(() => planTranscriptRoughCut(preview, Array(257).fill(valid))).toThrow("limited to 256");
|
|
expect(() => planTranscriptRoughCut(preview, [null])).toThrow("must be an object");
|
|
expect(() => planTranscriptRoughCut(preview, [{ ...valid, placement_id: "" }])).toThrow("1-512");
|
|
expect(() => planTranscriptRoughCut(preview, [{ ...valid, track_type: "caption" }])).toThrow("video or audio");
|
|
expect(() => planTranscriptRoughCut(preview, [{ ...valid, track_index: -1 }])).toThrow("non-negative integer");
|
|
expect(() => planTranscriptRoughCut(preview, [{ ...valid, source_out_seconds: Number.NaN }])).toThrow("finite number");
|
|
expect(() => planTranscriptRoughCut(preview, [{ ...valid, source_out_seconds: 0 }])).toThrow("increasing");
|
|
|
|
const plan = planTranscriptRoughCut(preview, [valid], false);
|
|
expect(plan).toMatchObject({ ripple: false, steps: [], unmappedDeletionRanges: preview.deletionRanges });
|
|
});
|
|
});
|
|
|
|
describe("transcript UXP MCP tools", () => {
|
|
it("exports native transcript JSON with a stable revision", async () => {
|
|
const json = '{"segments":[]}';
|
|
const request = vi.fn().mockResolvedValue({ projectGuid: "project-1", projectItemId: "clip-1", projectItemName: "Interview", json });
|
|
const bridge = { request, getState: vi.fn() } as unknown as UxpWebSocketBridge;
|
|
const result = await getUxpTools(bridge).get_clip_transcript_uxp.handler({ project_item_id: "clip-1" });
|
|
expect(request).toHaveBeenCalledWith("transcript.export", { projectItemId: "clip-1" });
|
|
expect(result).toMatchObject({
|
|
success: true,
|
|
data: { result: { projectGuid: "project-1", projectItemId: "clip-1", json, transcriptRevision: transcriptRevision(json) } },
|
|
});
|
|
});
|
|
|
|
it("maps a guarded transcript replacement to the documented UXP command", async () => {
|
|
const request = vi.fn().mockResolvedValue({ committed: true, verified: true });
|
|
const bridge = { request, getState: vi.fn() } as unknown as UxpWebSocketBridge;
|
|
const result = await getUxpTools(bridge).import_transcript_uxp.handler({
|
|
project_item_id: "clip-1",
|
|
project_guid: "project-1",
|
|
expected_transcript_revision: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
|
replacement_transcript_json: '{"segments":[]}',
|
|
confirm_destructive: true,
|
|
operation_id: "transcript-replace-1",
|
|
});
|
|
expect(request).toHaveBeenCalledWith("transcript.import", {
|
|
projectItemId: "clip-1",
|
|
expectedProjectGuid: "project-1",
|
|
expectedTranscriptRevision: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
|
json: '{"segments":[]}',
|
|
confirmDestructive: true,
|
|
operationId: "transcript-replace-1",
|
|
});
|
|
expect(result).toMatchObject({ success: true, data: { result: { committed: true, verified: true } } });
|
|
});
|
|
|
|
it("fails closed before the bridge when destructive confirmation or replay identity is missing", async () => {
|
|
const request = vi.fn();
|
|
const bridge = { request, getState: vi.fn() } as unknown as UxpWebSocketBridge;
|
|
const tool = getUxpTools(bridge).import_transcript_uxp;
|
|
await expect(tool.handler({
|
|
project_item_id: "clip-1", project_guid: "project-1", expected_transcript_revision: null,
|
|
replacement_transcript_json: '{"segments":[]}', confirm_destructive: false, operation_id: "transcript-replace-2",
|
|
})).resolves.toMatchObject({ success: false, error: expect.stringContaining("confirm_destructive") });
|
|
await expect(tool.handler({
|
|
project_item_id: "clip-1", project_guid: "project-1", expected_transcript_revision: null,
|
|
replacement_transcript_json: '{"segments":[]}', confirm_destructive: true, operation_id: "",
|
|
})).resolves.toMatchObject({ success: false, error: expect.stringContaining("operation_id") });
|
|
expect(request).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("maps bounded search arguments to the native transcript command", async () => {
|
|
const request = vi.fn().mockResolvedValue({ matches: [] });
|
|
const bridge = { request, getState: vi.fn() } as unknown as UxpWebSocketBridge;
|
|
await getUxpTools(bridge).search_clip_transcript_uxp.handler({
|
|
project_item_name: "Interview",
|
|
query: "launch date",
|
|
case_sensitive: true,
|
|
max_results: 10,
|
|
});
|
|
expect(request).toHaveBeenCalledWith("transcript.search", {
|
|
projectItemName: "Interview",
|
|
query: "launch date",
|
|
caseSensitive: true,
|
|
maxResults: 10,
|
|
});
|
|
});
|
|
|
|
it("re-exports the transcript before producing a revision-locked preview", async () => {
|
|
const json = '{"segments":[{"text":"cut this"}]}';
|
|
const request = vi.fn().mockResolvedValue({ projectItemId: "clip-1", projectItemName: "Interview", json });
|
|
const bridge = { request, getState: vi.fn() } as unknown as UxpWebSocketBridge;
|
|
const result = await getUxpTools(bridge).preview_transcript_edit_uxp.handler({
|
|
project_item_id: "clip-1",
|
|
transcript_revision: transcriptRevision(json),
|
|
deletions: [{ start_seconds: 2, end_seconds: 4 }],
|
|
});
|
|
expect(request).toHaveBeenCalledWith("transcript.export", { projectItemId: "clip-1" });
|
|
expect(result).toMatchObject({
|
|
success: true,
|
|
data: { result: { projectItemId: "clip-1", deletedSeconds: 2, applied: false } },
|
|
});
|
|
});
|
|
|
|
it("builds a transcript rough-cut plan without mutating Premiere", async () => {
|
|
const json = '{"segments":[{"text":"cut this"}]}';
|
|
const request = vi.fn().mockResolvedValue({ projectItemId: "clip-1", projectItemName: "Interview", json });
|
|
const bridge = { request, getState: vi.fn() } as unknown as UxpWebSocketBridge;
|
|
const result = await getUxpTools(bridge).plan_transcript_rough_cut_uxp.handler({
|
|
project_item_id: "clip-1",
|
|
transcript_revision: transcriptRevision(json),
|
|
deletions: [{ start_seconds: 2, end_seconds: 4 }],
|
|
placements: [{
|
|
placement_id: "timeline-clip-1",
|
|
track_type: "video",
|
|
track_index: 0,
|
|
source_in_seconds: 0,
|
|
source_out_seconds: 10,
|
|
timeline_start_seconds: 20,
|
|
timeline_end_seconds: 30,
|
|
}],
|
|
});
|
|
expect(request).toHaveBeenCalledTimes(1);
|
|
expect(request).toHaveBeenCalledWith("transcript.export", { projectItemId: "clip-1" });
|
|
expect(result).toMatchObject({
|
|
success: true,
|
|
data: { result: { applied: false, requiresDuplicateSequence: true, steps: [{ timeline_range: { start_seconds: 22, end_seconds: 24 } }] } },
|
|
});
|
|
});
|
|
});
|