From 8b57e9931360a62f0089813eab68603a59003ed5 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Mon, 31 Aug 2026 12:44:47 +1000 Subject: [PATCH 1/3] feat: let a host annotate the generate action --- readme.md | 8 ++ src/core/edit-session.ts | 64 ++++++++++++- src/core/events/edit-events.ts | 24 ++++- src/core/ui/generate-toolbar.ts | 35 ++++++-- src/index.ts | 1 + src/styles/ui/generate-toolbar.css | 8 ++ test-package.js | 6 +- tests/edit-clip-operations.test.ts | 140 +++++++++++++++++++++++++++++ tests/generate-toolbar.test.ts | 93 ++++++++++++++++++- 9 files changed, 368 insertions(+), 11 deletions(-) diff --git a/readme.md b/readme.md index 6e56e4c9..3cc69b70 100644 --- a/readme.md +++ b/readme.md @@ -213,6 +213,7 @@ Available event names: | Duration | `duration:changed` | | Output | `output:resized`, `output:resolutionChanged`, `output:aspectRatioChanged`, `output:fpsChanged`, `output:formatChanged`, `output:destinationsChanged` | | Merge fields | `mergefield:changed` | +| Generation | `generation:configChanged` | ### Generating assets from prompts @@ -237,6 +238,13 @@ controls. Entries must include their option schema; those without one are ignore `GET /models?expand=options`. The SDK stores a snapshot; fetching and refreshing it remain the host's responsibility. +Whenever the selected clip's model, options, length or prompt changes, the editor emits +`generation:configChanged` with what it knows: `clipId`, `type`, `model`, `options`, the +resolved `length` in seconds (`undefined` for `auto`), and the resolved `prompt`. Reply with +`edit.setGenerationStatus(clipId, { text, tone })` to show a line beside Generate; a `tone` +of `error` also disables it. Pass `undefined` to clear. Clearing the prompt or deleting the +clip emits nothing, so also clear any held state on `clip:selected` or `edit:changed`. + The SDK writes the returned URL to the clip, so the change is undoable and autosaves like any other edit. It tracks whether a clip is generating or has failed, and renders those states; a rejection's message is shown as-is next to a retry action. Everything diff --git a/src/core/edit-session.ts b/src/core/edit-session.ts index e066963a..4be6d49d 100644 --- a/src/core/edit-session.ts +++ b/src/core/edit-session.ts @@ -23,13 +23,21 @@ import { SetUpdatedClipCommand } from "@core/commands/set-updated-clip-command"; import { type TimingUpdateParams, UpdateClipTimingCommand } from "@core/commands/update-clip-timing-command"; import { UpdateTextContentCommand } from "@core/commands/update-text-content-command"; import type { MergeFieldBinding } from "@core/edit-document"; -import { EditEvent, InternalEvent, type EditEventMap, type InternalEventMap } from "@core/events/edit-events"; +import { + EditEvent, + InternalEvent, + type EditEventMap, + type InternalEventMap, + type GenerationConfig, + type GenerationStatus +} from "@core/events/edit-events"; import { EventEmitter, type ReadonlyEventEmitter } from "@core/events/event-emitter"; import { parseFontFamily } from "@core/fonts/font-config"; import { LumaMaskController } from "@core/luma-mask-controller"; import { MergeFieldService, type SerializedMergeField } from "@core/merge"; import { calculateSizeFromPreset, OutputSettingsManager } from "@core/output-settings-manager"; import { SelectionManager } from "@core/selection-manager"; +import { GENERATION_TYPE, isAiAsset, promptProperty } from "@core/shared/ai-asset-utils"; import { findEligibleSourceClips, ensureClipAlias } from "@core/shared/source-clip-finder"; import { deepMerge, nextFrame, setNestedValue, toLoadUrl } from "@core/shared/utils"; import { calculateTimelineEnd, resolveAutoLength, resolveAutoStart } from "@core/timing/resolver"; @@ -123,6 +131,8 @@ export class Edit { // ─── Internal Bookkeeping ───────────────────────────────────────────────── private clipsToDispose = new Set(); private clipErrors = new Map(); + private generationStatuses = new Map(); + private lastGenerationConfigKey: string | null = null; private playerByClipId = new Map(); private lumaContentRelations = new Map(); private fontMetadata = new Map(); @@ -140,6 +150,36 @@ export class Edit { this.assetGenerator.abortMissing(live); }; + // Clearing the key on deselection lets a re-selected clip announce itself again. + private emitGenerationConfig = (): void => { + const clipId = this.getSelectedClipInfo()?.player.clipId ?? null; + const resolved = clipId ? this.getResolvedClipById(clipId) : null; + const raw = clipId ? this.getDocumentClipById(clipId) : null; + if (!clipId || !resolved || !raw || !isAiAsset(resolved.asset)) { + this.lastGenerationConfigKey = null; + return; + } + + const asset = resolved.asset as unknown as Record; + const type = GENERATION_TYPE[String(asset["type"])]; + const promptValue = asset[promptProperty(resolved.asset)]; + const { options } = asset; + const config: GenerationConfig = { + clipId, + type, + ...(typeof asset["model"] === "string" ? { model: asset["model"] } : {}), + options: typeof options === "object" && options !== null && !Array.isArray(options) ? (options as Record) : {}, + // The resolver fabricates a placeholder for "auto", so only the raw clip can say the length is unknown. + length: raw.length === "auto" ? undefined : resolved.length, + prompt: typeof promptValue === "string" ? promptValue : "" + }; + + const key = JSON.stringify(config); + if (key === this.lastGenerationConfigKey) return; + this.lastGenerationConfigKey = key; + this.internalEvents.emit(EditEvent.GenerationConfigChanged, config); + }; + /** * Create an Edit instance from a template configuration. */ @@ -291,7 +331,9 @@ export class Edit { this.generationSettings = undefined; this.clearClips(); this.internalEvents.off(InternalEvent.Resolved, this.onResolvedForGeneration); + for (const name of Edit.GenerationConfigTriggers) this.internalEvents.off(name, this.emitGenerationConfig); this.assetGenerator.abortAll(); + this.generationStatuses.clear(); this.lumaMaskController.dispose(); this.playerReconciler.dispose(); @@ -452,6 +494,17 @@ export class Edit { this.internalEvents.emit(InternalEvent.AssetGeneratorChanged); } + public setGenerationStatus(clipId: string, status: GenerationStatus | undefined): void { + if (status === undefined) this.generationStatuses.delete(clipId); + else this.generationStatuses.set(clipId, status); + this.internalEvents.emit(InternalEvent.GenerationStatusChanged, { clipId }); + } + + /** @internal */ + public getGenerationStatus(clipId: string): GenerationStatus | undefined { + return this.generationStatuses.get(clipId); + } + /** @internal */ public registerGenerationSettings(handler: GenerationSettingsHandler): () => void { const callback: GenerationSettingsHandler = request => handler(request); @@ -2704,8 +2757,17 @@ export class Edit { // ─── Event Listeners ───────────────────────────────────────────────────────── + private static readonly GenerationConfigTriggers = [ + EditEvent.ClipSelected, + EditEvent.SelectionCleared, + EditEvent.EditChanged, + EditEvent.MergeFieldChanged, + EditEvent.TimelineUpdated + ] as const; + private setupGenerationListeners(): void { this.internalEvents.on(InternalEvent.Resolved, this.onResolvedForGeneration); + for (const name of Edit.GenerationConfigTriggers) this.internalEvents.on(name, this.emitGenerationConfig); } private setupIntentListeners(): void { diff --git a/src/core/events/edit-events.ts b/src/core/events/edit-events.ts index 35f07117..e9f7ad4f 100644 --- a/src/core/events/edit-events.ts +++ b/src/core/events/edit-events.ts @@ -101,6 +101,9 @@ export const EditEvent = { // Merge fields MergeFieldChanged: "mergefield:changed", + // Generation + GenerationConfigChanged: "generation:configChanged", + // Timeline UI TimelineResized: "timeline:resized" } as const; @@ -138,9 +141,24 @@ export const InternalEvent = { ClipBlurred: "clip:blurred", // Asset generation UI - AssetGeneratorChanged: "assetGenerator:changed" + AssetGeneratorChanged: "assetGenerator:changed", + GenerationStatusChanged: "generation:statusChanged" } as const; +export type GenerationConfig = { + clipId: string; + type: "image" | "video" | "audio"; + model?: string; + options: Record; + length: number | undefined; + prompt: string; +}; + +export type GenerationStatus = { + text: string; + tone?: "neutral" | "warning" | "error"; +}; + // ───────────────────────────────────────────────────────────── // Event Payload Maps // ───────────────────────────────────────────────────────────── @@ -197,6 +215,9 @@ export type EditEventMap = { // Merge fields [EditEvent.MergeFieldChanged]: { fields: MergeField[] }; + // Generation + [EditEvent.GenerationConfigChanged]: GenerationConfig; + // Timeline UI [EditEvent.TimelineResized]: { height: number }; }; @@ -237,4 +258,5 @@ export type InternalEventMap = { // Asset generation UI [InternalEvent.AssetGeneratorChanged]: void; + [InternalEvent.GenerationStatusChanged]: { clipId: string }; }; diff --git a/src/core/ui/generate-toolbar.ts b/src/core/ui/generate-toolbar.ts index 994f523c..0b563d76 100644 --- a/src/core/ui/generate-toolbar.ts +++ b/src/core/ui/generate-toolbar.ts @@ -13,6 +13,7 @@ import { injectShotstackStyles } from "@styles/inject"; import { BaseToolbar, TOOLBAR_ICONS } from "./base-toolbar"; const PROMPT_DEBOUNCE_MS = 300; + const OPTION_INPUT_TYPE: Readonly> = { boolean: "checkbox", integer: "number", @@ -94,9 +95,7 @@ export class GenerateToolbar extends BaseToolbar { - + `; @@ -186,7 +185,12 @@ 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 = [EditEvent.ClipGenerationStarted, EditEvent.ClipGenerationCompleted, EditEvent.ClipGenerationFailed] as const; + const names = [ + EditEvent.ClipGenerationStarted, + EditEvent.ClipGenerationCompleted, + EditEvent.ClipGenerationFailed, + InternalEvent.GenerationStatusChanged + ] as const; for (const name of names) { const handler = (payload: { clipId: string }): void => { if (payload.clipId === this.getSelectedClipId()) this.syncState(); @@ -411,7 +415,25 @@ export class GenerateToolbar extends BaseToolbar { const missing = this.syncCatalogueControls(record(asset)); const hasGenerator = this.edit.hasAssetGenerator(); this.generateBtn.hidden = !hasGenerator; - if (this.generateNote) this.generateNote.hidden = hasGenerator; + const status = hasGenerator ? this.edit.getGenerationStatus(this.getSelectedClipId() ?? "") : undefined; + if (this.generateNote) { + if (!hasGenerator) { + this.generateNote.textContent = "Generates on render"; + this.generateNote.title = "Rendering generates this from the prompt. Register an asset generator to preview it here."; + delete this.generateNote.dataset["tone"]; + this.generateNote.hidden = false; + } else if (status) { + this.generateNote.textContent = status.text; + this.generateNote.dataset["tone"] = status.tone ?? "neutral"; + this.generateNote.removeAttribute("title"); + this.generateNote.hidden = false; + } else { + this.generateNote.textContent = ""; + delete this.generateNote.dataset["tone"]; + this.generateNote.removeAttribute("title"); + this.generateNote.hidden = true; + } + } if (!hasGenerator) { if (this.generateError) this.generateError.hidden = true; return; @@ -422,7 +444,8 @@ export class GenerateToolbar extends BaseToolbar { const generating = state?.status === "generating"; const hasPrompt = (this.promptInput?.value ?? "").trim() !== ""; const label = this.generateBtn.querySelector("[data-generate-label]"); - this.generateBtn.disabled = generating || !hasPrompt || missing.length > 0; + const blocked = status?.tone === "error"; + this.generateBtn.disabled = generating || !hasPrompt || missing.length > 0 || blocked; this.generateBtn.classList.toggle("is-generating", generating); if (label) { if (generating) label.textContent = "Generating…"; diff --git a/src/index.ts b/src/index.ts index e3b4b33c..48b7e76a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,5 +12,6 @@ export type { UIControllerOptions, ToolbarButtonConfig } from "@core/ui/ui-contr export type { AssetGenerationRequest, AssetGeneratorHandler, AssetGeneratorOptions } from "@core/generation/asset-generator"; export type { EditConfig } from "@core/schemas"; export type { CommandResult } from "@core/commands/types"; +export type { GenerationConfig, GenerationStatus } from "@core/events/edit-events"; export const VERSION = pkg.version; diff --git a/src/styles/ui/generate-toolbar.css b/src/styles/ui/generate-toolbar.css index f79656a7..727ff4a4 100644 --- a/src/styles/ui/generate-toolbar.css +++ b/src/styles/ui/generate-toolbar.css @@ -88,6 +88,14 @@ display: none; } +.ss-ai-note[data-tone="warning"] { + color: #fcd34d; +} + +.ss-ai-note[data-tone="error"] { + color: #fca5a5; +} + .ss-ai-error { max-width: 180px; padding: 0 2px; diff --git a/test-package.js b/test-package.js index a45d9925..2fb8ac89 100644 --- a/test-package.js +++ b/test-package.js @@ -67,7 +67,8 @@ const CONTRACT = { "getInternalEvents(", "getGenerationModels(", "pruneUnusedFonts(", - "getClipGenerationState(" + "getClipGenerationState(", + "getGenerationStatus(" ] }, dtsForbiddenTokens: [ @@ -146,7 +147,8 @@ const CONTRACT = { tokens: [ "load(): Promise;", "registerAssetGenerator(handler: AssetGeneratorHandler, options?: AssetGeneratorOptions): void;", - "generateClip(clipId: string): Promise;" + "generateClip(clipId: string): Promise;", + "setGenerationStatus(clipId: string, status: GenerationStatus | undefined): void;" ] }, { className: "Canvas", tokens: ["load(): Promise;"] }, diff --git a/tests/edit-clip-operations.test.ts b/tests/edit-clip-operations.test.ts index e20fb88b..eb3a5e00 100644 --- a/tests/edit-clip-operations.test.ts +++ b/tests/edit-clip-operations.test.ts @@ -6,6 +6,7 @@ */ import { Edit } from "@core/edit-session"; +import { ShotstackEdit } from "@core/shotstack-edit"; import { EditEvent, InternalEvent } from "@core/events/edit-events"; import { PlayerType } from "@canvas/players/player"; import type { EventEmitter } from "@core/events/event-emitter"; @@ -1810,4 +1811,143 @@ describe("Edit Clip Operations", () => { expect(edit.getClip(0, 2)?.asset?.type).toBe("text"); }); }); + + describe("generation status", () => { + it("stores a status per clip and announces the change", () => { + edit.setGenerationStatus("clip-a", { text: "host line", tone: "neutral" }); + expect(edit.getGenerationStatus("clip-a")).toEqual({ text: "host line", tone: "neutral" }); + expect(emitSpy).toHaveBeenCalledWith(InternalEvent.GenerationStatusChanged, { clipId: "clip-a" }); + }); + + it("clears a status with undefined", () => { + edit.setGenerationStatus("clip-a", { text: "x" }); + edit.setGenerationStatus("clip-a", undefined); + expect(edit.getGenerationStatus("clip-a")).toBeUndefined(); + expect(emitSpy).toHaveBeenLastCalledWith(InternalEvent.GenerationStatusChanged, { clipId: "clip-a" }); + }); + + it("keeps statuses for different clips apart", () => { + edit.setGenerationStatus("clip-a", { text: "a" }); + edit.setGenerationStatus("clip-b", { text: "b", tone: "error" }); + expect(edit.getGenerationStatus("clip-a")).toEqual({ text: "a" }); + expect(edit.getGenerationStatus("clip-b")).toEqual({ text: "b", tone: "error" }); + }); + + it("drops stored statuses on dispose", () => { + edit.setGenerationStatus("clip-a", { text: "a" }); + edit.dispose(); + expect(edit.getGenerationStatus("clip-a")).toBeUndefined(); + }); + }); + + describe("generation:configChanged", () => { + const configs = (spy: jest.SpyInstance) => + spy.mock.calls.filter(([name]) => name === EditEvent.GenerationConfigChanged).map(([, payload]) => payload); + + const promptEdit = async (clip: Record, merge: { find: string; replace: string }[] = []) => { + const e = new Edit({ + timeline: { tracks: [{ clips: [{ start: 0, length: 4, ...clip }] }] }, + ...(merge.length ? { merge } : {}), + output: { size: { width: 1920, height: 1080 }, format: "mp4" } + } as never); + await e.load(); + const spy = jest.spyOn(e.getInternalEvents(), "emit"); + return { e, spy }; + }; + + it("emits the selected clip's configuration on selection", async () => { + const { e, spy } = await promptEdit({ + asset: { type: "audio", prompt: "hello", model: "polly-neural", options: { voice: "Matthew" } } + }); + e.selectClip(0, 0); + expect(configs(spy)).toEqual([ + { clipId: e.getClipId(0, 0), type: "audio", model: "polly-neural", options: { voice: "Matthew" }, length: 4, prompt: "hello" } + ]); + }); + + it("resolves merge fields in the prompt", async () => { + const { e, spy } = await promptEdit({ asset: { type: "image", prompt: "a {{ THING }}" } }, [{ find: "THING", replace: "cat" }]); + e.selectClip(0, 0); + expect(configs(spy)[0]).toMatchObject({ prompt: "a cat" }); + }); + + it("reads a text-to-speech prompt from the text field and reports audio", async () => { + const { e, spy } = await promptEdit({ asset: { type: "text-to-speech", text: "say this", voice: "Matthew" } }); + e.selectClip(0, 0); + expect(configs(spy)[0]).toMatchObject({ type: "audio", prompt: "say this" }); + }); + + it("omits model when the clip has none and reports empty options", async () => { + const { e, spy } = await promptEdit({ asset: { type: "image", prompt: "a cat" } }); + e.selectClip(0, 0); + const [payload] = configs(spy); + expect(payload).not.toHaveProperty("model"); + expect(payload.options).toEqual({}); + }); + + it("reports undefined length for auto", async () => { + const { e, spy } = await promptEdit({ asset: { type: "video", prompt: "pan" }, length: "auto" }); + e.selectClip(0, 0); + expect(configs(spy)[0]).toHaveProperty("length", undefined); + }); + + it("reports resolved seconds for end", async () => { + const e = new Edit({ + timeline: { + tracks: [ + { clips: [{ asset: { type: "video", prompt: "pan" }, start: 2, length: "end" }] }, + { clips: [{ asset: { type: "image", src: "https://cdn/x.png" }, start: 0, length: 10 }] } + ] + }, + output: { size: { width: 1920, height: 1080 }, format: "mp4" } + } as never); + await e.load(); + const spy = jest.spyOn(e.getInternalEvents(), "emit"); + e.selectClip(0, 0); + expect(configs(spy)[0].length).toBe(8); + }); + + it("re-emits when the selected clip's option changes and not otherwise", async () => { + const { e, spy } = await promptEdit({ asset: { type: "image", prompt: "a cat", model: "m", options: { resolution: "1K" } } }); + e.selectClip(0, 0); + await e.updateClip(0, 0, { asset: { options: { resolution: "2K" } } } as never); + await e.updateClip(0, 0, { start: 1 } as never); // no config field touched + expect(configs(spy).map(c => c.options)).toEqual([{ resolution: "1K" }, { resolution: "2K" }]); + }); + + it("emits nothing for a clip without a prompt", async () => { + const { e, spy } = await promptEdit({ asset: { type: "image", src: "https://cdn/x.png" } }); + e.selectClip(0, 0); + expect(configs(spy)).toHaveLength(0); + }); + + it("emits nothing after the selection is cleared", async () => { + const { e, spy } = await promptEdit({ asset: { type: "image", prompt: "a cat" } }); + e.selectClip(0, 0); + e.clearSelection(); + await e.updateClip(0, 0, { asset: { prompt: "a dog" } } as never); + expect(configs(spy)).toHaveLength(1); + }); + + it("announces the same configuration again when the clip is re-selected", async () => { + const { e, spy } = await promptEdit({ asset: { type: "image", prompt: "a cat" } }); + e.selectClip(0, 0); + e.clearSelection(); + e.selectClip(0, 0); + expect(configs(spy).map(c => c.prompt)).toEqual(["a cat", "a cat"]); + }); + + it("re-emits when a live merge field value edit changes the prompt", async () => { + const e = new ShotstackEdit({ + timeline: { tracks: [{ clips: [{ asset: { type: "image", prompt: "a {{ THING }}" }, start: 0, length: 4 }] }] }, + merge: [{ find: "THING", replace: "cat" }], + output: { size: { width: 1920, height: 1080 }, format: "mp4" } + } as never); + await e.load(); + const spy = jest.spyOn(e.getInternalEvents(), "emit"); + e.selectClip(0, 0); + e.updateMergeFieldValueLive("THING", "dog"); + expect(configs(spy).map(c => c.prompt)).toEqual(["a cat", "a dog"]); + }); + }); }); diff --git a/tests/generate-toolbar.test.ts b/tests/generate-toolbar.test.ts index 60a45e3e..2fd4d996 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 { EditEvent } from "@core/events/edit-events"; +import { EditEvent, InternalEvent } from "@core/events/edit-events"; import type { GenerationAssetType, GenerationModelDefinition, GenerationOptionDefinition } from "@core/generation/model-catalogue"; import { GenerateToolbar } from "@core/ui/generate-toolbar"; @@ -36,6 +36,7 @@ function createMockEdit(asset: Record = { type: "image", prompt getDocument: jest.fn(), hasAssetGenerator: jest.fn().mockReturnValue(true), getClipGenerationState: jest.fn(), + getGenerationStatus: jest.fn(), generateClip: jest.fn().mockResolvedValue(undefined), resolveMergeFields: jest.fn((value: string) => value), updateClip: jest.fn(), @@ -578,4 +579,94 @@ describe("GenerateToolbar", () => { toolbar.dispose(); }); + + describe("host status", () => { + it("shows the host's text in the note slot with its tone", () => { + const edit = createMockEdit(); + edit.getGenerationStatus.mockReturnValue({ text: "neutral line", tone: "neutral" }); + const { toolbar, container } = mountToolbar(edit); + const note = container.querySelector("[data-generate-note]"); + expect(note?.hidden).toBe(false); + expect(note?.textContent).toBe("neutral line"); + expect(note?.dataset["tone"]).toBe("neutral"); + expect(container.querySelector("[data-action='generate']")?.disabled).toBe(false); + toolbar.dispose(); + }); + + it("disables Generate only for an error tone", () => { + const edit = createMockEdit(); + edit.getGenerationStatus.mockReturnValue({ text: "blocked", tone: "error" }); + const { toolbar, container } = mountToolbar(edit); + expect(container.querySelector("[data-action='generate']")?.disabled).toBe(true); + expect(container.querySelector("[data-generate-note]")?.dataset["tone"]).toBe("error"); + + edit.getGenerationStatus.mockReturnValue({ text: "caution", tone: "warning" }); + toolbar.show(0, 0); + expect(container.querySelector("[data-action='generate']")?.disabled).toBe(false); + toolbar.dispose(); + }); + + it("hides the note again when the status is cleared", () => { + const edit = createMockEdit(); + edit.getGenerationStatus.mockReturnValue({ text: "x" }); + const { toolbar, container } = mountToolbar(edit); + const note = container.querySelector("[data-generate-note]"); + expect(note?.dataset["tone"]).toBe("neutral"); + edit.getGenerationStatus.mockReturnValue(undefined); + toolbar.show(0, 0); + expect(note?.hidden).toBe(true); + expect(note?.dataset["tone"]).toBeUndefined(); + toolbar.dispose(); + }); + + it("still shows the no-generator note when no generator is registered", () => { + const edit = createMockEdit(); + edit.hasAssetGenerator.mockReturnValue(false); + edit.getGenerationStatus.mockReturnValue({ text: "ignored" }); + const { toolbar, container } = mountToolbar(edit); + expect(container.querySelector("[data-generate-note]")?.textContent).toBe("Generates on render"); + toolbar.dispose(); + }); + + it("carries the no-generator tooltip only when no generator is registered", () => { + const edit = createMockEdit(); + edit.hasAssetGenerator.mockReturnValue(false); + const { toolbar, container } = mountToolbar(edit); + expect(container.querySelector("[data-generate-note]")?.getAttribute("title")).toBe( + "Rendering generates this from the prompt. Register an asset generator to preview it here." + ); + toolbar.dispose(); + }); + + it("drops the no-generator tooltip once a generator reports a status", () => { + const edit = createMockEdit(); + edit.getGenerationStatus.mockReturnValue({ text: "now" }); + const { toolbar, container } = mountToolbar(edit); + expect(container.querySelector("[data-generate-note]")?.hasAttribute("title")).toBe(false); + toolbar.dispose(); + }); + + it("re-syncs when the status for the selected clip changes", () => { + const edit = createMockEdit(); + const { toolbar, container } = mountToolbar(edit); + const handler = edit.getInternalEvents().on.mock.calls.find(([name]) => name === InternalEvent.GenerationStatusChanged)?.[1]; + expect(handler).toBeDefined(); + edit.getGenerationStatus.mockReturnValue({ text: "now" }); + handler({ clipId: "clip-1" }); + expect(container.querySelector("[data-generate-note]")?.textContent).toBe("now"); + toolbar.dispose(); + }); + + it("ignores a status change for a clip other than the selected one", () => { + const edit = createMockEdit(); + edit.getGenerationStatus.mockReturnValue({ text: "original" }); + const { toolbar, container } = mountToolbar(edit); + const handler = edit.getInternalEvents().on.mock.calls.find(([name]) => name === InternalEvent.GenerationStatusChanged)?.[1]; + expect(handler).toBeDefined(); + edit.getGenerationStatus.mockReturnValue({ text: "unrelated clip's line" }); + handler({ clipId: "other-clip" }); + expect(container.querySelector("[data-generate-note]")?.textContent).toBe("original"); + toolbar.dispose(); + }); + }); }); From 78e30481bd630e5a6e4e5294fa70bb077b4616a6 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Thu, 24 Sep 2026 18:57:36 +1000 Subject: [PATCH 2/3] refactor: keep generation annotations behind the internal entry point --- readme.md | 8 - src/core/edit-session.ts | 34 ++-- src/core/events/edit-events.ts | 20 --- src/core/generation/asset-generator.ts | 55 ++++++ src/core/generation/generation-status.ts | 26 +++ src/core/ui/generate-toolbar.ts | 1 + src/index.ts | 1 - src/internal.ts | 3 + test-package.js | 16 +- tests/asset-generator.test.ts | 1 + tests/edit-clip-operations.test.ts | 212 ++++++++++++++++------- tests/generate-toolbar.test.ts | 2 + 12 files changed, 260 insertions(+), 119 deletions(-) create mode 100644 src/core/generation/generation-status.ts diff --git a/readme.md b/readme.md index 3cc69b70..6e56e4c9 100644 --- a/readme.md +++ b/readme.md @@ -213,7 +213,6 @@ Available event names: | Duration | `duration:changed` | | Output | `output:resized`, `output:resolutionChanged`, `output:aspectRatioChanged`, `output:fpsChanged`, `output:formatChanged`, `output:destinationsChanged` | | Merge fields | `mergefield:changed` | -| Generation | `generation:configChanged` | ### Generating assets from prompts @@ -238,13 +237,6 @@ controls. Entries must include their option schema; those without one are ignore `GET /models?expand=options`. The SDK stores a snapshot; fetching and refreshing it remain the host's responsibility. -Whenever the selected clip's model, options, length or prompt changes, the editor emits -`generation:configChanged` with what it knows: `clipId`, `type`, `model`, `options`, the -resolved `length` in seconds (`undefined` for `auto`), and the resolved `prompt`. Reply with -`edit.setGenerationStatus(clipId, { text, tone })` to show a line beside Generate; a `tone` -of `error` also disables it. Pass `undefined` to clear. Clearing the prompt or deleting the -clip emits nothing, so also clear any held state on `clip:selected` or `edit:changed`. - The SDK writes the returned URL to the clip, so the change is undoable and autosaves like any other edit. It tracks whether a clip is generating or has failed, and renders those states; a rejection's message is shown as-is next to a retry action. Everything diff --git a/src/core/edit-session.ts b/src/core/edit-session.ts index 4be6d49d..2ff39b15 100644 --- a/src/core/edit-session.ts +++ b/src/core/edit-session.ts @@ -23,16 +23,10 @@ import { SetUpdatedClipCommand } from "@core/commands/set-updated-clip-command"; import { type TimingUpdateParams, UpdateClipTimingCommand } from "@core/commands/update-clip-timing-command"; import { UpdateTextContentCommand } from "@core/commands/update-text-content-command"; import type { MergeFieldBinding } from "@core/edit-document"; -import { - EditEvent, - InternalEvent, - type EditEventMap, - type InternalEventMap, - type GenerationConfig, - type GenerationStatus -} from "@core/events/edit-events"; +import { EditEvent, InternalEvent, type EditEventMap, type InternalEventMap } from "@core/events/edit-events"; import { EventEmitter, type ReadonlyEventEmitter } from "@core/events/event-emitter"; import { parseFontFamily } from "@core/fonts/font-config"; +import type { GenerationConfig, GenerationStatus, GenerationStatusProvider } from "@core/generation/generation-status"; import { LumaMaskController } from "@core/luma-mask-controller"; import { MergeFieldService, type SerializedMergeField } from "@core/merge"; import { calculateSizeFromPreset, OutputSettingsManager } from "@core/output-settings-manager"; @@ -131,7 +125,6 @@ export class Edit { // ─── Internal Bookkeeping ───────────────────────────────────────────────── private clipsToDispose = new Set(); private clipErrors = new Map(); - private generationStatuses = new Map(); private lastGenerationConfigKey: string | null = null; private playerByClipId = new Map(); private lumaContentRelations = new Map(); @@ -156,7 +149,10 @@ export class Edit { const resolved = clipId ? this.getResolvedClipById(clipId) : null; const raw = clipId ? this.getDocumentClipById(clipId) : null; if (!clipId || !resolved || !raw || !isAiAsset(resolved.asset)) { - this.lastGenerationConfigKey = null; + if (this.lastGenerationConfigKey !== null) { + this.lastGenerationConfigKey = null; + this.assetGenerator.describe(null); + } return; } @@ -177,7 +173,7 @@ export class Edit { const key = JSON.stringify(config); if (key === this.lastGenerationConfigKey) return; this.lastGenerationConfigKey = key; - this.internalEvents.emit(EditEvent.GenerationConfigChanged, config); + this.assetGenerator.describe(config); }; /** @@ -214,7 +210,8 @@ export class Edit { applyGeneratedSrc: (clipId, url) => this.applyGeneratedSrc(clipId, url), 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 }) + emitFailed: (clipId, error) => this.internalEvents.emit(EditEvent.ClipGenerationFailed, { clipId, error }), + emitStatusChanged: clipId => this.internalEvents.emit(InternalEvent.GenerationStatusChanged, { clipId }) }); this.mergeFieldService = new MergeFieldService(this.internalEvents); this.outputSettings = new OutputSettingsManager(this); @@ -333,7 +330,6 @@ export class Edit { this.internalEvents.off(InternalEvent.Resolved, this.onResolvedForGeneration); for (const name of Edit.GenerationConfigTriggers) this.internalEvents.off(name, this.emitGenerationConfig); this.assetGenerator.abortAll(); - this.generationStatuses.clear(); this.lumaMaskController.dispose(); this.playerReconciler.dispose(); @@ -494,15 +490,17 @@ export class Edit { this.internalEvents.emit(InternalEvent.AssetGeneratorChanged); } - public setGenerationStatus(clipId: string, status: GenerationStatus | undefined): void { - if (status === undefined) this.generationStatuses.delete(clipId); - else this.generationStatuses.set(clipId, status); - this.internalEvents.emit(InternalEvent.GenerationStatusChanged, { clipId }); + /** @internal */ + public registerGenerationStatus(provider: GenerationStatusProvider): () => void { + const unregister = this.assetGenerator.registerStatus(provider); + this.lastGenerationConfigKey = null; + this.emitGenerationConfig(); + return unregister; } /** @internal */ public getGenerationStatus(clipId: string): GenerationStatus | undefined { - return this.generationStatuses.get(clipId); + return this.assetGenerator.getStatus(clipId); } /** @internal */ diff --git a/src/core/events/edit-events.ts b/src/core/events/edit-events.ts index e9f7ad4f..7aaa6072 100644 --- a/src/core/events/edit-events.ts +++ b/src/core/events/edit-events.ts @@ -101,9 +101,6 @@ export const EditEvent = { // Merge fields MergeFieldChanged: "mergefield:changed", - // Generation - GenerationConfigChanged: "generation:configChanged", - // Timeline UI TimelineResized: "timeline:resized" } as const; @@ -145,20 +142,6 @@ export const InternalEvent = { GenerationStatusChanged: "generation:statusChanged" } as const; -export type GenerationConfig = { - clipId: string; - type: "image" | "video" | "audio"; - model?: string; - options: Record; - length: number | undefined; - prompt: string; -}; - -export type GenerationStatus = { - text: string; - tone?: "neutral" | "warning" | "error"; -}; - // ───────────────────────────────────────────────────────────── // Event Payload Maps // ───────────────────────────────────────────────────────────── @@ -215,9 +198,6 @@ export type EditEventMap = { // Merge fields [EditEvent.MergeFieldChanged]: { fields: MergeField[] }; - // Generation - [EditEvent.GenerationConfigChanged]: GenerationConfig; - // Timeline UI [EditEvent.TimelineResized]: { height: number }; }; diff --git a/src/core/generation/asset-generator.ts b/src/core/generation/asset-generator.ts index c3dd8341..80ca6d97 100644 --- a/src/core/generation/asset-generator.ts +++ b/src/core/generation/asset-generator.ts @@ -1,5 +1,6 @@ import { isAiAsset } from "@core/shared/ai-asset-utils"; +import type { GenerationConfig, GenerationStatus, GenerationStatusProvider } from "./generation-status"; import { type GenerationAssetType, type GenerationModelCatalogueResponse, @@ -40,6 +41,7 @@ export interface AssetGeneratorDeps { emitStarted: (clipId: string) => void; emitCompleted: (clipId: string) => void; emitFailed: (clipId: string, error: string) => void; + emitStatusChanged: (clipId: string) => void; } /** @@ -50,6 +52,9 @@ export interface AssetGeneratorDeps { export class AssetGenerator { private handler?: AssetGeneratorHandler; private models?: readonly GenerationModelDefinition[]; + private statusProvider?: GenerationStatusProvider; + private statusController: AbortController | null = null; + private status: { clipId: string; value: GenerationStatus } | null = null; private readonly states = new Map(); private readonly controllers = new Map(); @@ -60,6 +65,55 @@ export class AssetGenerator { this.models = options?.catalogue === undefined ? undefined : readGenerationModels(options.catalogue); } + public registerStatus(provider: GenerationStatusProvider): () => void { + const callback: GenerationStatusProvider = request => provider(request); + this.statusProvider = callback; + this.describe(null); + return () => { + if (this.statusProvider !== callback) return; + this.statusProvider = undefined; + this.describe(null); + }; + } + + public getStatus(clipId: string): GenerationStatus | undefined { + return this.status?.clipId === clipId ? this.status.value : undefined; + } + + public describe(config: GenerationConfig | null): void { + this.statusController?.abort(); + this.statusController = null; + this.setStatus(null); + if (!config || !this.statusProvider) { + return; + } + const controller = new AbortController(); + this.statusController = controller; + const settle = (value: GenerationStatus | undefined): void => { + if (controller.signal.aborted) return; + this.setStatus(value ? { clipId: config.clipId, value } : null); + }; + const fail = (error: unknown): void => { + if (controller.signal.aborted) return; + console.warn(`Generation status: ${error instanceof Error ? error.message : String(error)}`); + settle(undefined); + }; + try { + const result = this.statusProvider({ ...config, signal: controller.signal }); + if (result instanceof Promise) result.then(settle, fail); + else settle(result); + } catch (error) { + fail(error); + } + } + + private setStatus(next: { clipId: string; value: GenerationStatus } | null): void { + const previous = this.status; + this.status = next; + const changed = next ?? previous; + if (changed) this.deps.emitStatusChanged(changed.clipId); + } + public getModels(type: GenerationAssetType): readonly GenerationModelDefinition[] | undefined { return this.models?.filter(model => model.type === type); } @@ -125,5 +179,6 @@ export class AssetGenerator { public abortAll(): void { for (const clipId of [...this.controllers.keys()]) this.abort(clipId); this.states.clear(); + this.describe(null); } } diff --git a/src/core/generation/generation-status.ts b/src/core/generation/generation-status.ts new file mode 100644 index 00000000..720d8bbb --- /dev/null +++ b/src/core/generation/generation-status.ts @@ -0,0 +1,26 @@ +import type { Edit } from "@core/edit-session"; + +export type GenerationConfig = { + clipId: string; + type: "image" | "video" | "audio"; + model?: string; + options: Record; + length: number | undefined; + prompt: string; +}; + +export type GenerationStatus = { + text: string; + tone?: "neutral" | "warning" | "error"; +}; + +export type GenerationStatusRequest = GenerationConfig & { + /** Aborted when the configuration changes again or the clip is deselected. */ + signal: AbortSignal; +}; + +export type GenerationStatusProvider = (request: GenerationStatusRequest) => GenerationStatus | undefined | Promise; + +export function registerGenerationStatus(edit: Edit, provider: GenerationStatusProvider): () => void { + return edit.registerGenerationStatus(provider); +} diff --git a/src/core/ui/generate-toolbar.ts b/src/core/ui/generate-toolbar.ts index 0b563d76..a8368e03 100644 --- a/src/core/ui/generate-toolbar.ts +++ b/src/core/ui/generate-toolbar.ts @@ -174,6 +174,7 @@ export class GenerateToolbar extends BaseToolbar { if (!clipId) return; if ((this.promptInput?.value ?? "").trim() === "") return; if (this.edit.getClipGenerationState(clipId)?.status === "generating") return; + if (this.edit.getGenerationStatus(clipId)?.tone === "error") 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.generateClip(clipId).catch((error: unknown) => { diff --git a/src/index.ts b/src/index.ts index 48b7e76a..e3b4b33c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,6 +12,5 @@ export type { UIControllerOptions, ToolbarButtonConfig } from "@core/ui/ui-contr export type { AssetGenerationRequest, AssetGeneratorHandler, AssetGeneratorOptions } from "@core/generation/asset-generator"; export type { EditConfig } from "@core/schemas"; export type { CommandResult } from "@core/commands/types"; -export type { GenerationConfig, GenerationStatus } from "@core/events/edit-events"; export const VERSION = pkg.version; diff --git a/src/internal.ts b/src/internal.ts index 796b8924..43548e37 100644 --- a/src/internal.ts +++ b/src/internal.ts @@ -16,3 +16,6 @@ export type { MergeField, MergeFieldService } from "@core/merge"; export { InvalidAssetUrlError } from "@core/url-validation"; export { registerGenerationSettings } from "@core/generation/generation-settings"; + +export { registerGenerationStatus } from "@core/generation/generation-status"; +export type { GenerationConfig, GenerationStatus, GenerationStatusRequest, GenerationStatusProvider } from "@core/generation/generation-status"; diff --git a/test-package.js b/test-package.js index 2fb8ac89..4667721d 100644 --- a/test-package.js +++ b/test-package.js @@ -27,7 +27,7 @@ const CONTRACT = { } }, runtimeExports: ["Edit", "Canvas", "Controls", "Timeline", "UIController", "VideoExporter", "VERSION"], - internalRuntimeExports: ["Edit", "ShotstackEdit", "registerGenerationSettings"], + internalRuntimeExports: ["Edit", "ShotstackEdit", "registerGenerationSettings", "registerGenerationStatus"], dtsHiddenMembersByClass: { UIController: [ "updateOverlays(", @@ -72,6 +72,11 @@ const CONTRACT = { ] }, dtsForbiddenTokens: [ + "registerGenerationStatus", + "setGenerationStatus", + "type GenerationConfig =", + "type GenerationStatus =", + "generation:configChanged", "registerGenerationSettings", "generationSettings", "GenerationSettingsHandler", @@ -147,8 +152,7 @@ const CONTRACT = { tokens: [ "load(): Promise;", "registerAssetGenerator(handler: AssetGeneratorHandler, options?: AssetGeneratorOptions): void;", - "generateClip(clipId: string): Promise;", - "setGenerationStatus(clipId: string, status: GenerationStatus | undefined): void;" + "generateClip(clipId: string): Promise;" ] }, { className: "Canvas", tokens: ["load(): Promise;"] }, @@ -254,7 +258,7 @@ const checkInternalDeclarationSurface = () => { const dtsPath = resolve(__dirname, "dist/internal.d.ts"); const dtsContent = readFileSync(dtsPath, "utf-8"); const errors = []; - const requiredTokens = ["export declare class Edit", "export declare class ShotstackEdit extends Edit", "export declare class MergeFieldService", "export declare function registerGenerationSettings"]; + const requiredTokens = ["export declare class Edit", "export declare class ShotstackEdit extends Edit", "export declare class MergeFieldService", "export declare function registerGenerationSettings", "export declare function registerGenerationStatus"]; const isEntryStubOnly = /^\s*export\s+\*\s+from\s+['"]\.\/internal['"]\s*;\s*export\s*\{\s*\}\s*;?\s*$/.test(dtsContent); for (const token of requiredTokens) { @@ -386,8 +390,8 @@ const checkBundleSizes = () => { const runRuntimeExportSmokeTest = async (name, modulePath, expectedExports) => { try { const module = await import(modulePath); - if (modulePath === "./dist/shotstack-studio.es.js" && "registerGenerationSettings" in module) { - failWithDetails(name, ["Internal settings hook leaked into public exports"]); + if (modulePath === "./dist/shotstack-studio.es.js" && ["registerGenerationSettings", "registerGenerationStatus"].some(symbol => symbol in module)) { + failWithDetails(name, ["Internal generation hook leaked into public exports"]); } const missing = expectedExports.filter(symbol => !module[symbol]); diff --git a/tests/asset-generator.test.ts b/tests/asset-generator.test.ts index 523c1bcb..ebc1d78d 100644 --- a/tests/asset-generator.test.ts +++ b/tests/asset-generator.test.ts @@ -18,6 +18,7 @@ function makeDeps(overrides: Partial = {}) { applyGeneratedSrc: async (clipId, url) => { applied.push({ clipId, url }); }, + emitStatusChanged: () => {}, emitStarted: clipId => started.push(clipId), emitCompleted: clipId => completed.push(clipId), emitFailed: (clipId, error) => failed.push({ clipId, error }), diff --git a/tests/edit-clip-operations.test.ts b/tests/edit-clip-operations.test.ts index eb3a5e00..dca9f981 100644 --- a/tests/edit-clip-operations.test.ts +++ b/tests/edit-clip-operations.test.ts @@ -1,3 +1,9 @@ +import { + registerGenerationStatus, + type GenerationStatus, + type GenerationStatusProvider, + type GenerationStatusRequest +} from "@core/generation/generation-status"; /** * Edit Class Clip Operations Tests * @@ -1813,86 +1819,64 @@ describe("Edit Clip Operations", () => { }); describe("generation status", () => { - it("stores a status per clip and announces the change", () => { - edit.setGenerationStatus("clip-a", { text: "host line", tone: "neutral" }); - expect(edit.getGenerationStatus("clip-a")).toEqual({ text: "host line", tone: "neutral" }); - expect(emitSpy).toHaveBeenCalledWith(InternalEvent.GenerationStatusChanged, { clipId: "clip-a" }); - }); - - it("clears a status with undefined", () => { - edit.setGenerationStatus("clip-a", { text: "x" }); - edit.setGenerationStatus("clip-a", undefined); - expect(edit.getGenerationStatus("clip-a")).toBeUndefined(); - expect(emitSpy).toHaveBeenLastCalledWith(InternalEvent.GenerationStatusChanged, { clipId: "clip-a" }); - }); - - it("keeps statuses for different clips apart", () => { - edit.setGenerationStatus("clip-a", { text: "a" }); - edit.setGenerationStatus("clip-b", { text: "b", tone: "error" }); - expect(edit.getGenerationStatus("clip-a")).toEqual({ text: "a" }); - expect(edit.getGenerationStatus("clip-b")).toEqual({ text: "b", tone: "error" }); - }); - - it("drops stored statuses on dispose", () => { - edit.setGenerationStatus("clip-a", { text: "a" }); - edit.dispose(); - expect(edit.getGenerationStatus("clip-a")).toBeUndefined(); - }); - }); - - describe("generation:configChanged", () => { - const configs = (spy: jest.SpyInstance) => - spy.mock.calls.filter(([name]) => name === EditEvent.GenerationConfigChanged).map(([, payload]) => payload); + type Provider = jest.Mock, [GenerationStatusRequest]>; const promptEdit = async (clip: Record, merge: { find: string; replace: string }[] = []) => { - const e = new Edit({ + const e = new ShotstackEdit({ timeline: { tracks: [{ clips: [{ start: 0, length: 4, ...clip }] }] }, ...(merge.length ? { merge } : {}), output: { size: { width: 1920, height: 1080 }, format: "mp4" } } as never); await e.load(); - const spy = jest.spyOn(e.getInternalEvents(), "emit"); - return { e, spy }; + const status: Provider = jest.fn(); + registerGenerationStatus(e, status); + return { e, status }; }; - it("emits the selected clip's configuration on selection", async () => { - const { e, spy } = await promptEdit({ + const configs = (status: Provider) => status.mock.calls.map(([{ signal: _signal, ...config }]) => config); + const flush = () => + new Promise(resolve => { + setTimeout(resolve, 0); + }); + + it("describes the selected clip's configuration on selection", async () => { + const { e, status } = await promptEdit({ asset: { type: "audio", prompt: "hello", model: "polly-neural", options: { voice: "Matthew" } } }); e.selectClip(0, 0); - expect(configs(spy)).toEqual([ + expect(configs(status)).toEqual([ { clipId: e.getClipId(0, 0), type: "audio", model: "polly-neural", options: { voice: "Matthew" }, length: 4, prompt: "hello" } ]); }); it("resolves merge fields in the prompt", async () => { - const { e, spy } = await promptEdit({ asset: { type: "image", prompt: "a {{ THING }}" } }, [{ find: "THING", replace: "cat" }]); + const { e, status } = await promptEdit({ asset: { type: "image", prompt: "a {{ THING }}" } }, [{ find: "THING", replace: "cat" }]); e.selectClip(0, 0); - expect(configs(spy)[0]).toMatchObject({ prompt: "a cat" }); + expect(configs(status)[0]).toMatchObject({ prompt: "a cat" }); }); it("reads a text-to-speech prompt from the text field and reports audio", async () => { - const { e, spy } = await promptEdit({ asset: { type: "text-to-speech", text: "say this", voice: "Matthew" } }); + const { e, status } = await promptEdit({ asset: { type: "text-to-speech", text: "say this", voice: "Matthew" } }); e.selectClip(0, 0); - expect(configs(spy)[0]).toMatchObject({ type: "audio", prompt: "say this" }); + expect(configs(status)[0]).toMatchObject({ type: "audio", prompt: "say this" }); }); it("omits model when the clip has none and reports empty options", async () => { - const { e, spy } = await promptEdit({ asset: { type: "image", prompt: "a cat" } }); + const { e, status } = await promptEdit({ asset: { type: "image", prompt: "a cat" } }); e.selectClip(0, 0); - const [payload] = configs(spy); - expect(payload).not.toHaveProperty("model"); - expect(payload.options).toEqual({}); + const [config] = configs(status); + expect(config).not.toHaveProperty("model"); + expect(config.options).toEqual({}); }); it("reports undefined length for auto", async () => { - const { e, spy } = await promptEdit({ asset: { type: "video", prompt: "pan" }, length: "auto" }); + const { e, status } = await promptEdit({ asset: { type: "video", prompt: "pan" }, length: "auto" }); e.selectClip(0, 0); - expect(configs(spy)[0]).toHaveProperty("length", undefined); + expect(configs(status)[0]).toHaveProperty("length", undefined); }); it("reports resolved seconds for end", async () => { - const e = new Edit({ + const e = new ShotstackEdit({ timeline: { tracks: [ { clips: [{ asset: { type: "video", prompt: "pan" }, start: 2, length: "end" }] }, @@ -1902,52 +1886,148 @@ describe("Edit Clip Operations", () => { output: { size: { width: 1920, height: 1080 }, format: "mp4" } } as never); await e.load(); - const spy = jest.spyOn(e.getInternalEvents(), "emit"); + const status: Provider = jest.fn(); + registerGenerationStatus(e, status); e.selectClip(0, 0); - expect(configs(spy)[0].length).toBe(8); + expect(configs(status)[0].length).toBe(8); }); - it("re-emits when the selected clip's option changes and not otherwise", async () => { - const { e, spy } = await promptEdit({ asset: { type: "image", prompt: "a cat", model: "m", options: { resolution: "1K" } } }); + it("asks again when the selected clip's option changes and not otherwise", async () => { + const { e, status } = await promptEdit({ asset: { type: "image", prompt: "a cat", model: "m", options: { resolution: "1K" } } }); e.selectClip(0, 0); await e.updateClip(0, 0, { asset: { options: { resolution: "2K" } } } as never); await e.updateClip(0, 0, { start: 1 } as never); // no config field touched - expect(configs(spy).map(c => c.options)).toEqual([{ resolution: "1K" }, { resolution: "2K" }]); + expect(configs(status).map(c => c.options)).toEqual([{ resolution: "1K" }, { resolution: "2K" }]); }); - it("emits nothing for a clip without a prompt", async () => { - const { e, spy } = await promptEdit({ asset: { type: "image", src: "https://cdn/x.png" } }); + it("asks nothing for a clip without a prompt", async () => { + const { e, status } = await promptEdit({ asset: { type: "image", src: "https://cdn/x.png" } }); e.selectClip(0, 0); - expect(configs(spy)).toHaveLength(0); + expect(configs(status)).toHaveLength(0); }); - it("emits nothing after the selection is cleared", async () => { - const { e, spy } = await promptEdit({ asset: { type: "image", prompt: "a cat" } }); + it("asks nothing after the selection is cleared", async () => { + const { e, status } = await promptEdit({ asset: { type: "image", prompt: "a cat" } }); e.selectClip(0, 0); e.clearSelection(); await e.updateClip(0, 0, { asset: { prompt: "a dog" } } as never); - expect(configs(spy)).toHaveLength(1); + expect(configs(status)).toHaveLength(1); }); - it("announces the same configuration again when the clip is re-selected", async () => { - const { e, spy } = await promptEdit({ asset: { type: "image", prompt: "a cat" } }); + it("asks again when the clip is re-selected", async () => { + const { e, status } = await promptEdit({ asset: { type: "image", prompt: "a cat" } }); e.selectClip(0, 0); e.clearSelection(); e.selectClip(0, 0); - expect(configs(spy).map(c => c.prompt)).toEqual(["a cat", "a cat"]); + expect(configs(status).map(c => c.prompt)).toEqual(["a cat", "a cat"]); }); - it("re-emits when a live merge field value edit changes the prompt", async () => { + it("asks again when a live merge field value edit changes the prompt", async () => { + const { e, status } = await promptEdit({ asset: { type: "image", prompt: "a {{ THING }}" } }, [{ find: "THING", replace: "cat" }]); + e.selectClip(0, 0); + e.updateMergeFieldValueLive("THING", "dog"); + expect(configs(status).map(c => c.prompt)).toEqual(["a cat", "a dog"]); + }); + + it("describes the selection immediately when the internal hook is registered", async () => { const e = new ShotstackEdit({ - timeline: { tracks: [{ clips: [{ asset: { type: "image", prompt: "a {{ THING }}" }, start: 0, length: 4 }] }] }, - merge: [{ find: "THING", replace: "cat" }], + timeline: { tracks: [{ clips: [{ asset: { type: "image", prompt: "a cat" }, start: 0, length: 4 }] }] }, output: { size: { width: 1920, height: 1080 }, format: "mp4" } } as never); await e.load(); + e.selectClip(0, 0); + const status: Provider = jest.fn(); + registerGenerationStatus(e, status); + expect(configs(status).map(c => c.prompt)).toEqual(["a cat"]); + }); + + it("shows the provider's answer for the selected clip and announces it", async () => { + const { e, status } = await promptEdit({ asset: { type: "image", prompt: "a cat" } }); + status.mockReturnValue({ text: "0.5 credits" }); const spy = jest.spyOn(e.getInternalEvents(), "emit"); e.selectClip(0, 0); - e.updateMergeFieldValueLive("THING", "dog"); - expect(configs(spy).map(c => c.prompt)).toEqual(["a cat", "a dog"]); + const clipId = e.getClipId(0, 0) ?? ""; + expect(e.getGenerationStatus(clipId)).toEqual({ text: "0.5 credits" }); + expect(spy).toHaveBeenCalledWith(InternalEvent.GenerationStatusChanged, { clipId }); + }); + + it("awaits an asynchronous answer", async () => { + const { e, status } = await promptEdit({ asset: { type: "image", prompt: "a cat" } }); + status.mockResolvedValue({ text: "later" }); + e.selectClip(0, 0); + expect(e.getGenerationStatus(e.getClipId(0, 0) ?? "")).toBeUndefined(); + await flush(); + expect(e.getGenerationStatus(e.getClipId(0, 0) ?? "")).toEqual({ text: "later" }); + }); + + it("discards an answer that arrives after the configuration changed", async () => { + const { e, status } = await promptEdit({ asset: { type: "image", prompt: "a cat" } }); + let resolveFirst!: (value: GenerationStatus) => void; + status + .mockImplementationOnce( + () => + new Promise(resolve => { + resolveFirst = resolve; + }) + ) + .mockReturnValueOnce({ text: "second" }); + e.selectClip(0, 0); + await e.updateClip(0, 0, { asset: { prompt: "a dog" } } as never); + resolveFirst({ text: "first" }); + await flush(); + expect(e.getGenerationStatus(e.getClipId(0, 0) ?? "")).toEqual({ text: "second" }); + expect(status.mock.calls[0][0].signal.aborted).toBe(true); + }); + + it("clears the line for undefined and on deselection", async () => { + const { e, status } = await promptEdit({ asset: { type: "image", prompt: "a cat" } }); + status.mockReturnValueOnce({ text: "shown" }).mockReturnValueOnce(undefined); + e.selectClip(0, 0); + const clipId = e.getClipId(0, 0) ?? ""; + expect(e.getGenerationStatus(clipId)).toEqual({ text: "shown" }); + await e.updateClip(0, 0, { asset: { prompt: "a dog" } } as never); + expect(e.getGenerationStatus(clipId)).toBeUndefined(); + status.mockReturnValue({ text: "again" }); + e.clearSelection(); + e.selectClip(0, 0); + expect(e.getGenerationStatus(clipId)).toEqual({ text: "again" }); + e.clearSelection(); + expect(e.getGenerationStatus(clipId)).toBeUndefined(); + }); + + it("treats a throwing provider as no answer", async () => { + const { e, status } = await promptEdit({ asset: { type: "image", prompt: "a cat" } }); + const warn = jest.spyOn(console, "warn").mockImplementation(() => {}); + status.mockImplementation(() => { + throw new Error("boom"); + }); + e.selectClip(0, 0); + expect(e.getGenerationStatus(e.getClipId(0, 0) ?? "")).toBeUndefined(); + expect(warn).toHaveBeenCalledWith("Generation status: boom"); + warn.mockRestore(); + }); + + it("ignores stale cleanup and clears the current provider on removal", async () => { + const { e, status } = await promptEdit({ asset: { type: "image", prompt: "a cat" } }); + e.selectClip(0, 0); + const removeOld = registerGenerationStatus(e, status); + const replacement = jest.fn(() => ({ text: "new" })); + const remove = registerGenerationStatus(e, replacement); + removeOld(); + const clipId = e.getClipId(0, 0) ?? ""; + expect(e.getGenerationStatus(clipId)).toEqual({ text: "new" }); + remove(); + expect(e.getGenerationStatus(clipId)).toBeUndefined(); + await e.updateClip(0, 0, { asset: { prompt: "changed" } } as never); + expect(replacement).toHaveBeenCalledTimes(1); + }); + + it("aborts a pending answer on dispose", async () => { + const { e, status } = await promptEdit({ asset: { type: "image", prompt: "a cat" } }); + status.mockImplementation(() => new Promise(() => {})); + e.selectClip(0, 0); + e.dispose(); + expect(status.mock.calls[0][0].signal.aborted).toBe(true); }); }); }); diff --git a/tests/generate-toolbar.test.ts b/tests/generate-toolbar.test.ts index 2fd4d996..9e3a3080 100644 --- a/tests/generate-toolbar.test.ts +++ b/tests/generate-toolbar.test.ts @@ -599,6 +599,8 @@ describe("GenerateToolbar", () => { const { toolbar, container } = mountToolbar(edit); expect(container.querySelector("[data-action='generate']")?.disabled).toBe(true); expect(container.querySelector("[data-generate-note]")?.dataset["tone"]).toBe("error"); + container.querySelector("[data-prompt-input]")?.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + expect(edit.generateClip).not.toHaveBeenCalled(); edit.getGenerationStatus.mockReturnValue({ text: "caution", tone: "warning" }); toolbar.show(0, 0); From 87fae0b1c5d5416265d97476d6e6e0d5b906b9f5 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Thu, 24 Sep 2026 19:26:06 +1000 Subject: [PATCH 3/3] fix: preserve generation blocks and isolate status options --- src/core/generation/asset-generator.ts | 6 ++-- tests/edit-clip-operations.test.ts | 20 +++++++++++ tests/generate-toolbar.test.ts | 46 ++++++++++++++++++++++++++ 3 files changed, 70 insertions(+), 2 deletions(-) diff --git a/src/core/generation/asset-generator.ts b/src/core/generation/asset-generator.ts index 80ca6d97..34e33155 100644 --- a/src/core/generation/asset-generator.ts +++ b/src/core/generation/asset-generator.ts @@ -83,10 +83,12 @@ export class AssetGenerator { public describe(config: GenerationConfig | null): void { this.statusController?.abort(); this.statusController = null; - this.setStatus(null); if (!config || !this.statusProvider) { + this.setStatus(null); return; } + // A pending refresh must not lift an existing block for this clip. + if (this.status?.clipId !== config.clipId || this.status.value.tone !== "error") this.setStatus(null); const controller = new AbortController(); this.statusController = controller; const settle = (value: GenerationStatus | undefined): void => { @@ -99,7 +101,7 @@ export class AssetGenerator { settle(undefined); }; try { - const result = this.statusProvider({ ...config, signal: controller.signal }); + const result = this.statusProvider({ ...structuredClone(config), signal: controller.signal }); if (result instanceof Promise) result.then(settle, fail); else settle(result); } catch (error) { diff --git a/tests/edit-clip-operations.test.ts b/tests/edit-clip-operations.test.ts index dca9f981..0eca3b24 100644 --- a/tests/edit-clip-operations.test.ts +++ b/tests/edit-clip-operations.test.ts @@ -2022,6 +2022,26 @@ describe("Edit Clip Operations", () => { expect(replacement).toHaveBeenCalledTimes(1); }); + it("isolates generated options from status provider mutations", async () => { + const { e, status } = await promptEdit({ + asset: { type: "image", prompt: "a cat", options: { resolution: "1K", imageUrls: ["https://cdn/reference.png"] } } + }); + status.mockImplementation(({ options }) => { + Object.assign(options, { resolution: "4K" }); + (options["imageUrls"] as string[]).push("https://cdn/extra.png"); + return { text: "estimated" }; + }); + let generatedOptions: unknown; + e.registerAssetGenerator(async ({ asset }) => { + generatedOptions = asset["options"]; + return { url: "https://cdn/out.png" }; + }); + e.selectClip(0, 0); + await e.generateClip(e.getClipId(0, 0)!); + expect(generatedOptions).toEqual({ resolution: "1K", imageUrls: ["https://cdn/reference.png"] }); + e.dispose(); + }); + it("aborts a pending answer on dispose", async () => { const { e, status } = await promptEdit({ asset: { type: "image", prompt: "a cat" } }); status.mockImplementation(() => new Promise(() => {})); diff --git a/tests/generate-toolbar.test.ts b/tests/generate-toolbar.test.ts index 9e3a3080..00abb027 100644 --- a/tests/generate-toolbar.test.ts +++ b/tests/generate-toolbar.test.ts @@ -21,6 +21,8 @@ jest.mock("@styles/inject", () => ({ })); import { EditEvent, InternalEvent } from "@core/events/edit-events"; +import { AssetGenerator } from "@core/generation/asset-generator"; +import type { GenerationStatus } from "@core/generation/generation-status"; import type { GenerationAssetType, GenerationModelDefinition, GenerationOptionDefinition } from "@core/generation/model-catalogue"; import { GenerateToolbar } from "@core/ui/generate-toolbar"; @@ -581,6 +583,50 @@ describe("GenerateToolbar", () => { }); describe("host status", () => { + it("keeps Generate and Enter blocked until an error status refresh settles", async () => { + const edit = createMockEdit(); + let generations = 0; + const generator = new AssetGenerator({ + getClipAsset: () => ({ type: "image", prompt: "a dog" }), + applyGeneratedSrc: async () => {}, + emitStarted: () => {}, + emitCompleted: () => {}, + emitFailed: () => {}, + emitStatusChanged: clipId => { + const listener = edit.getInternalEvents().on.mock.calls.find(([name]) => name === InternalEvent.GenerationStatusChanged)?.[1]; + listener?.({ clipId }); + } + }); + generator.register(async () => { + generations += 1; + return { url: "https://cdn/out.png" }; + }); + edit.getGenerationStatus.mockImplementation(clipId => generator.getStatus(clipId)); + edit.generateClip.mockImplementation(clipId => generator.generate(clipId)); + let resolveStatus!: (value: GenerationStatus | undefined) => void; + const pending = new Promise(resolve => { + resolveStatus = resolve; + }); + generator.registerStatus(({ prompt }) => (prompt === "a cat" ? { text: "Insufficient credits", tone: "error" } : pending)); + const config = { clipId: "clip-1", type: "image" as const, options: {}, length: 4, prompt: "a cat" }; + generator.describe(config); + const { toolbar, container } = mountToolbar(edit); + const button = container.querySelector("[data-action='generate']")!; + const prompt = container.querySelector("[data-prompt-input]")!; + expect(button.disabled).toBe(true); + generator.describe({ ...config, prompt: "a dog" }); + expect(button.disabled).toBe(true); + button.click(); + prompt.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + expect(generations).toBe(0); + resolveStatus(undefined); + await pending; + expect(button.disabled).toBe(false); + prompt.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + expect(generations).toBe(1); + toolbar.dispose(); + }); + it("shows the host's text in the note slot with its tone", () => { const edit = createMockEdit(); edit.getGenerationStatus.mockReturnValue({ text: "neutral line", tone: "neutral" });