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
+679
@@ -0,0 +1,679 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user