From 8832da59137fd3d91628f04851dde8e8d0485c8b Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Fri, 28 Aug 2026 12:11:26 +1000 Subject: [PATCH 1/8] feat: declare the generation config event and status type --- src/core/events/edit-events.ts | 29 ++++++++++++++++++++++++++++- src/index.ts | 1 + 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/src/core/events/edit-events.ts b/src/core/events/edit-events.ts index 44338faa..65850e97 100644 --- a/src/core/events/edit-events.ts +++ b/src/core/events/edit-events.ts @@ -98,6 +98,9 @@ export const EditEvent = { // Merge fields MergeFieldChanged: "mergefield:changed", + // Generation + GenerationConfigChanged: "generation:configChanged", + // Timeline UI TimelineResized: "timeline:resized" } as const; @@ -138,9 +141,29 @@ export const InternalEvent = { AssetGeneratorChanged: "assetGenerator:changed", ClipGenerationStarted: "clip:generationStarted", ClipGenerationCompleted: "clip:generationCompleted", - ClipGenerationFailed: "clip:generationFailed" + ClipGenerationFailed: "clip:generationFailed", + GenerationStatusChanged: "generation:statusChanged" } as const; +/** What the editor knows about a prompt-bearing clip's generation, for a host to act on. */ +export type GenerationConfig = { + clipId: string; + type: "image" | "video" | "audio"; + /** Absent when the clip relies on the backend default model. */ + model?: string; + options: Record; + /** Seconds. `undefined` when the length cannot be known before the asset exists. */ + length: number | undefined; + prompt: string; +}; + +/** A host-supplied line shown beside the Generate action. */ +export type GenerationStatus = { + text: string; + /** `error` also disables Generate. */ + tone?: "neutral" | "warning" | "error"; +}; + // ───────────────────────────────────────────────────────────── // Event Payload Maps // ───────────────────────────────────────────────────────────── @@ -194,6 +217,9 @@ export type EditEventMap = { // Merge fields [EditEvent.MergeFieldChanged]: { fields: MergeField[] }; + // Generation + [EditEvent.GenerationConfigChanged]: GenerationConfig; + // Timeline UI [EditEvent.TimelineResized]: { height: number }; }; @@ -237,4 +263,5 @@ export type InternalEventMap = { [InternalEvent.ClipGenerationStarted]: { clipId: string }; [InternalEvent.ClipGenerationCompleted]: { clipId: string }; [InternalEvent.ClipGenerationFailed]: { clipId: string; error: string }; + [InternalEvent.GenerationStatusChanged]: { clipId: string }; }; 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; From 2d5baa7b6fd8b390bf259e31fb5a871e2c524ea2 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Fri, 28 Aug 2026 12:17:28 +1000 Subject: [PATCH 2/8] feat: let a host set a generation status per clip --- src/core/edit-session.ts | 15 ++++++++++++++- test-package.js | 11 +++++++++-- tests/edit-clip-operations.test.ts | 22 ++++++++++++++++++++++ 3 files changed, 45 insertions(+), 3 deletions(-) diff --git a/src/core/edit-session.ts b/src/core/edit-session.ts index 707587fe..4b83b742 100644 --- a/src/core/edit-session.ts +++ b/src/core/edit-session.ts @@ -22,7 +22,7 @@ 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 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"; @@ -124,6 +124,7 @@ export class Edit { // ─── Internal Bookkeeping ───────────────────────────────────────────────── private clipsToDispose = new Set(); private clipErrors = new Map(); + private generationStatuses = new Map(); private playerByClipId = new Map(); private lumaContentRelations = new Map(); private fontMetadata = new Map(); @@ -452,6 +453,18 @@ export class Edit { this.internalEvents.emit(InternalEvent.AssetGeneratorChanged); } + /** Show a host-supplied line beside the Generate action for one clip. `undefined` clears it. */ + 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 hasAssetGenerator(): boolean { return this.assetGenerator.hasHandler(); diff --git a/test-package.js b/test-package.js index 67400ee3..04c995aa 100644 --- a/test-package.js +++ b/test-package.js @@ -60,7 +60,7 @@ const CONTRACT = { "clearSelection(", "registerClipRenderer(" ], - Edit: ["validateEdit(", "getTimelineFonts(", "getContentClipIdForLuma(", "getInternalEvents(", "getGenerationModels(", "pruneUnusedFonts("] + Edit: ["validateEdit(", "getTimelineFonts(", "getContentClipIdForLuma(", "getInternalEvents(", "getGenerationModels(", "pruneUnusedFonts(", "getGenerationStatus("] }, dtsForbiddenTokens: [ "export declare class SelectionHandles", @@ -128,7 +128,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;", + "setGenerationStatus(clipId: string, status: GenerationStatus | undefined): void;" + ] + }, { 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..846a2cdb 100644 --- a/tests/edit-clip-operations.test.ts +++ b/tests/edit-clip-operations.test.ts @@ -1781,4 +1781,26 @@ 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" }); + }); + }); }); From dbaf31b4df57481b9fc26e593050eaf6ef2ef9c3 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Fri, 28 Aug 2026 12:30:37 +1000 Subject: [PATCH 3/8] feat: emit the selected clip's generation config --- src/core/edit-session.ts | 56 +++++++++++++++++- tests/edit-clip-operations.test.ts | 92 +++++++++++++++++++++++++++++- 2 files changed, 146 insertions(+), 2 deletions(-) diff --git a/src/core/edit-session.ts b/src/core/edit-session.ts index 4b83b742..2e756597 100644 --- a/src/core/edit-session.ts +++ b/src/core/edit-session.ts @@ -22,13 +22,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, type GenerationStatus } 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"; @@ -125,6 +133,7 @@ export class Edit { 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(); @@ -142,6 +151,42 @@ export class Edit { this.assetGenerator.abortMissing(live); }; + /** + * Announce the selected prompt-bearing clip's configuration, deduped so only a real change + * reaches a host. Clearing the key on deselection means re-selecting the same clip announces + * it again, for a host that discarded what it knew. + */ + 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"])]; + if (!type) return; + + 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. */ @@ -292,6 +337,7 @@ export class Edit { public dispose(): void { 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.lumaMaskController.dispose(); this.playerReconciler.dispose(); @@ -2691,8 +2737,16 @@ export class Edit { // ─── Event Listeners ───────────────────────────────────────────────────────── + private static readonly GenerationConfigTriggers = [ + EditEvent.ClipSelected, + EditEvent.SelectionCleared, + EditEvent.EditChanged, + EditEvent.MergeFieldChanged + ] 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/tests/edit-clip-operations.test.ts b/tests/edit-clip-operations.test.ts index 846a2cdb..0eed4040 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"; @@ -1803,4 +1803,94 @@ describe("Edit Clip Operations", () => { expect(edit.getGenerationStatus("clip-b")).toEqual({ text: "b", tone: "error" }); }); }); + + 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); + }); + }); }); From dd26cb03b9b369fddb4bb179d402fa23eeda5103 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Fri, 28 Aug 2026 12:43:32 +1000 Subject: [PATCH 4/8] fix: announce generation config after a live merge field edit --- src/core/edit-session.ts | 5 ++--- tests/edit-clip-operations.test.ts | 22 ++++++++++++++++++++++ 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/src/core/edit-session.ts b/src/core/edit-session.ts index 2e756597..79760c4b 100644 --- a/src/core/edit-session.ts +++ b/src/core/edit-session.ts @@ -167,8 +167,6 @@ export class Edit { const asset = resolved.asset as unknown as Record; const type = GENERATION_TYPE[String(asset["type"])]; - if (!type) return; - const promptValue = asset[promptProperty(resolved.asset)]; const { options } = asset; const config: GenerationConfig = { @@ -2741,7 +2739,8 @@ export class Edit { EditEvent.ClipSelected, EditEvent.SelectionCleared, EditEvent.EditChanged, - EditEvent.MergeFieldChanged + EditEvent.MergeFieldChanged, + EditEvent.TimelineUpdated ] as const; private setupGenerationListeners(): void { diff --git a/tests/edit-clip-operations.test.ts b/tests/edit-clip-operations.test.ts index 0eed4040..140ea32b 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"; @@ -1892,5 +1893,26 @@ describe("Edit Clip Operations", () => { 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"]); + }); }); }); From f98b03c267bfef1ab2061c9f44a37e2354c96c2a Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Fri, 28 Aug 2026 12:50:14 +1000 Subject: [PATCH 5/8] feat: show a host status beside the generate action --- src/core/ui/generate-toolbar.ts | 34 ++++++++++++++--- src/styles/ui/generate-toolbar.css | 8 ++++ tests/generate-toolbar.test.ts | 60 ++++++++++++++++++++++++++++++ 3 files changed, 96 insertions(+), 6 deletions(-) diff --git a/src/core/ui/generate-toolbar.ts b/src/core/ui/generate-toolbar.ts index 27972bb5..eea88779 100644 --- a/src/core/ui/generate-toolbar.ts +++ b/src/core/ui/generate-toolbar.ts @@ -94,9 +94,7 @@ export class GenerateToolbar extends BaseToolbar { - + `; @@ -182,7 +180,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 = [InternalEvent.ClipGenerationStarted, InternalEvent.ClipGenerationCompleted, InternalEvent.ClipGenerationFailed] as const; + const names = [ + InternalEvent.ClipGenerationStarted, + InternalEvent.ClipGenerationCompleted, + InternalEvent.ClipGenerationFailed, + InternalEvent.GenerationStatusChanged + ] as const; for (const name of names) { const handler = (payload: { clipId: string }): void => { if (payload.clipId === this.getSelectedClipId()) this.syncState(); @@ -385,7 +388,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; @@ -396,7 +417,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/styles/ui/generate-toolbar.css b/src/styles/ui/generate-toolbar.css index 30eaa13f..7ea0c25f 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/tests/generate-toolbar.test.ts b/tests/generate-toolbar.test.ts index 7165ab24..7458dd82 100644 --- a/tests/generate-toolbar.test.ts +++ b/tests/generate-toolbar.test.ts @@ -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(), generateClipAsset: jest.fn().mockResolvedValue(undefined), resolveMergeFields: jest.fn((value: string) => value), updateClip: jest.fn(), @@ -498,4 +499,63 @@ 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); + edit.getGenerationStatus.mockReturnValue(undefined); + toolbar.show(0, 0); + const note = container.querySelector("[data-generate-note]"); + 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("re-syncs when the status for the selected clip changes", () => { + const edit = createMockEdit(); + const { toolbar } = 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(edit.getGenerationStatus).toHaveBeenCalled(); + toolbar.dispose(); + }); + }); }); From 0c4b97b568b5daa33ede5112fb283bdefccda404 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Fri, 28 Aug 2026 12:56:38 +1000 Subject: [PATCH 6/8] test: assert observable status re-sync and note title behaviour --- tests/generate-toolbar.test.ts | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/tests/generate-toolbar.test.ts b/tests/generate-toolbar.test.ts index 7458dd82..4fdda5e3 100644 --- a/tests/generate-toolbar.test.ts +++ b/tests/generate-toolbar.test.ts @@ -547,14 +547,32 @@ describe("GenerateToolbar", () => { 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 } = mountToolbar(edit); + 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(edit.getGenerationStatus).toHaveBeenCalled(); + expect(container.querySelector("[data-generate-note]")?.textContent).toBe("now"); toolbar.dispose(); }); }); From f6cfb5d1d93a3f163d70da0ef14cbf286572a2ea Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Fri, 28 Aug 2026 13:01:01 +1000 Subject: [PATCH 7/8] docs: document generation config event and status method --- readme.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/readme.md b/readme.md index ef039327..534c27a2 100644 --- a/readme.md +++ b/readme.md @@ -189,6 +189,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 @@ -209,6 +210,12 @@ 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. + 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 From 254eef6d303019ebf840d0f840d85c45008097d5 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Fri, 28 Aug 2026 13:24:04 +1000 Subject: [PATCH 8/8] fix: address review findings on generation status teardown, tests and wording --- readme.md | 3 ++- src/core/edit-session.ts | 1 + src/core/ui/generate-toolbar.ts | 1 + tests/edit-clip-operations.test.ts | 6 ++++++ tests/generate-toolbar.test.ts | 15 ++++++++++++++- 5 files changed, 24 insertions(+), 2 deletions(-) diff --git a/readme.md b/readme.md index 534c27a2..9f1182a6 100644 --- a/readme.md +++ b/readme.md @@ -214,7 +214,8 @@ Whenever the selected clip's model, options, length or prompt changes, the edito `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. +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 diff --git a/src/core/edit-session.ts b/src/core/edit-session.ts index 79760c4b..3ad00e0c 100644 --- a/src/core/edit-session.ts +++ b/src/core/edit-session.ts @@ -337,6 +337,7 @@ 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(); diff --git a/src/core/ui/generate-toolbar.ts b/src/core/ui/generate-toolbar.ts index eea88779..e98a5615 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", diff --git a/tests/edit-clip-operations.test.ts b/tests/edit-clip-operations.test.ts index 140ea32b..343c5d42 100644 --- a/tests/edit-clip-operations.test.ts +++ b/tests/edit-clip-operations.test.ts @@ -1803,6 +1803,12 @@ describe("Edit Clip Operations", () => { 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", () => { diff --git a/tests/generate-toolbar.test.ts b/tests/generate-toolbar.test.ts index 4fdda5e3..e1dadb48 100644 --- a/tests/generate-toolbar.test.ts +++ b/tests/generate-toolbar.test.ts @@ -530,9 +530,10 @@ describe("GenerateToolbar", () => { 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); - const note = container.querySelector("[data-generate-note]"); expect(note?.hidden).toBe(true); expect(note?.dataset["tone"]).toBeUndefined(); toolbar.dispose(); @@ -575,5 +576,17 @@ describe("GenerateToolbar", () => { 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(); + }); }); });