From 727448b268be64bb45499b84a759d040d82cdb11 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Sun, 4 Oct 2026 15:08:23 +1100 Subject: [PATCH] feat: expose resolved assets for generation quotes --- src/core/edit-session.ts | 1 + src/core/generation/asset-generator.ts | 6 ++++-- src/core/generation/generation-status.ts | 2 ++ src/core/ui/generate-toolbar.ts | 5 +++-- tests/edit-clip-operations.test.ts | 25 ++++++++++++++++++++++-- tests/generate-toolbar.test.ts | 11 ++++------- 6 files changed, 37 insertions(+), 13 deletions(-) diff --git a/src/core/edit-session.ts b/src/core/edit-session.ts index f15fd81b..22261058 100644 --- a/src/core/edit-session.ts +++ b/src/core/edit-session.ts @@ -162,6 +162,7 @@ export class Edit { const { options } = asset; const config: GenerationConfig = { clipId, + asset, type, ...(typeof asset["model"] === "string" ? { model: asset["model"] } : {}), options: typeof options === "object" && options !== null && !Array.isArray(options) ? (options as Record) : {}, diff --git a/src/core/generation/asset-generator.ts b/src/core/generation/asset-generator.ts index 34e33155..fc389208 100644 --- a/src/core/generation/asset-generator.ts +++ b/src/core/generation/asset-generator.ts @@ -102,8 +102,10 @@ export class AssetGenerator { }; try { const result = this.statusProvider({ ...structuredClone(config), signal: controller.signal }); - if (result instanceof Promise) result.then(settle, fail); - else settle(result); + if (result instanceof Promise) { + if (this.status?.value.tone !== "error") this.setStatus({ clipId: config.clipId, value: { text: "Checking generation…", pending: true } }); + result.then(settle, fail); + } else settle(result); } catch (error) { fail(error); } diff --git a/src/core/generation/generation-status.ts b/src/core/generation/generation-status.ts index a6fc0b64..62923b63 100644 --- a/src/core/generation/generation-status.ts +++ b/src/core/generation/generation-status.ts @@ -2,6 +2,7 @@ import type { Edit } from "@core/edit-session"; export type GenerationConfig = { clipId: string; + asset: Record; type: "image" | "video" | "audio"; model?: string; options: Record; @@ -11,6 +12,7 @@ export type GenerationConfig = { export type GenerationStatus = { text: string; + pending?: boolean; tone?: "neutral" | "warning" | "error"; onActivate?: () => void; }; diff --git a/src/core/ui/generate-toolbar.ts b/src/core/ui/generate-toolbar.ts index fa306c9f..af49ed35 100644 --- a/src/core/ui/generate-toolbar.ts +++ b/src/core/ui/generate-toolbar.ts @@ -172,7 +172,8 @@ 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; + const status = this.edit.getGenerationStatus(clipId); + if (status?.pending || status?.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) => { @@ -354,7 +355,7 @@ export class GenerateToolbar extends BaseToolbar { const generating = state?.status === "generating"; const hasPrompt = (this.promptInput?.value ?? "").trim() !== ""; const label = this.generateBtn.querySelector("[data-generate-label]"); - const blocked = status?.tone === "error"; + const blocked = status?.pending || status?.tone === "error"; this.generateBtn.disabled = generating || !hasPrompt || missing.length > 0 || blocked; this.generateBtn.classList.toggle("is-generating", generating); if (label) { diff --git a/tests/edit-clip-operations.test.ts b/tests/edit-clip-operations.test.ts index 0eca3b24..ee0323c1 100644 --- a/tests/edit-clip-operations.test.ts +++ b/tests/edit-clip-operations.test.ts @@ -1845,8 +1845,29 @@ describe("Edit Clip Operations", () => { }); 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" } + { + asset: expect.objectContaining({ type: "audio", prompt: "hello", model: "polly-neural", options: { voice: "Matthew" } }), + clipId: e.getClipId(0, 0), + type: "audio", + model: "polly-neural", + options: { voice: "Matthew" }, + length: 4, + prompt: "hello" + } + ]); + }); + + it("passes the complete resolved asset for quoting and refreshes legacy voice changes", async () => { + const { e, status } = await promptEdit({ asset: { type: "text-to-speech", text: "{{ WORDS }}", voice: "Joanna" } }, [ + { find: "WORDS", replace: "hello" } ]); + e.selectClip(0, 0); + const clipId = e.getClipId(0, 0)!; + expect(status.mock.calls.at(-1)?.[0]).toMatchObject({ asset: { type: "text-to-speech", text: "hello", voice: "Joanna" } }); + const previous = status.mock.calls.at(-1)![0]; + await e.updateClipById(clipId, { asset: { type: "text-to-speech", text: "{{ WORDS }}", voice: "Matthew" } } as never); + expect(previous.signal.aborted).toBe(true); + expect(status.mock.calls.at(-1)?.[0]).toMatchObject({ asset: { voice: "Matthew", text: "hello" } }); }); it("resolves merge fields in the prompt", async () => { @@ -1955,7 +1976,7 @@ describe("Edit Clip Operations", () => { 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(); + expect(e.getGenerationStatus(e.getClipId(0, 0) ?? "")).toMatchObject({ pending: true }); await flush(); expect(e.getGenerationStatus(e.getClipId(0, 0) ?? "")).toEqual({ text: "later" }); }); diff --git a/tests/generate-toolbar.test.ts b/tests/generate-toolbar.test.ts index b752b0f4..ab463567 100644 --- a/tests/generate-toolbar.test.ts +++ b/tests/generate-toolbar.test.ts @@ -195,10 +195,7 @@ describe("GenerateToolbar", () => { expect(container.querySelector("[data-model-label]")?.textContent).toBe("Select model"); expect(container.querySelector("[data-options-picker]")?.disabled).toBe(true); container.querySelector("[data-model-picker]")?.click(); - expect([...container.querySelectorAll("[data-model-value]")].map(node => node.textContent)).toEqual([ - "flux-schnell", - "nano-banana-2" - ]); + expect([...container.querySelectorAll("[data-model-value]")].map(node => node.textContent)).toEqual(["flux-schnell", "nano-banana-2"]); expect(container.textContent).not.toContain("Automatic"); toolbar.dispose(); }); @@ -603,7 +600,7 @@ describe("GenerateToolbar", () => { toolbar.dispose(); }); - it("keeps Generate and Enter blocked until an error status refresh settles", async () => { + it.each([true, false])("keeps Generate and Enter blocked until an async status settles (previous error: %s)", async previousError => { const edit = createMockEdit(); let generations = 0; const generator = new AssetGenerator({ @@ -627,8 +624,8 @@ describe("GenerateToolbar", () => { 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.registerStatus(({ prompt }) => (previousError && prompt === "a cat" ? { text: "Insufficient credits", tone: "error" } : pending)); + const config = { asset: { type: "image", prompt: "a cat" }, 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']")!;