diff --git a/src/core/edit-session.ts b/src/core/edit-session.ts index e066963a..2ff39b15 100644 --- a/src/core/edit-session.ts +++ b/src/core/edit-session.ts @@ -26,10 +26,12 @@ import type { MergeFieldBinding } from "@core/edit-document"; 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"; 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 +125,7 @@ export class Edit { // ─── Internal Bookkeeping ───────────────────────────────────────────────── private clipsToDispose = new Set(); private clipErrors = new Map(); + private lastGenerationConfigKey: string | null = null; private playerByClipId = new Map(); private lumaContentRelations = new Map(); private fontMetadata = new Map(); @@ -140,6 +143,39 @@ 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)) { + if (this.lastGenerationConfigKey !== null) { + this.lastGenerationConfigKey = null; + this.assetGenerator.describe(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.assetGenerator.describe(config); + }; + /** * Create an Edit instance from a template configuration. */ @@ -174,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); @@ -291,6 +328,7 @@ 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.lumaMaskController.dispose(); this.playerReconciler.dispose(); @@ -452,6 +490,19 @@ export class Edit { this.internalEvents.emit(InternalEvent.AssetGeneratorChanged); } + /** @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.assetGenerator.getStatus(clipId); + } + /** @internal */ public registerGenerationSettings(handler: GenerationSettingsHandler): () => void { const callback: GenerationSettingsHandler = request => handler(request); @@ -2704,8 +2755,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..7aaa6072 100644 --- a/src/core/events/edit-events.ts +++ b/src/core/events/edit-events.ts @@ -138,7 +138,8 @@ export const InternalEvent = { ClipBlurred: "clip:blurred", // Asset generation UI - AssetGeneratorChanged: "assetGenerator:changed" + AssetGeneratorChanged: "assetGenerator:changed", + GenerationStatusChanged: "generation:statusChanged" } as const; // ───────────────────────────────────────────────────────────── @@ -237,4 +238,5 @@ export type InternalEventMap = { // Asset generation UI [InternalEvent.AssetGeneratorChanged]: void; + [InternalEvent.GenerationStatusChanged]: { clipId: string }; }; diff --git a/src/core/generation/asset-generator.ts b/src/core/generation/asset-generator.ts index c3dd8341..34e33155 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,57 @@ 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; + 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 => { + 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({ ...structuredClone(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 +181,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 994f523c..a8368e03 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 { - + `; @@ -175,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) => { @@ -186,7 +186,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 +416,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 +445,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/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/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..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(", @@ -67,10 +67,16 @@ const CONTRACT = { "getInternalEvents(", "getGenerationModels(", "pruneUnusedFonts(", - "getClipGenerationState(" + "getClipGenerationState(", + "getGenerationStatus(" ] }, dtsForbiddenTokens: [ + "registerGenerationStatus", + "setGenerationStatus", + "type GenerationConfig =", + "type GenerationStatus =", + "generation:configChanged", "registerGenerationSettings", "generationSettings", "GenerationSettingsHandler", @@ -252,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) { @@ -384,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 e20fb88b..0eca3b24 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 * @@ -6,6 +12,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 +1817,237 @@ describe("Edit Clip Operations", () => { expect(edit.getClip(0, 2)?.asset?.type).toBe("text"); }); }); + + describe("generation status", () => { + type Provider = jest.Mock, [GenerationStatusRequest]>; + + const promptEdit = async (clip: Record, merge: { find: string; replace: string }[] = []) => { + 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 status: Provider = jest.fn(); + registerGenerationStatus(e, status); + return { e, status }; + }; + + 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(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, status } = await promptEdit({ asset: { type: "image", prompt: "a {{ THING }}" } }, [{ find: "THING", replace: "cat" }]); + e.selectClip(0, 0); + 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, status } = await promptEdit({ asset: { type: "text-to-speech", text: "say this", voice: "Matthew" } }); + e.selectClip(0, 0); + 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, status } = await promptEdit({ asset: { type: "image", prompt: "a cat" } }); + e.selectClip(0, 0); + const [config] = configs(status); + expect(config).not.toHaveProperty("model"); + expect(config.options).toEqual({}); + }); + + it("reports undefined length for auto", async () => { + const { e, status } = await promptEdit({ asset: { type: "video", prompt: "pan" }, length: "auto" }); + e.selectClip(0, 0); + expect(configs(status)[0]).toHaveProperty("length", undefined); + }); + + it("reports resolved seconds for end", async () => { + const e = new ShotstackEdit({ + 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 status: Provider = jest.fn(); + registerGenerationStatus(e, status); + e.selectClip(0, 0); + expect(configs(status)[0].length).toBe(8); + }); + + 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(status).map(c => c.options)).toEqual([{ resolution: "1K" }, { resolution: "2K" }]); + }); + + 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(status)).toHaveLength(0); + }); + + 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(status)).toHaveLength(1); + }); + + 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(status).map(c => c.prompt)).toEqual(["a cat", "a cat"]); + }); + + 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 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); + 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("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(() => {})); + 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 60a45e3e..00abb027 100644 --- a/tests/generate-toolbar.test.ts +++ b/tests/generate-toolbar.test.ts @@ -20,7 +20,9 @@ jest.mock("@styles/inject", () => ({ injectShotstackStyles: jest.fn() })); -import { EditEvent } from "@core/events/edit-events"; +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"; @@ -36,6 +38,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 +581,140 @@ describe("GenerateToolbar", () => { toolbar.dispose(); }); + + 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" }); + 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"); + 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); + 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(); + }); + }); });