- Adicionado estrutura completa do projeto - Configurado MCP server para Premiere Pro - Adicionado documentação e skills - Configurado Gitignore para o projeto
680 lines
31 KiB
TypeScript
Executable File
680 lines
31 KiB
TypeScript
Executable File
import { createRequire } from "node:module";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
|
|
const require = createRequire(import.meta.url);
|
|
const Commands = require("../../uxp-plugin/commands.cjs");
|
|
const Events = require("../../uxp-plugin/events.cjs");
|
|
const NextWorkflows = require("../../uxp-plugin/next-workflows.cjs");
|
|
const Protocol = require("../../uxp-plugin/protocol.cjs");
|
|
|
|
function registry() {
|
|
const events = Events.createEventJournal({ capacity: 16 });
|
|
const ppro = { Project: { getActiveProject: vi.fn(async () => null) } };
|
|
return { events, registry: Commands.createCommandRegistry({ ppro, Protocol, events }) };
|
|
}
|
|
|
|
describe("next-wave UXP event workflows", () => {
|
|
it("advertises event listing only when a journal is present", async () => {
|
|
await expect(registry().registry.capabilities()).resolves.toMatchObject({
|
|
commands: {
|
|
"events.list": { supported: true, readOnly: true, minHostVersion: "25.6.0" },
|
|
"events.wait": { supported: true, readOnly: true, minHostVersion: "25.6.0" },
|
|
},
|
|
});
|
|
const withoutEvents = Commands.createCommandRegistry({
|
|
ppro: { Project: { getActiveProject: vi.fn(async () => null) } }, Protocol,
|
|
});
|
|
await expect(withoutEvents.capabilities()).resolves.toMatchObject({
|
|
commands: { "events.list": { supported: false } },
|
|
});
|
|
});
|
|
|
|
it("lists and filters safe host receipts", async () => {
|
|
const value = registry();
|
|
value.events.append({ category: "project", name: "project.dirty" });
|
|
value.events.append({ category: "encoder", name: "encoder.complete" });
|
|
await expect(value.registry.dispatch("events.list", {
|
|
afterRevision: 0, categories: ["encoder"], limit: 10,
|
|
})).resolves.toMatchObject({
|
|
latestRevision: 2,
|
|
events: [{ revision: 2, category: "encoder", name: "encoder.complete" }],
|
|
});
|
|
});
|
|
|
|
it("rejects unbounded and unexpected event-query arguments", async () => {
|
|
const value = registry();
|
|
await expect(value.registry.dispatch("events.wait", { timeoutMs: 60001 })).rejects.toMatchObject({ code: "UXP_INVALID_ARGUMENT" });
|
|
await expect(value.registry.dispatch("events.list", { rawPayload: true })).rejects.toMatchObject({ code: "UXP_INVALID_ARGUMENT" });
|
|
});
|
|
|
|
it("waits adaptively for sequence video-effect analysis readback", async () => {
|
|
let clock = 0;
|
|
const sequence = {
|
|
guid: "sequence-1",
|
|
isDoneAnalyzingForVideoEffects: vi.fn()
|
|
.mockResolvedValueOnce(false)
|
|
.mockResolvedValueOnce(true),
|
|
};
|
|
const project = {
|
|
getActiveSequence: vi.fn(async () => sequence),
|
|
getSequences: vi.fn(async () => [sequence]),
|
|
};
|
|
const definitions = NextWorkflows.createNextWorkflowDefinitions({
|
|
ppro: { Project: { getActiveProject: vi.fn(async () => project) } },
|
|
events: Events.createEventJournal({ capacity: 16 }),
|
|
now: () => clock,
|
|
sleep: vi.fn(async (milliseconds: number) => { clock += milliseconds; }),
|
|
});
|
|
|
|
await expect(definitions["readiness.analysis.wait"].handler({
|
|
sequenceId: "sequence-1", expectedSequenceId: "sequence-1",
|
|
timeoutMs: 1000, pollMinMs: 100, pollMaxMs: 500,
|
|
})).resolves.toMatchObject({
|
|
ready: true, timedOut: false, sequenceId: "sequence-1", checks: 2,
|
|
elapsedMs: 100, verificationBoundary: "sequence_analysis_readback",
|
|
});
|
|
});
|
|
|
|
it("requires a pre-dispatch revision and classifies operation completion state", async () => {
|
|
const events = Events.createEventJournal({ capacity: 16 });
|
|
const definitions = NextWorkflows.createNextWorkflowDefinitions({
|
|
ppro: {
|
|
Project: { getActiveProject: vi.fn(async () => null) },
|
|
Constants: { OperationCompleteState: { SUCCESS: 0, CANCELLED: 1, FAILED: 2 } },
|
|
},
|
|
events,
|
|
});
|
|
await expect(definitions["readiness.operation.wait"].handler({
|
|
operationType: "import", timeoutMs: 0,
|
|
})).rejects.toMatchObject({ code: "UXP_INVALID_ARGUMENT" });
|
|
|
|
const before = events.status().latestRevision;
|
|
events.recordHostEvent({
|
|
category: "operation", name: "operation.import.complete", detail: { state: 0 },
|
|
});
|
|
await expect(definitions["readiness.operation.wait"].handler({
|
|
operationType: "import", afterRevision: before, timeoutMs: 0,
|
|
})).resolves.toMatchObject({
|
|
ready: true, timedOut: false, outcome: "completed",
|
|
receipt: { name: "operation.import.complete", detail: { state: 0 } },
|
|
verificationBoundary: "operation_terminal_event_only",
|
|
});
|
|
});
|
|
|
|
it("deduplicates project views, redacts paths by default, and verifies Save As retargeting", async () => {
|
|
const project = {
|
|
guid: "project-1", name: "Edit", path: "C:/work/source.prproj",
|
|
saveAs: vi.fn(async (path: string) => { project.path = path; return true; }),
|
|
};
|
|
const ppro = {
|
|
ProjectUtils: {
|
|
getProjectViewIds: vi.fn(async () => ["view-1", "view-2"]),
|
|
getProjectFromViewId: vi.fn(async () => project),
|
|
},
|
|
Project: {
|
|
getActiveProject: vi.fn(async () => project),
|
|
getProject: vi.fn(() => project),
|
|
open: vi.fn(), createProject: vi.fn(), isProject: vi.fn(() => false),
|
|
},
|
|
Guid: { fromString: vi.fn((value: string) => value) },
|
|
};
|
|
const definitions = NextWorkflows.createNextWorkflowDefinitions({
|
|
ppro,
|
|
events: Events.createEventJournal({ capacity: 16 }),
|
|
workspace: { assertPathAllowed: vi.fn(async (path: string) => path) },
|
|
});
|
|
|
|
await expect(definitions["project.sessions.list"].handler({})).resolves.toEqual({
|
|
count: 1,
|
|
activeProjectId: "project-1",
|
|
projects: [{ projectId: "project-1", name: "Edit", hasPath: true }],
|
|
pathDisclosure: "redacted",
|
|
});
|
|
await expect(definitions["project.sessions.saveAs"].handler({
|
|
path: "C:/work/branch.prproj",
|
|
})).rejects.toMatchObject({ code: "UXP_CONFIRMATION_REQUIRED" });
|
|
await expect(definitions["project.sessions.saveAs"].handler({
|
|
path: "C:/work/branch.prproj", expectedPath: "C:/work/source.prproj",
|
|
confirmExternalWrite: true,
|
|
})).resolves.toMatchObject({
|
|
action: "saved_as", projectId: "project-1", path: "C:/work/branch.prproj",
|
|
outcome: "verified", verificationBoundary: "project_path_readback",
|
|
});
|
|
});
|
|
|
|
it("bounds growing-media pauses with a persisted lease and resumes on disposal", async () => {
|
|
const values = new Map<string, string>();
|
|
const storage = {
|
|
getItem: vi.fn((key: string) => values.get(key) ?? null),
|
|
setItem: vi.fn((key: string, value: string) => values.set(key, value)),
|
|
removeItem: vi.fn((key: string) => values.delete(key)),
|
|
};
|
|
const project = {
|
|
guid: "project-1", path: "C:/work/source.prproj",
|
|
pauseGrowing: vi.fn(async () => true),
|
|
};
|
|
const timer = { callback: null as null | (() => void) };
|
|
const runtime = NextWorkflows.createNextWorkflowRuntime({
|
|
ppro: {
|
|
Project: {
|
|
getActiveProject: vi.fn(async () => project),
|
|
getProject: vi.fn(() => project),
|
|
},
|
|
Guid: { fromString: vi.fn((value: string) => value) },
|
|
},
|
|
storage,
|
|
now: () => 1000,
|
|
setTimer: vi.fn((callback: () => void) => { timer.callback = callback; return 7; }),
|
|
clearTimer: vi.fn(),
|
|
});
|
|
|
|
await expect(runtime.definitions["growing.pause"].handler({ leaseMs: 600000 })).rejects.toMatchObject({
|
|
code: "UXP_CONFIRMATION_REQUIRED",
|
|
});
|
|
await expect(runtime.definitions["growing.pause"].handler({
|
|
leaseMs: 5000, confirmPause: true,
|
|
})).resolves.toMatchObject({
|
|
paused: true, projectId: "project-1", leaseMs: 5000,
|
|
outcome: "committed_unverified", verificationBoundary: "project_pauseGrowing_host_return_only",
|
|
});
|
|
expect(storage.setItem).toHaveBeenCalled();
|
|
expect(timer.callback).toBeTypeOf("function");
|
|
expect(runtime.definitions["growing.status"].handler({})).toMatchObject({
|
|
pausedByThisPanel: true, projectId: "project-1", verificationBoundary: "panel_local_lease_only",
|
|
});
|
|
await expect(runtime.dispose()).resolves.toMatchObject({ resumed: true, reason: "panel_or_bridge_disconnect" });
|
|
expect(project.pauseGrowing).toHaveBeenNthCalledWith(1, true);
|
|
expect(project.pauseGrowing).toHaveBeenNthCalledWith(2, false);
|
|
expect(storage.removeItem).toHaveBeenCalled();
|
|
});
|
|
|
|
it("writes namespaced checkpoints in one transaction and verifies typed readback", async () => {
|
|
const values = new Map<string, unknown>();
|
|
const properties = {
|
|
hasValue: vi.fn((key: string) => values.has(key)),
|
|
getValue: vi.fn((key: string) => values.get(key)),
|
|
getValueAsInt: vi.fn((key: string) => values.get(key)),
|
|
getValueAsFloat: vi.fn((key: string) => values.get(key)),
|
|
getValueAsBool: vi.fn((key: string) => values.get(key)),
|
|
createSetValueAction: vi.fn((key: string, value: unknown) => ({ apply: () => values.set(key, value) })),
|
|
createClearValueAction: vi.fn((key: string) => ({ apply: () => values.delete(key) })),
|
|
};
|
|
const project = {
|
|
guid: "project-1",
|
|
getActiveSequence: vi.fn(async () => null),
|
|
lockedAccess: vi.fn((callback: () => void) => callback()),
|
|
executeTransaction: vi.fn((callback: (compound: { addAction: (action: { apply: () => void }) => boolean }) => void) => {
|
|
callback({ addAction: (action) => { action.apply(); return true; } });
|
|
return true;
|
|
}),
|
|
};
|
|
const definitions = NextWorkflows.createNextWorkflowDefinitions({
|
|
ppro: {
|
|
Project: { getActiveProject: vi.fn(async () => project) },
|
|
Properties: {
|
|
getProperties: vi.fn(async () => properties),
|
|
PROPERTY_PERSISTENT: 1,
|
|
PROPERTY_NON_PERSISTENT: 2,
|
|
},
|
|
},
|
|
});
|
|
|
|
await expect(definitions["checkpoint.set"].handler({
|
|
owner: "project", expectedOwnerId: "project-1", name: "render.pass",
|
|
valueType: "int", value: 3, persistence: "persistent",
|
|
})).resolves.toMatchObject({
|
|
owner: "project", ownerId: "project-1", name: "render.pass",
|
|
keyNamespace: "premiereMcp.", exists: true, valueType: "int", value: 3,
|
|
persistence: "persistent", outcome: "verified", verificationBoundary: "typed_property_readback",
|
|
});
|
|
expect(properties.createSetValueAction).toHaveBeenCalledWith("premiereMcp.render.pass", 3, 1);
|
|
await expect(definitions["checkpoint.get"].handler({
|
|
name: "render.pass", valueType: "int",
|
|
})).resolves.toMatchObject({ exists: true, value: 3 });
|
|
await expect(definitions["checkpoint.clear"].handler({ name: "render.pass" })).resolves.toMatchObject({
|
|
cleared: true, exists: false, verificationBoundary: "property_absence_readback",
|
|
});
|
|
expect(project.executeTransaction).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("redacts media paths by default and verifies grouped offline actions", async () => {
|
|
let offline = false;
|
|
const clip = {
|
|
name: "Camera A",
|
|
getId: vi.fn(async () => "clip-1"),
|
|
isOffline: vi.fn(async () => offline),
|
|
canChangeMediaPath: vi.fn(async () => true),
|
|
canProxy: vi.fn(async () => true),
|
|
hasProxy: vi.fn(async () => true),
|
|
isMergedClip: vi.fn(async () => false),
|
|
isMulticamClip: vi.fn(async () => false),
|
|
getMediaFilePath: vi.fn(async () => "C:/private/camera.mov"),
|
|
getProxyPath: vi.fn(async () => "C:/private/proxy.mov"),
|
|
getOriginatingProjectPath: vi.fn(async () => "C:/private/source.prproj"),
|
|
getMedia: vi.fn(async () => ({ start: { seconds: 2 }, duration: { seconds: 5 } })),
|
|
createSetOfflineAction: vi.fn(() => ({ apply: () => { offline = true; } })),
|
|
};
|
|
const project = {
|
|
guid: "project-1",
|
|
lockedAccess: vi.fn((callback: () => void) => callback()),
|
|
executeTransaction: vi.fn((callback: (compound: { addAction: (action: { apply: () => void }) => boolean }) => void) => {
|
|
callback({ addAction: (action) => { action.apply(); return true; } });
|
|
return true;
|
|
}),
|
|
};
|
|
const definitions = NextWorkflows.createNextWorkflowDefinitions({
|
|
ppro: {
|
|
Project: { getActiveProject: vi.fn(async () => project) },
|
|
ProjectUtils: { getSelection: vi.fn(async () => ({ getItems: vi.fn(async () => [clip]) })) },
|
|
ClipProjectItem: { cast: vi.fn((item: unknown) => item) },
|
|
},
|
|
});
|
|
|
|
const inspected = await definitions["media.health.inspect"].handler({});
|
|
expect(inspected).toMatchObject({
|
|
count: 1, pathDisclosure: "redacted",
|
|
items: [{ projectItemId: "clip-1", offline: false, hasProxy: true }],
|
|
});
|
|
expect(inspected.items[0]).not.toHaveProperty("mediaPath");
|
|
expect(inspected.items[0]).not.toHaveProperty("mediaTiming");
|
|
expect(clip.getMedia).not.toHaveBeenCalled();
|
|
await expect(definitions["media.health.setOffline"].handler({
|
|
expectedOffline: false,
|
|
})).rejects.toMatchObject({ code: "UXP_CONFIRMATION_REQUIRED" });
|
|
await expect(definitions["media.health.setOffline"].handler({
|
|
expectedOffline: false, confirmSetOffline: true,
|
|
})).resolves.toMatchObject({
|
|
updated: 1, items: [{ projectItemId: "clip-1", offline: true }],
|
|
outcome: "verified", verificationBoundary: "offline_state_readback",
|
|
});
|
|
});
|
|
|
|
it("reads opt-in media timing through stable properties, including beta promise-shape compatibility", async () => {
|
|
let media: Record<string, unknown> = {
|
|
start: { seconds: 1.25 },
|
|
duration: { seconds: 9.5 },
|
|
};
|
|
const clip = {
|
|
name: "Camera A",
|
|
getId: vi.fn(async () => "clip-1"),
|
|
isOffline: vi.fn(async () => false),
|
|
canChangeMediaPath: vi.fn(async () => true),
|
|
canProxy: vi.fn(async () => true),
|
|
hasProxy: vi.fn(async () => false),
|
|
isMergedClip: vi.fn(async () => false),
|
|
isMulticamClip: vi.fn(async () => false),
|
|
getMedia: vi.fn(async () => media),
|
|
};
|
|
const project = { guid: "project-1" };
|
|
const definitions = NextWorkflows.createNextWorkflowDefinitions({
|
|
ppro: {
|
|
Project: { getActiveProject: vi.fn(async () => project) },
|
|
ProjectUtils: { getSelection: vi.fn(async () => ({ getItems: vi.fn(async () => [clip]) })) },
|
|
ClipProjectItem: { cast: vi.fn((item: unknown) => item) },
|
|
},
|
|
});
|
|
|
|
await expect(definitions["media.health.inspect"].handler({ includeMediaTiming: true })).resolves.toMatchObject({
|
|
items: [{
|
|
mediaTiming: {
|
|
available: true,
|
|
startSeconds: 1.25,
|
|
durationSeconds: 9.5,
|
|
startAccessor: "start",
|
|
durationAccessor: "duration",
|
|
},
|
|
}],
|
|
});
|
|
|
|
const betaPropertyMedia = {
|
|
getStart: vi.fn(async () => ({ seconds: 99 })),
|
|
getDuration: vi.fn(async () => ({ seconds: 99 })),
|
|
start: Promise.resolve({ seconds: 3 }),
|
|
duration: Promise.resolve({ seconds: 12 }),
|
|
};
|
|
media = betaPropertyMedia;
|
|
await expect(definitions["media.health.inspect"].handler({ includeMediaTiming: true })).resolves.toMatchObject({
|
|
items: [{
|
|
mediaTiming: {
|
|
available: true,
|
|
startSeconds: 3,
|
|
durationSeconds: 12,
|
|
startAccessor: "start",
|
|
durationAccessor: "duration",
|
|
},
|
|
}],
|
|
});
|
|
expect(betaPropertyMedia.getStart).not.toHaveBeenCalled();
|
|
expect(betaPropertyMedia.getDuration).not.toHaveBeenCalled();
|
|
|
|
const failedPropertyMedia = {
|
|
getStart: vi.fn(async () => ({ seconds: 99 })),
|
|
getDuration: vi.fn(async () => ({ seconds: 99 })),
|
|
get start() { throw new Error("start unavailable"); },
|
|
duration: { seconds: 7 },
|
|
};
|
|
media = failedPropertyMedia;
|
|
await expect(definitions["media.health.inspect"].handler({ includeMediaTiming: true })).resolves.toMatchObject({
|
|
items: [{
|
|
mediaTiming: {
|
|
available: false,
|
|
startSeconds: null,
|
|
durationSeconds: 7,
|
|
startAccessor: "start",
|
|
durationAccessor: "duration",
|
|
},
|
|
}],
|
|
});
|
|
expect(failedPropertyMedia.getStart).not.toHaveBeenCalled();
|
|
expect(failedPropertyMedia.getDuration).not.toHaveBeenCalled();
|
|
|
|
media = { start: { seconds: -1 }, duration: { seconds: 86400001 } };
|
|
await expect(definitions["media.health.inspect"].handler({ includeMediaTiming: true })).resolves.toMatchObject({
|
|
items: [{
|
|
mediaTiming: {
|
|
available: false,
|
|
startSeconds: null,
|
|
durationSeconds: null,
|
|
startAccessor: "start",
|
|
durationAccessor: "duration",
|
|
},
|
|
}],
|
|
});
|
|
await expect(definitions["media.health.inspect"].handler({ includeMediaTiming: "yes" })).rejects.toMatchObject({
|
|
code: "UXP_INVALID_ARGUMENT",
|
|
});
|
|
});
|
|
|
|
it("guards, serializes, replays, and reads back a source-media start-time action", async () => {
|
|
let startSeconds = 10, durationSeconds = 60;
|
|
const media = {
|
|
get start() { return { seconds: startSeconds }; },
|
|
get duration() { return { seconds: durationSeconds }; },
|
|
createSetStartAction: vi.fn((time: { seconds: number }) => ({
|
|
apply: () => {
|
|
startSeconds = time.seconds;
|
|
if (time.seconds === 16) durationSeconds = 59;
|
|
},
|
|
})),
|
|
};
|
|
const clip = {
|
|
getId: vi.fn(async () => "clip-1"),
|
|
getMedia: vi.fn(async () => media),
|
|
};
|
|
const root = { getItems: vi.fn(async () => [clip]) };
|
|
const project = {
|
|
guid: "project-1",
|
|
getRootItem: vi.fn(async () => root),
|
|
lockedAccess: vi.fn((callback: () => void) => callback()),
|
|
executeTransaction: vi.fn((callback: (compound: { addAction: (action: { apply: () => void }) => boolean }) => void) => {
|
|
callback({ addAction: (action) => { action.apply(); return true; } });
|
|
return true;
|
|
}),
|
|
};
|
|
const ppro = {
|
|
Project: { getActiveProject: vi.fn(async () => project) },
|
|
ProjectItem: { cast: vi.fn((item: unknown) => item) },
|
|
ClipProjectItem: { cast: vi.fn((item: unknown) => item) },
|
|
FolderItem: { cast: vi.fn(() => null) },
|
|
TickTime: { createWithSeconds: vi.fn((seconds: number) => ({ seconds })) },
|
|
};
|
|
const registry = Commands.createCommandRegistry({ ppro, Protocol });
|
|
|
|
await expect(registry.dispatch("source.mediaTiming.inspect", { projectItemId: "clip-1" })).resolves.toEqual({
|
|
projectItemId: "clip-1", startSeconds: 10, durationSeconds: 60,
|
|
verificationBoundary: "source_media_timing_readback",
|
|
});
|
|
await expect(registry.dispatch("source.mediaTiming.setStart", {
|
|
projectItemId: "clip-1", expectedTiming: { startSeconds: 10, durationSeconds: 60 }, startSeconds: 12,
|
|
})).rejects.toMatchObject({ code: "UXP_CONFIRMATION_REQUIRED" });
|
|
expect(media.createSetStartAction).not.toHaveBeenCalled();
|
|
await expect(registry.dispatch("source.mediaTiming.setStart", {
|
|
projectItemId: "clip-1", expectedTiming: { startSeconds: 10 }, startSeconds: 12, confirmSetStart: true,
|
|
})).rejects.toMatchObject({ code: "UXP_INVALID_ARGUMENT" });
|
|
expect(media.createSetStartAction).not.toHaveBeenCalled();
|
|
|
|
const firstArgs = {
|
|
projectItemId: "clip-1", expectedTiming: { startSeconds: 10, durationSeconds: 60 },
|
|
startSeconds: 12, confirmSetStart: true, operationId: "source-start-1",
|
|
};
|
|
await expect(registry.dispatch("source.mediaTiming.setStart", firstArgs)).resolves.toMatchObject({
|
|
updated: true, projectItemId: "clip-1", outcome: "verified", operationId: "source-start-1",
|
|
before: { startSeconds: 10, durationSeconds: 60 }, after: { startSeconds: 12, durationSeconds: 60 },
|
|
});
|
|
expect(project.executeTransaction).toHaveBeenCalledWith(expect.any(Function), "Set source media start time");
|
|
await expect(registry.dispatch("source.mediaTiming.setStart", firstArgs)).resolves.toMatchObject({
|
|
replayed: true, operationId: "source-start-1",
|
|
});
|
|
expect(media.createSetStartAction).toHaveBeenCalledTimes(1);
|
|
|
|
const expectedTiming = { startSeconds: 12, durationSeconds: 60 };
|
|
const concurrentFirst = registry.dispatch("source.mediaTiming.setStart", {
|
|
projectItemId: "clip-1", expectedTiming, startSeconds: 15, confirmSetStart: true, operationId: "source-start-2",
|
|
});
|
|
const concurrentSecond = registry.dispatch("source.mediaTiming.setStart", {
|
|
projectItemId: "clip-1", expectedTiming, startSeconds: 18, confirmSetStart: true, operationId: "source-start-3",
|
|
});
|
|
await expect(concurrentFirst).resolves.toMatchObject({
|
|
updated: true, after: { startSeconds: 15, durationSeconds: 60 }, operationId: "source-start-2",
|
|
});
|
|
await expect(concurrentSecond).rejects.toMatchObject({ code: "UXP_STALE_TARGET" });
|
|
expect(media.createSetStartAction).toHaveBeenCalledTimes(2);
|
|
expect(project.executeTransaction).toHaveBeenCalledTimes(2);
|
|
expect(startSeconds).toBe(15);
|
|
|
|
await expect(registry.dispatch("source.mediaTiming.setStart", {
|
|
projectItemId: "clip-1", expectedTiming: { startSeconds: 12, durationSeconds: 60 },
|
|
startSeconds: 19, confirmSetStart: true,
|
|
})).rejects.toMatchObject({ code: "UXP_STALE_TARGET" });
|
|
expect(media.createSetStartAction).toHaveBeenCalledTimes(2);
|
|
|
|
await expect(registry.dispatch("source.mediaTiming.setStart", {
|
|
projectItemId: "clip-1", expectedTiming: { startSeconds: 15, durationSeconds: 60 },
|
|
startSeconds: 16, confirmSetStart: true, operationId: "source-start-4",
|
|
})).rejects.toMatchObject({ code: "UXP_VERIFICATION_FAILED" });
|
|
expect(project.executeTransaction).toHaveBeenCalledTimes(3);
|
|
expect(startSeconds).toBe(16);
|
|
expect(durationSeconds).toBe(59);
|
|
|
|
const betaPromiseMedia = {
|
|
start: Promise.resolve({ seconds: 16 }),
|
|
duration: Promise.resolve({ seconds: 59 }),
|
|
createSetStartAction: vi.fn(),
|
|
};
|
|
clip.getMedia.mockResolvedValue(betaPromiseMedia);
|
|
await expect(registry.dispatch("source.mediaTiming.setStart", {
|
|
projectItemId: "clip-1", expectedTiming: { startSeconds: 16, durationSeconds: 59 },
|
|
startSeconds: 19, confirmSetStart: true,
|
|
})).rejects.toMatchObject({ code: "UXP_COMMAND_UNAVAILABLE" });
|
|
expect(betaPromiseMedia.createSetStartAction).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("guards, serializes, replays, and reads back source-media interpretation overrides", async () => {
|
|
let frameRate = 23.976, pixelAspectRatio = 1;
|
|
const clip = {
|
|
getId: vi.fn(async () => "clip-1"),
|
|
getFootageInterpretation: vi.fn(async () => ({
|
|
getFrameRate: () => frameRate,
|
|
getPixelAspectRatio: () => pixelAspectRatio,
|
|
})),
|
|
createSetOverrideFrameRateAction: vi.fn((value: number) => ({ apply: () => { frameRate = value; } })),
|
|
createSetOverridePixelAspectRatioAction: vi.fn((numerator: number, denominator: number) => ({
|
|
apply: () => { pixelAspectRatio = numerator / denominator; },
|
|
})),
|
|
};
|
|
const root = { getItems: vi.fn(async () => [clip]) };
|
|
const project = {
|
|
guid: "project-1",
|
|
getRootItem: vi.fn(async () => root),
|
|
lockedAccess: vi.fn((callback: () => void) => callback()),
|
|
executeTransaction: vi.fn((callback: (compound: { addAction: (action: { apply: () => void }) => boolean }) => void) => {
|
|
callback({ addAction: (action) => { action.apply(); return true; } });
|
|
return true;
|
|
}),
|
|
};
|
|
const ppro = {
|
|
Project: { getActiveProject: vi.fn(async () => project) },
|
|
ProjectItem: { cast: vi.fn((item: unknown) => item) },
|
|
ClipProjectItem: { cast: vi.fn((item: unknown) => item) },
|
|
FolderItem: { cast: vi.fn(() => null) },
|
|
};
|
|
const registry = Commands.createCommandRegistry({ ppro, Protocol });
|
|
const initial = { projectGuid: "project-1", frameRate: 23.976, pixelAspectRatio: 1 };
|
|
|
|
await expect(registry.dispatch("source.mediaOverrides.inspect", { projectItemId: "clip-1" })).resolves.toEqual({
|
|
...initial,
|
|
projectItemId: "clip-1",
|
|
verificationBoundary: "source_media_effective_interpretation_readback",
|
|
});
|
|
await expect(registry.dispatch("source.mediaOverrides.update", {
|
|
projectItemId: "clip-1", expectedOverrides: initial, frameRate: 25,
|
|
})).rejects.toMatchObject({ code: "UXP_CONFIRMATION_REQUIRED" });
|
|
await expect(registry.dispatch("source.mediaOverrides.update", {
|
|
projectItemId: "clip-1", expectedOverrides: initial, frameRate: 25, confirmMediaInterpretation: true,
|
|
})).rejects.toMatchObject({ code: "UXP_INVALID_ARGUMENT" });
|
|
expect(clip.createSetOverrideFrameRateAction).not.toHaveBeenCalled();
|
|
|
|
const firstArgs = {
|
|
projectItemId: "clip-1", expectedOverrides: initial, frameRate: 25,
|
|
pixelAspectRatio: { numerator: 4, denominator: 3 }, confirmMediaInterpretation: true,
|
|
operationId: "source-override-1",
|
|
};
|
|
await expect(registry.dispatch("source.mediaOverrides.update", firstArgs)).resolves.toMatchObject({
|
|
updated: true, operationId: "source-override-1", outcome: "verified",
|
|
before: { projectGuid: "project-1", projectItemId: "clip-1", frameRate: 23.976, pixelAspectRatio: 1 },
|
|
after: { projectGuid: "project-1", projectItemId: "clip-1", frameRate: 25, pixelAspectRatio: 4 / 3 },
|
|
requested: { frameRate: 25, pixelAspectRatio: { numerator: 4, denominator: 3 } },
|
|
});
|
|
expect(project.executeTransaction).toHaveBeenCalledWith(expect.any(Function), "Set source media interpretation override");
|
|
await expect(registry.dispatch("source.mediaOverrides.update", firstArgs)).resolves.toMatchObject({
|
|
replayed: true, operationId: "source-override-1",
|
|
});
|
|
expect(clip.createSetOverrideFrameRateAction).toHaveBeenCalledTimes(1);
|
|
expect(clip.createSetOverridePixelAspectRatioAction).toHaveBeenCalledTimes(1);
|
|
|
|
const expectedCurrent = { projectGuid: "project-1", frameRate: 25, pixelAspectRatio: 4 / 3 };
|
|
const concurrentFirst = registry.dispatch("source.mediaOverrides.update", {
|
|
projectItemId: "clip-1", expectedOverrides: expectedCurrent, frameRate: 30,
|
|
confirmMediaInterpretation: true, operationId: "source-override-2",
|
|
});
|
|
const concurrentSecond = registry.dispatch("source.mediaOverrides.update", {
|
|
projectItemId: "clip-1", expectedOverrides: expectedCurrent, pixelAspectRatio: { numerator: 16, denominator: 9 },
|
|
confirmMediaInterpretation: true, operationId: "source-override-3",
|
|
});
|
|
await expect(concurrentFirst).resolves.toMatchObject({
|
|
updated: true, operationId: "source-override-2", after: { frameRate: 30, pixelAspectRatio: 4 / 3 },
|
|
});
|
|
await expect(concurrentSecond).rejects.toMatchObject({ code: "UXP_STALE_TARGET" });
|
|
expect(clip.createSetOverrideFrameRateAction).toHaveBeenCalledTimes(2);
|
|
expect(clip.createSetOverridePixelAspectRatioAction).toHaveBeenCalledTimes(1);
|
|
expect(frameRate).toBe(30);
|
|
expect(pixelAspectRatio).toBeCloseTo(4 / 3);
|
|
|
|
await expect(registry.dispatch("source.mediaOverrides.update", {
|
|
projectItemId: "clip-1", expectedOverrides: expectedCurrent, frameRate: 24,
|
|
confirmMediaInterpretation: true, operationId: "source-override-4",
|
|
})).rejects.toMatchObject({ code: "UXP_STALE_TARGET" });
|
|
expect(clip.createSetOverrideFrameRateAction).toHaveBeenCalledTimes(2);
|
|
|
|
clip.createSetOverrideFrameRateAction.mockImplementationOnce(() => ({ apply: () => { frameRate = 29.97; } }));
|
|
await expect(registry.dispatch("source.mediaOverrides.update", {
|
|
projectItemId: "clip-1", expectedOverrides: { projectGuid: "project-1", frameRate: 30, pixelAspectRatio: 4 / 3 }, frameRate: 24,
|
|
confirmMediaInterpretation: true, operationId: "source-override-5",
|
|
})).rejects.toMatchObject({ code: "UXP_VERIFICATION_FAILED" });
|
|
expect(project.executeTransaction).toHaveBeenCalledTimes(3);
|
|
expect(frameRate).toBe(29.97);
|
|
});
|
|
|
|
it("sets caption-track mute state through direct host promises and reads it back", async () => {
|
|
let muted = false;
|
|
const captionTrack = {
|
|
id: 9, name: "English",
|
|
getIndex: vi.fn(async () => 0),
|
|
isMuted: vi.fn(async () => muted),
|
|
setMute: vi.fn(async (value: boolean) => { muted = value; return true; }),
|
|
};
|
|
const sequence = {
|
|
guid: "sequence-1",
|
|
getVideoTrackCount: vi.fn(async () => 0),
|
|
getAudioTrackCount: vi.fn(async () => 0),
|
|
getCaptionTrackCount: vi.fn(async () => 1),
|
|
getCaptionTrack: vi.fn(async () => captionTrack),
|
|
};
|
|
const project = {
|
|
getActiveSequence: vi.fn(async () => sequence),
|
|
getSequences: vi.fn(async () => [sequence]),
|
|
};
|
|
const definitions = NextWorkflows.createNextWorkflowDefinitions({
|
|
ppro: { Project: { getActiveProject: vi.fn(async () => project) } },
|
|
});
|
|
|
|
await expect(definitions["track.state.set"].handler({
|
|
sequenceId: "sequence-1", expectedSequenceId: "sequence-1",
|
|
mediaType: "caption", trackIndices: [0], muted: true, expectedMuted: false,
|
|
})).resolves.toMatchObject({
|
|
sequenceId: "sequence-1", mediaType: "caption", requested: 1, updated: 1, failed: 0,
|
|
tracks: [{ mediaType: "caption", trackIndex: 0, beforeMuted: false, afterMuted: true, verified: true }],
|
|
outcome: "verified", undoable: false, verificationBoundary: "per_track_mute_readback",
|
|
});
|
|
await expect(definitions["track.state.inspect"].handler({
|
|
sequenceId: "sequence-1", mediaType: "all",
|
|
})).resolves.toMatchObject({
|
|
count: 1, tracks: [{ mediaType: "caption", trackIndex: 0, muted: true }],
|
|
});
|
|
});
|
|
|
|
it("commits source trim actions together, verifies time readback, and withholds scale verification", async () => {
|
|
let inSeconds = 1, outSeconds = 9;
|
|
const clip = {
|
|
name: "Camera A",
|
|
getId: vi.fn(() => "clip-1"),
|
|
getInPoint: vi.fn(async () => ({ seconds: inSeconds })),
|
|
getOutPoint: vi.fn(async () => ({ seconds: outSeconds })),
|
|
createSetInOutPointsAction: vi.fn((inPoint: { seconds: number }, outPoint: { seconds: number }) => ({
|
|
apply: () => { inSeconds = inPoint.seconds; outSeconds = outPoint.seconds; },
|
|
})),
|
|
createSetInPointAction: vi.fn(),
|
|
createSetOutPointAction: vi.fn(),
|
|
createClearInOutPointsAction: vi.fn(),
|
|
createSetScaleToFrameSizeAction: vi.fn(() => ({ apply: () => undefined })),
|
|
};
|
|
const root = { getItems: vi.fn(async () => [clip]) };
|
|
const project = {
|
|
getRootItem: vi.fn(async () => root),
|
|
lockedAccess: vi.fn((callback: () => void) => callback()),
|
|
executeTransaction: vi.fn((callback: (compound: { addAction: (action: { apply: () => void }) => boolean }) => void) => {
|
|
callback({ addAction: (action) => { action.apply(); return true; } });
|
|
return true;
|
|
}),
|
|
};
|
|
const definitions = NextWorkflows.createNextWorkflowDefinitions({
|
|
ppro: {
|
|
Project: { getActiveProject: vi.fn(async () => project) },
|
|
ClipProjectItem: { cast: vi.fn((item: unknown) => item) },
|
|
ProjectItem: { cast: vi.fn((item: unknown) => item) },
|
|
FolderItem: { cast: vi.fn(() => null) },
|
|
TickTime: { createWithSeconds: vi.fn((seconds: number) => ({ seconds })) },
|
|
Constants: { MediaType: { VIDEO: 1, AUDIO: 2 } },
|
|
},
|
|
});
|
|
|
|
await expect(definitions["source.clip.update"].handler({
|
|
items: [{
|
|
projectItemId: "clip-1", mediaType: "video", expectedInSeconds: 1, expectedOutSeconds: 9,
|
|
inSeconds: 2, outSeconds: 8,
|
|
}],
|
|
})).resolves.toMatchObject({
|
|
updated: 1, outcome: "verified", verificationBoundary: "source_in_out_readback",
|
|
items: [{ after: { inSeconds: 2, outSeconds: 8 }, trimVerified: true, scaleToFrameRequested: false }],
|
|
});
|
|
await expect(definitions["source.clip.update"].handler({
|
|
items: [{ projectItemId: "clip-1", mediaType: "video", scaleToFrame: true }],
|
|
})).resolves.toMatchObject({
|
|
outcome: "committed_unverified",
|
|
verificationBoundary: "transaction_commit_with_missing_clear_or_scale_getter",
|
|
items: [{ scaleToFrameRequested: true, scaleToFrameVerified: false }],
|
|
});
|
|
expect(project.executeTransaction).toHaveBeenCalledTimes(2);
|
|
});
|
|
});
|