diff --git a/readme.md b/readme.md index ef039327..b334614c 100644 --- a/readme.md +++ b/readme.md @@ -203,6 +203,10 @@ edit.registerAssetGenerator(async ({ clipId, asset, signal }) => { }); ``` +A host can trigger the same generation directly, without the toolbar: `await +edit.generateClip(clipId)` runs it for a prompt-bearing clip. Track progress with the events +`"clip:generationStarted"`, `"clip:generationCompleted"` and `"clip:generationFailed"`. + Pass a model catalogue as the registration's `catalogue` option to show model and option controls. Entries must include their option schema; those without one are ignored. The [Edit API](https://shotstack.io/docs/api/#shotstack-edit) returns this shape from diff --git a/src/components/canvas/players/generation/state-binding.ts b/src/components/canvas/players/generation/state-binding.ts index 9b41e966..ad9516a0 100644 --- a/src/components/canvas/players/generation/state-binding.ts +++ b/src/components/canvas/players/generation/state-binding.ts @@ -1,5 +1,5 @@ import type { Edit } from "@core/edit-session"; -import { InternalEvent } from "@core/events/edit-events"; +import { EditEvent } from "@core/events/edit-events"; import type { AiPendingOverlay } from "./pending-overlay"; @@ -20,13 +20,13 @@ export function bindGenerationState(edit: Edit, clipId: string | null, overlay: }; const events = edit.getInternalEvents(); - events.on(InternalEvent.ClipGenerationStarted, onStarted); - events.on(InternalEvent.ClipGenerationCompleted, onCompleted); - events.on(InternalEvent.ClipGenerationFailed, onFailed); + events.on(EditEvent.ClipGenerationStarted, onStarted); + events.on(EditEvent.ClipGenerationCompleted, onCompleted); + events.on(EditEvent.ClipGenerationFailed, onFailed); return () => { - events.off(InternalEvent.ClipGenerationStarted, onStarted); - events.off(InternalEvent.ClipGenerationCompleted, onCompleted); - events.off(InternalEvent.ClipGenerationFailed, onFailed); + events.off(EditEvent.ClipGenerationStarted, onStarted); + events.off(EditEvent.ClipGenerationCompleted, onCompleted); + events.off(EditEvent.ClipGenerationFailed, onFailed); }; } diff --git a/src/components/timeline/timeline-state.ts b/src/components/timeline/timeline-state.ts index 240bf9e7..cbfb2f6c 100644 --- a/src/components/timeline/timeline-state.ts +++ b/src/components/timeline/timeline-state.ts @@ -33,9 +33,9 @@ export class TimelineStateManager { // Listen on clip/timeline events this.edit.events.on(EditEvent.ClipUpdated, this.invalidateCache); this.edit.events.on(EditEvent.TimelineUpdated, this.invalidateCache); - this.edit.getInternalEvents().on(InternalEvent.ClipGenerationStarted, this.invalidateCache); - this.edit.getInternalEvents().on(InternalEvent.ClipGenerationCompleted, this.invalidateCache); - this.edit.getInternalEvents().on(InternalEvent.ClipGenerationFailed, this.invalidateCache); + this.edit.events.on(EditEvent.ClipGenerationStarted, this.invalidateCache); + this.edit.events.on(EditEvent.ClipGenerationCompleted, this.invalidateCache); + this.edit.events.on(EditEvent.ClipGenerationFailed, this.invalidateCache); // Selection changes are UI state (not document mutations) this.edit.events.on(EditEvent.ClipSelected, this.invalidateCache); @@ -238,9 +238,9 @@ export class TimelineStateManager { this.edit.getInternalEvents().off(InternalEvent.Resolved, this.invalidateCache); this.edit.events.off(EditEvent.ClipUpdated, this.invalidateCache); this.edit.events.off(EditEvent.TimelineUpdated, this.invalidateCache); - this.edit.getInternalEvents().off(InternalEvent.ClipGenerationStarted, this.invalidateCache); - this.edit.getInternalEvents().off(InternalEvent.ClipGenerationCompleted, this.invalidateCache); - this.edit.getInternalEvents().off(InternalEvent.ClipGenerationFailed, this.invalidateCache); + this.edit.events.off(EditEvent.ClipGenerationStarted, this.invalidateCache); + this.edit.events.off(EditEvent.ClipGenerationCompleted, this.invalidateCache); + this.edit.events.off(EditEvent.ClipGenerationFailed, this.invalidateCache); this.edit.events.off(EditEvent.ClipSelected, this.invalidateCache); this.edit.events.off(EditEvent.SelectionCleared, this.invalidateCache); this.edit.getInternalEvents().off(InternalEvent.ClipFocused, this.onClipFocused); diff --git a/src/components/timeline/timeline.ts b/src/components/timeline/timeline.ts index c43ff6c1..3c1741c4 100644 --- a/src/components/timeline/timeline.ts +++ b/src/components/timeline/timeline.ts @@ -259,9 +259,9 @@ export class Timeline { // Listen for clip load failures (to show error badge on timeline) this.edit.events.on(EditEvent.ClipLoadFailed, this.handleClipLoadFailed); - this.edit.getInternalEvents().on(InternalEvent.ClipGenerationStarted, this.handleClipGeneration); - this.edit.getInternalEvents().on(InternalEvent.ClipGenerationCompleted, this.handleClipGeneration); - this.edit.getInternalEvents().on(InternalEvent.ClipGenerationFailed, this.handleClipGeneration); + this.edit.events.on(EditEvent.ClipGenerationStarted, this.handleClipGeneration); + this.edit.events.on(EditEvent.ClipGenerationCompleted, this.handleClipGeneration); + this.edit.events.on(EditEvent.ClipGenerationFailed, this.handleClipGeneration); // Listen for focus changes (source popup hover-to-highlight) const internal = this.edit.getInternalEvents(); @@ -281,9 +281,9 @@ export class Timeline { this.edit.events.off(EditEvent.ClipSelected, this.handleClipSelected); this.edit.events.off(EditEvent.ClipUpdated, this.handleClipUpdated); this.edit.events.off(EditEvent.ClipLoadFailed, this.handleClipLoadFailed); - this.edit.getInternalEvents().off(InternalEvent.ClipGenerationStarted, this.handleClipGeneration); - this.edit.getInternalEvents().off(InternalEvent.ClipGenerationCompleted, this.handleClipGeneration); - this.edit.getInternalEvents().off(InternalEvent.ClipGenerationFailed, this.handleClipGeneration); + this.edit.events.off(EditEvent.ClipGenerationStarted, this.handleClipGeneration); + this.edit.events.off(EditEvent.ClipGenerationCompleted, this.handleClipGeneration); + this.edit.events.off(EditEvent.ClipGenerationFailed, this.handleClipGeneration); const internal = this.edit.getInternalEvents(); internal.off(InternalEvent.ClipFocused, this.handleClipFocusChanged); diff --git a/src/core/edit-session.ts b/src/core/edit-session.ts index 707587fe..7c7514c6 100644 --- a/src/core/edit-session.ts +++ b/src/core/edit-session.ts @@ -173,9 +173,9 @@ export class Edit { this.assetGenerator = new AssetGenerator({ getClipAsset: clipId => this.getResolvedClipById(clipId)?.asset as Record | undefined, applyGeneratedSrc: (clipId, url) => this.applyGeneratedSrc(clipId, url), - emitStarted: clipId => this.internalEvents.emit(InternalEvent.ClipGenerationStarted, { clipId }), - emitCompleted: clipId => this.internalEvents.emit(InternalEvent.ClipGenerationCompleted, { clipId }), - emitFailed: (clipId, error) => this.internalEvents.emit(InternalEvent.ClipGenerationFailed, { clipId, error }) + emitStarted: clipId => this.internalEvents.emit(EditEvent.ClipGenerationStarted, { clipId }), + emitCompleted: clipId => this.internalEvents.emit(EditEvent.ClipGenerationCompleted, { clipId }), + emitFailed: (clipId, error) => this.internalEvents.emit(EditEvent.ClipGenerationFailed, { clipId, error }) }); this.mergeFieldService = new MergeFieldService(this.internalEvents); this.outputSettings = new OutputSettingsManager(this); @@ -466,12 +466,12 @@ export class Edit { * Generate the asset for a prompt-bearing clip and write the result to it. * * Rejects only when no generator is registered or the clip has nothing to generate from. - * A generation failure resolves and surfaces as `failed` clip state plus a - * `ClipGenerationFailed` event. A clip removed mid-flight resolves writing nothing, silently. - * A second call while one is in flight for the same clip is ignored. - * @internal + * A generation failure resolves and surfaces as a `clip:generationFailed` event. Removing + * the clip, reloading the edit or disposing it resolves writing nothing, and no completed + * or failed event follows the `clip:generationStarted` already emitted. A second call while + * one is in flight for the same clip is ignored. */ - public generateClipAsset(clipId: string): Promise { + public generateClip(clipId: string): Promise { return this.assetGenerator.generate(clipId); } diff --git a/src/core/events/edit-events.ts b/src/core/events/edit-events.ts index 44338faa..35f07117 100644 --- a/src/core/events/edit-events.ts +++ b/src/core/events/edit-events.ts @@ -70,6 +70,9 @@ export const EditEvent = { ClipCaptureStarted: "clip:captureStarted", ClipCaptureCompleted: "clip:captureCompleted", ClipCaptureFailed: "clip:captureFailed", + ClipGenerationStarted: "clip:generationStarted", + ClipGenerationCompleted: "clip:generationCompleted", + ClipGenerationFailed: "clip:generationFailed", ClipUnresolved: "clip:unresolved", // Selection @@ -135,10 +138,7 @@ export const InternalEvent = { ClipBlurred: "clip:blurred", // Asset generation UI - AssetGeneratorChanged: "assetGenerator:changed", - ClipGenerationStarted: "clip:generationStarted", - ClipGenerationCompleted: "clip:generationCompleted", - ClipGenerationFailed: "clip:generationFailed" + AssetGeneratorChanged: "assetGenerator:changed" } as const; // ───────────────────────────────────────────────────────────── @@ -166,6 +166,9 @@ export type EditEventMap = { [EditEvent.ClipCaptureStarted]: { clipId: string | null; assetType: string }; [EditEvent.ClipCaptureCompleted]: { clipId: string | null; assetType: string; frameCount: number }; [EditEvent.ClipCaptureFailed]: { clipId: string | null; assetType: string; error: string; fallback: string }; + [EditEvent.ClipGenerationStarted]: { clipId: string }; + [EditEvent.ClipGenerationCompleted]: { clipId: string }; + [EditEvent.ClipGenerationFailed]: { clipId: string; error: string }; [EditEvent.ClipUnresolved]: ClipLocation & { assetType: string; clipId: string }; // Selection @@ -234,7 +237,4 @@ export type InternalEventMap = { // Asset generation UI [InternalEvent.AssetGeneratorChanged]: void; - [InternalEvent.ClipGenerationStarted]: { clipId: string }; - [InternalEvent.ClipGenerationCompleted]: { clipId: string }; - [InternalEvent.ClipGenerationFailed]: { clipId: string; error: string }; }; diff --git a/src/core/ui/generate-toolbar.ts b/src/core/ui/generate-toolbar.ts index 27972bb5..ac91d669 100644 --- a/src/core/ui/generate-toolbar.ts +++ b/src/core/ui/generate-toolbar.ts @@ -173,7 +173,7 @@ export class GenerateToolbar extends BaseToolbar { if (this.edit.getClipGenerationState(clipId)?.status === "generating") return; // A generation failure surfaces as clip state; a rejection means the clip could not be // generated at all — no handler registered, or nothing on the asset to generate from. - this.edit.generateClipAsset(clipId).catch((error: unknown) => { + this.edit.generateClip(clipId).catch((error: unknown) => { console.warn(`Generate: ${error instanceof Error ? error.message : String(error)}`); }); } @@ -182,7 +182,7 @@ export class GenerateToolbar extends BaseToolbar { // mount() can run more than once on an instance; never stack listeners. if (this.generationUnsubscribers.length > 0) return; const events = this.edit.getInternalEvents(); - const names = [InternalEvent.ClipGenerationStarted, InternalEvent.ClipGenerationCompleted, InternalEvent.ClipGenerationFailed] as const; + const names = [EditEvent.ClipGenerationStarted, EditEvent.ClipGenerationCompleted, EditEvent.ClipGenerationFailed] as const; for (const name of names) { const handler = (payload: { clipId: string }): void => { if (payload.clipId === this.getSelectedClipId()) this.syncState(); diff --git a/src/core/ui/ui-controller.ts b/src/core/ui/ui-controller.ts index 9cf63d90..e88ba761 100644 --- a/src/core/ui/ui-controller.ts +++ b/src/core/ui/ui-controller.ts @@ -385,7 +385,7 @@ export class UIController { // Keep the generate segment's in-flight marker current. const internalEvents = this.edit.getInternalEvents(); - for (const name of [InternalEvent.ClipGenerationStarted, InternalEvent.ClipGenerationCompleted, InternalEvent.ClipGenerationFailed] as const) { + for (const name of [EditEvent.ClipGenerationStarted, EditEvent.ClipGenerationCompleted, EditEvent.ClipGenerationFailed] as const) { const handler = (): void => this.syncGenerateSegments(); internalEvents.on(name, handler); this.generationListeners.push(() => internalEvents.off(name, handler)); diff --git a/test-package.js b/test-package.js index 67400ee3..ab32cc6e 100644 --- a/test-package.js +++ b/test-package.js @@ -60,7 +60,15 @@ const CONTRACT = { "clearSelection(", "registerClipRenderer(" ], - Edit: ["validateEdit(", "getTimelineFonts(", "getContentClipIdForLuma(", "getInternalEvents(", "getGenerationModels(", "pruneUnusedFonts("] + Edit: [ + "validateEdit(", + "getTimelineFonts(", + "getContentClipIdForLuma(", + "getInternalEvents(", + "getGenerationModels(", + "pruneUnusedFonts(", + "getClipGenerationState(" + ] }, dtsForbiddenTokens: [ "export declare class SelectionHandles", @@ -128,7 +136,14 @@ const CONTRACT = { "export declare type Seconds =" ], dtsPublicAnchors: [ - { className: "Edit", tokens: ["load(): Promise;", "registerAssetGenerator(handler: AssetGeneratorHandler, options?: AssetGeneratorOptions): void;"] }, + { + className: "Edit", + tokens: [ + "load(): Promise;", + "registerAssetGenerator(handler: AssetGeneratorHandler, options?: AssetGeneratorOptions): void;", + "generateClip(clipId: string): Promise;" + ] + }, { className: "Canvas", tokens: ["load(): Promise;"] }, { className: "UIController", tokens: ["registerButton(config: ToolbarButtonConfig): this;"] }, { className: "Timeline", tokens: ["load(): Promise;"] } diff --git a/tests/edit-clip-operations.test.ts b/tests/edit-clip-operations.test.ts index d4d386fa..63a4b99e 100644 --- a/tests/edit-clip-operations.test.ts +++ b/tests/edit-clip-operations.test.ts @@ -6,7 +6,7 @@ */ import { Edit } from "@core/edit-session"; -import { InternalEvent } from "@core/events/edit-events"; +import { EditEvent, InternalEvent } from "@core/events/edit-events"; import { PlayerType } from "@canvas/players/player"; import type { EventEmitter } from "@core/events/event-emitter"; import type { Clip, ResolvedClip } from "@schemas"; @@ -594,7 +594,7 @@ describe("Edit Clip Operations", () => { const clip = mergedEdit.getEdit({ includeIds: true }).timeline.tracks[0]?.clips[0] as Clip & { id: string }; mergedEdit.registerAssetGenerator(async () => ({ url: "https://cdn.example.com/speech.mp3" })); - await mergedEdit.generateClipAsset(clip.id); + await mergedEdit.generateClip(clip.id); expect(mergedEdit.getEdit().timeline.tracks[0]?.clips[0]?.asset).toMatchObject({ type: "audio", prompt: "Hello {{ NAME }}" @@ -629,7 +629,7 @@ describe("Edit Clip Operations", () => { const clip = mergedEdit.getEdit({ includeIds: true }).timeline.tracks[0]?.clips[0] as Clip & { id: string }; mergedEdit.registerAssetGenerator(async () => ({ url: "https://cdn.example.com/speech.mp3" })); - await mergedEdit.generateClipAsset(clip.id); + await mergedEdit.generateClip(clip.id); expect(mergedEdit.getEdit().timeline.tracks[0]?.clips[0]?.asset).toMatchObject({ type: "audio", @@ -659,7 +659,7 @@ describe("Edit Clip Operations", () => { const clip = mergedEdit.getEdit({ includeIds: true }).timeline.tracks[0]?.clips[0] as Clip & { id: string }; mergedEdit.registerAssetGenerator(async () => ({ url: "https://cdn.example.com/out.mp4" })); - await mergedEdit.generateClipAsset(clip.id); + await mergedEdit.generateClip(clip.id); expect(mergedEdit.getEdit().timeline.tracks[0]?.clips[0]?.asset).toMatchObject({ type: "video", @@ -683,12 +683,12 @@ describe("Edit Clip Operations", () => { refusedEdit.registerAssetGenerator(async () => ({ url: "https://cdn.example.com/out.png" })); const completed: string[] = []; - refusedEdit.getInternalEvents().on(InternalEvent.ClipGenerationCompleted, ({ clipId }) => completed.push(clipId)); + refusedEdit.getInternalEvents().on(EditEvent.ClipGenerationCompleted, ({ clipId }) => completed.push(clipId)); jest .spyOn(refusedEdit as unknown as { executeCommand: () => Promise }, "executeCommand") .mockResolvedValue({ status: "noop", message: "Invalid clip at 0/0" }); - await refusedEdit.generateClipAsset(clip.id); + await refusedEdit.generateClip(clip.id); expect(refusedEdit.getClipGenerationState(clip.id)).toEqual({ status: "failed", error: "Invalid clip at 0/0" }); expect(completed).toEqual([]); @@ -710,7 +710,7 @@ describe("Edit Clip Operations", () => { const doc = (edit as unknown as { document: { getClipId(t: number, c: number): string | null } }).document; const id = doc.getClipId(0, 1) as string; - const pending = edit.generateClipAsset(id); + const pending = edit.generateClip(id); expect(edit.getClipGenerationState(id)?.status).toBe("generating"); await edit.deleteClip(0, 1); @@ -809,7 +809,7 @@ describe("Edit Clip Operations", () => { const seen = blockingGenerator(edit); const id = clipIdAt(edit, 1, 0); - const pending = edit.generateClipAsset(id); + const pending = edit.generateClip(id); expect(edit.getClipGenerationState(id)?.status).toBe("generating"); await edit.deleteTrack(1); @@ -824,7 +824,7 @@ describe("Edit Clip Operations", () => { const seen = blockingGenerator(edit); const id = clipIdAt(edit, 0, 1); - const pending = edit.generateClipAsset(id); + const pending = edit.generateClip(id); expect(edit.getClipGenerationState(id)?.status).toBe("generating"); await edit.undo(); @@ -839,7 +839,7 @@ describe("Edit Clip Operations", () => { const seen = blockingGenerator(edit); const id = clipIdAt(edit, 0, 1); - const pending = edit.generateClipAsset(id); + const pending = edit.generateClip(id); expect(edit.getClipGenerationState(id)?.status).toBe("generating"); await edit.loadEdit({ @@ -863,7 +863,7 @@ describe("Edit Clip Operations", () => { const seen = blockingGenerator(solo); const id = clipIdAt(solo, 0, 0); - const pending = solo.generateClipAsset(id); + const pending = solo.generateClip(id); expect((await solo.deleteClip(0, 0)).status).toBe("noop"); expect(seen.aborted).toBe(false); @@ -892,7 +892,7 @@ describe("Edit Clip Operations", () => { }); const doc = (templated as unknown as { document: { getClipId(t: number, c: number): string | null } }).document; - await templated.generateClipAsset(doc.getClipId(0, 0) as string); + await templated.generateClip(doc.getClipId(0, 0) as string); expect(received).toBe("an illustration of a red apple"); templated.dispose(); diff --git a/tests/generate-toolbar.test.ts b/tests/generate-toolbar.test.ts index 7165ab24..90b1e292 100644 --- a/tests/generate-toolbar.test.ts +++ b/tests/generate-toolbar.test.ts @@ -20,7 +20,7 @@ jest.mock("@styles/inject", () => ({ injectShotstackStyles: jest.fn() })); -import { InternalEvent } from "@core/events/edit-events"; +import { EditEvent } from "@core/events/edit-events"; import type { GenerationAssetType, GenerationModelDefinition, GenerationOptionDefinition } from "@core/generation/model-catalogue"; import { GenerateToolbar } from "@core/ui/generate-toolbar"; @@ -36,7 +36,7 @@ function createMockEdit(asset: Record = { type: "image", prompt getDocument: jest.fn(), hasAssetGenerator: jest.fn().mockReturnValue(true), getClipGenerationState: jest.fn(), - generateClipAsset: jest.fn().mockResolvedValue(undefined), + generateClip: jest.fn().mockResolvedValue(undefined), resolveMergeFields: jest.fn((value: string) => value), updateClip: jest.fn(), deleteClip: jest.fn(), @@ -317,7 +317,7 @@ describe("GenerateToolbar", () => { container.querySelector("[data-action='generate']")?.click(); - expect(edit.generateClipAsset).toHaveBeenCalledWith("clip-1"); + expect(edit.generateClip).toHaveBeenCalledWith("clip-1"); toolbar.dispose(); }); @@ -351,7 +351,7 @@ describe("GenerateToolbar", () => { input?.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); expect(edit.updateClip).toHaveBeenCalledWith(0, 0, expect.objectContaining({ asset: expect.objectContaining({ prompt: "a dog instead" }) })); - expect(edit.generateClipAsset).toHaveBeenCalledWith("clip-1"); + expect(edit.generateClip).toHaveBeenCalledWith("clip-1"); toolbar.dispose(); }); @@ -417,7 +417,7 @@ describe("GenerateToolbar", () => { container.querySelector("[data-action='generate']")?.click(); - expect(edit.generateClipAsset).not.toHaveBeenCalled(); + expect(edit.generateClip).not.toHaveBeenCalled(); toolbar.dispose(); }); @@ -446,7 +446,7 @@ describe("GenerateToolbar", () => { expect(btn?.disabled).toBe(false); btn?.click(); - expect(edit.generateClipAsset).toHaveBeenCalledWith("clip-1"); + expect(edit.generateClip).toHaveBeenCalledWith("clip-1"); input!.value = ""; input?.dispatchEvent(new Event("input", { bubbles: true })); @@ -464,7 +464,7 @@ describe("GenerateToolbar", () => { input?.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); expect(edit.updateClip).toHaveBeenCalledWith(0, 0, expect.objectContaining({ asset: expect.objectContaining({ prompt: "a golden sunset" }) })); - expect(edit.generateClipAsset).toHaveBeenCalledWith("clip-1"); + expect(edit.generateClip).toHaveBeenCalledWith("clip-1"); toolbar.dispose(); }); @@ -481,7 +481,7 @@ describe("GenerateToolbar", () => { expect(edit.updateClip).toHaveBeenCalledWith(0, 0, { asset: expect.objectContaining({ prompt: undefined }) }); expect(document.removeClipBinding).toHaveBeenCalledWith("clip-1", "asset.prompt"); - expect(edit.generateClipAsset).not.toHaveBeenCalled(); + expect(edit.generateClip).not.toHaveBeenCalled(); toolbar.dispose(); }); @@ -492,7 +492,7 @@ describe("GenerateToolbar", () => { toolbar.mount(container); - [InternalEvent.ClipGenerationStarted, InternalEvent.ClipGenerationCompleted, InternalEvent.ClipGenerationFailed].forEach(event => { + [EditEvent.ClipGenerationStarted, EditEvent.ClipGenerationCompleted, EditEvent.ClipGenerationFailed].forEach(event => { expect(edit.getInternalEvents().on.mock.calls.filter(([name]) => name === event)).toHaveLength(1); }); diff --git a/tests/generation-public-api.test.ts b/tests/generation-public-api.test.ts new file mode 100644 index 00000000..ffcf9b68 --- /dev/null +++ b/tests/generation-public-api.test.ts @@ -0,0 +1,158 @@ +import { Edit } from "@core/edit-session"; +import { EditEvent } from "@core/events/edit-events"; + +import type { Clip } from "@schemas"; + +// A prompt-bearing clip has no src, so PlayerFactory routes it to a pending +// placeholder player. Constructing one for real still needs pixi.js mocked — +// see edit-clip-operations.test.ts for the fuller player-mock pattern. +jest.mock("pixi-filters", () => ({ + AdjustmentFilter: jest.fn().mockImplementation(() => ({})), + BloomFilter: jest.fn().mockImplementation(() => ({})), + GlowFilter: jest.fn().mockImplementation(() => ({})), + OutlineFilter: jest.fn().mockImplementation(() => ({})), + DropShadowFilter: jest.fn().mockImplementation(() => ({})) +})); + +jest.mock("pixi.js", () => { + const createMockContainer = (): Record => { + const children: unknown[] = []; + const self = { + children, + sortableChildren: true, + parent: null as unknown, + label: null as string | null, + zIndex: 0, + visible: true, + destroyed: false, + addChild: jest.fn((child: { parent?: unknown }) => { + children.push(child); + if (typeof child === "object" && child !== null) { + // eslint-disable-next-line no-param-reassign -- Intentional mock of Pixi.js Container behavior + child.parent = self; + } + return child; + }), + removeChild: jest.fn((child: unknown) => { + const idx = children.indexOf(child); + if (idx !== -1) children.splice(idx, 1); + return child; + }), + removeChildAt: jest.fn(), + getChildByLabel: jest.fn(() => null), + getChildIndex: jest.fn(() => 0), + destroy: jest.fn(() => { + self.destroyed = true; + }), + setMask: jest.fn() + }; + return self; + }; + + const createMockGraphics = (): Record => ({ + fillStyle: {}, + rect: jest.fn().mockReturnThis(), + fill: jest.fn().mockReturnThis(), + clear: jest.fn().mockReturnThis(), + stroke: jest.fn().mockReturnThis(), + strokeStyle: {}, + destroy: jest.fn() + }); + + return { + // eslint-disable-next-line global-require, @typescript-eslint/no-require-imports + ...require("./helpers/pixi-mock-filters").pixiFilterStubs, + Container: jest.fn().mockImplementation(createMockContainer), + Graphics: jest.fn().mockImplementation(createMockGraphics), + Sprite: jest.fn().mockImplementation(() => ({ + texture: {}, + width: 100, + height: 100, + parent: null, + anchor: { set: jest.fn() }, + scale: { set: jest.fn() }, + position: { set: jest.fn() }, + destroy: jest.fn() + })), + Texture: { from: jest.fn() }, + Assets: { load: jest.fn().mockResolvedValue({}), unload: jest.fn(), cache: { has: jest.fn().mockReturnValue(false) } }, + ColorMatrixFilter: jest.fn(() => ({ negative: jest.fn() })), + Rectangle: jest.fn() + }; +}); + +jest.mock("@loaders/asset-loader", () => ({ + AssetLoader: jest.fn().mockImplementation(() => ({ + load: jest.fn().mockResolvedValue({}), + unload: jest.fn(), + getProgress: jest.fn().mockReturnValue(100), + incrementRef: jest.fn(), + decrementRef: jest.fn().mockReturnValue(true), + loadTracker: { on: jest.fn(), off: jest.fn() } + })) +})); + +jest.mock("@core/luma-mask-controller", () => ({ + LumaMaskController: jest.fn().mockImplementation(() => ({ + initialize: jest.fn(), + update: jest.fn(), + dispose: jest.fn(), + cleanupForPlayer: jest.fn(), + getActiveMaskCount: jest.fn().mockReturnValue(0) + })) +})); + +const editWithPromptClip = async (): Promise => { + const edit = new Edit({ + timeline: { + tracks: [ + { + clips: [{ asset: { type: "audio", prompt: "a calm harbour at dusk" }, start: 0, length: 5 }] + } + ] + }, + output: { size: { width: 1920, height: 1080 }, format: "mp4" } + }); + await edit.load(); + return edit; +}; + +const clipIdOf = (edit: Edit): string => + (edit.getEdit({ includeIds: true }).timeline.tracks[0]?.clips[0] as Clip & { id: string }).id; + +describe("generation through the public API", () => { + it("reports started then completed when the host handler resolves", async () => { + const edit = await editWithPromptClip(); + const seen: string[] = []; + edit.events.on(EditEvent.ClipGenerationStarted, () => seen.push("started")); + edit.events.on(EditEvent.ClipGenerationCompleted, () => seen.push("completed")); + edit.registerAssetGenerator(async () => ({ url: "https://cdn.example.com/harbour.mp3" })); + + await edit.generateClip(clipIdOf(edit)); + + expect(seen).toEqual(["started", "completed"]); + edit.dispose(); + }); + + it("reports the handler's message on failure, and resolves rather than rejecting", async () => { + const edit = await editWithPromptClip(); + const failures: Array<{ clipId: string; error: string }> = []; + edit.events.on(EditEvent.ClipGenerationFailed, payload => failures.push(payload)); + edit.registerAssetGenerator(async () => { + throw new Error("provider refused the prompt"); + }); + + await expect(edit.generateClip(clipIdOf(edit))).resolves.toBeUndefined(); + + expect(failures).toHaveLength(1); + expect(failures[0]?.error).toBe("provider refused the prompt"); + expect(failures[0]?.clipId).toBe(clipIdOf(edit)); + edit.dispose(); + }); + + it("rejects when no handler is registered", async () => { + const edit = await editWithPromptClip(); + await expect(edit.generateClip(clipIdOf(edit))).rejects.toThrow(/No asset generator registered/); + edit.dispose(); + }); +}); diff --git a/tests/generation-state-binding.test.ts b/tests/generation-state-binding.test.ts index 404287b3..b465fdde 100644 --- a/tests/generation-state-binding.test.ts +++ b/tests/generation-state-binding.test.ts @@ -2,10 +2,10 @@ import type { AiPendingOverlay } from "@canvas/players/generation/pending-overla import { bindGenerationState } from "@canvas/players/generation/state-binding"; import type { Edit } from "@core/edit-session"; import { EventEmitter } from "@core/events/event-emitter"; -import { InternalEvent, type InternalEventMap } from "@core/events/edit-events"; +import { EditEvent, type EditEventMap } from "@core/events/edit-events"; function setup(state: { status: "generating" | "failed"; error?: string }) { - const events = new EventEmitter(); + const events = new EventEmitter(); const edit = { getClipGenerationState: jest.fn(() => state), getInternalEvents: jest.fn(() => events) @@ -23,11 +23,11 @@ describe("generation state binding", () => { const unbind = bindGenerationState(edit, "clip-1", overlay); expect(overlay.setGenerating).toHaveBeenCalledWith(true); - events.emit(InternalEvent.ClipGenerationFailed, { clipId: "clip-1", error: "model unavailable" }); + events.emit(EditEvent.ClipGenerationFailed, { clipId: "clip-1", error: "model unavailable" }); expect(overlay.setFailed).toHaveBeenCalledWith("model unavailable"); unbind(); - events.emit(InternalEvent.ClipGenerationStarted, { clipId: "clip-1" }); + events.emit(EditEvent.ClipGenerationStarted, { clipId: "clip-1" }); expect(overlay.setGenerating).toHaveBeenCalledTimes(1); }); diff --git a/tests/timeline-state-manager.test.ts b/tests/timeline-state-manager.test.ts index c9f9ff8a..0affcb6d 100644 --- a/tests/timeline-state-manager.test.ts +++ b/tests/timeline-state-manager.test.ts @@ -161,14 +161,14 @@ describe("TimelineStateManager", () => { expect(tracks2).not.toBe(tracks1); }); - it.each([InternalEvent.ClipGenerationStarted, InternalEvent.ClipGenerationCompleted, InternalEvent.ClipGenerationFailed])( + it.each([EditEvent.ClipGenerationStarted, EditEvent.ClipGenerationCompleted, EditEvent.ClipGenerationFailed])( "invalidates cache when %s fires", event => { const edit = createMockEdit([[{ asset: { type: "image", prompt: "a lighthouse" }, start: 0, length: 5 }]]); const stateManager = new TimelineStateManager(edit as never); const tracks1 = stateManager.getTracks(); - edit.getInternalEvents().emit(event); + edit.events.emit(event); expect(stateManager.getTracks()).not.toBe(tracks1); } @@ -183,9 +183,9 @@ describe("TimelineStateManager", () => { expect(edit.events.off).toHaveBeenCalledWith(InternalEvent.Resolved, expect.any(Function)); expect(edit.events.off).toHaveBeenCalledWith(EditEvent.ClipUpdated, expect.any(Function)); expect(edit.events.off).toHaveBeenCalledWith(EditEvent.TimelineUpdated, expect.any(Function)); - expect(edit.getInternalEvents().off).toHaveBeenCalledWith(InternalEvent.ClipGenerationStarted, expect.any(Function)); - expect(edit.getInternalEvents().off).toHaveBeenCalledWith(InternalEvent.ClipGenerationCompleted, expect.any(Function)); - expect(edit.getInternalEvents().off).toHaveBeenCalledWith(InternalEvent.ClipGenerationFailed, expect.any(Function)); + expect(edit.events.off).toHaveBeenCalledWith(EditEvent.ClipGenerationStarted, expect.any(Function)); + expect(edit.events.off).toHaveBeenCalledWith(EditEvent.ClipGenerationCompleted, expect.any(Function)); + expect(edit.events.off).toHaveBeenCalledWith(EditEvent.ClipGenerationFailed, expect.any(Function)); expect(edit.events.off).toHaveBeenCalledWith(EditEvent.ClipSelected, expect.any(Function)); expect(edit.events.off).toHaveBeenCalledWith(EditEvent.SelectionCleared, expect.any(Function)); });