From cffd19ebe73ba6f3db614853d4105d1b64bb1d8d Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Thu, 27 Aug 2026 20:43:59 +1000 Subject: [PATCH] feat: add generation model and option controls --- src/core/ui/generate-toolbar.ts | 221 ++++++++++++++++++++++++++++- src/core/ui/ui-controller.ts | 8 ++ src/styles/ui/generate-toolbar.css | 109 ++++++++++++++ tests/generate-toolbar.test.ts | 174 +++++++++++++++++++++++ tests/toolbar.test.ts | 23 ++- 5 files changed, 531 insertions(+), 4 deletions(-) diff --git a/src/core/ui/generate-toolbar.ts b/src/core/ui/generate-toolbar.ts index 26ee3268..ae4f99f6 100644 --- a/src/core/ui/generate-toolbar.ts +++ b/src/core/ui/generate-toolbar.ts @@ -1,19 +1,60 @@ import { EditEvent, InternalEvent } from "@core/events/edit-events"; +import { + isGenerationOptionValueValid, + missingGenerationOptions, + reconcileGenerationOptions, + type GenerationAssetType, + type GenerationModelDefinition, + type GenerationOptionDefinition +} from "@core/generation/model-catalogue"; import { MERGE_FIELD_TEST_PATTERN } from "@core/merge/merge-field-service"; import { canCarryPrompt } from "@core/shared/ai-asset-utils"; import { injectShotstackStyles } from "@styles/inject"; -import { BaseToolbar } from "./base-toolbar"; +import { BaseToolbar, TOOLBAR_ICONS } from "./base-toolbar"; const PROMPT_DEBOUNCE_MS = 300; +const GENERATION_TYPE: Readonly> = { + image: "image", + video: "video", + audio: "audio", + "text-to-image": "image", + "image-to-video": "video", + "text-to-speech": "audio" +}; + +const OPTION_INPUT_TYPE: Readonly> = { + boolean: "checkbox", + integer: "number", + string: "text" +}; const promptProperty = (asset: { type: string }): "prompt" | "text" => (asset.type === "text-to-speech" ? "text" : "prompt"); +const record = (value: unknown): Record => + typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record) : {}; + +const modelLabelText = ( + models: readonly GenerationModelDefinition[] | undefined, + selected: string | undefined, + selectedModel: GenerationModelDefinition | undefined +): string => { + if (models === undefined) return ""; + if (selectedModel) return selectedModel.model; + if (selected) return `${selected} (Unavailable)`; + return models.length === 0 ? "No models available" : "Select model"; +}; export class GenerateToolbar extends BaseToolbar { private promptInput: HTMLInputElement | null = null; private generateBtn: HTMLButtonElement | null = null; private generateError: HTMLElement | null = null; private generateNote: HTMLElement | null = null; + private modelBtn: HTMLButtonElement | null = null; + private modelLabel: HTMLElement | null = null; + private modelPopup: HTMLElement | null = null; + private optionsBtn: HTMLButtonElement | null = null; + private optionsPopup: HTMLElement | null = null; + private optionRows = new Map(); private promptDebounceTimer: ReturnType | null = null; private generationUnsubscribers: (() => void)[] = []; private abortController: AbortController | null = null; @@ -50,6 +91,17 @@ export class GenerateToolbar extends BaseToolbar { +
+ + +
+
+ +
+
@@ -65,9 +117,15 @@ export class GenerateToolbar extends BaseToolbar { this.generateBtn = this.container.querySelector("[data-action='generate']"); this.generateError = this.container.querySelector("[data-generate-error]"); this.generateNote = this.container.querySelector("[data-generate-note]"); + this.modelBtn = this.container.querySelector("[data-model-picker]"); + this.modelLabel = this.container.querySelector("[data-model-label]"); + this.modelPopup = this.container.querySelector("[data-model-popup]"); + this.optionsBtn = this.container.querySelector("[data-options-picker]"); + this.optionsPopup = this.container.querySelector("[data-options-popup]"); this.setupEventListeners(); this.subscribeToEditState(); + this.setupOutsideClickHandler(); this.enableDrag(); this.appendDeleteButton(); } @@ -108,6 +166,15 @@ export class GenerateToolbar extends BaseToolbar { }, { signal } ); + + this.modelBtn?.addEventListener("click", e => { + e.stopPropagation(); + this.togglePopup(this.modelPopup); + }, { signal }); + this.optionsBtn?.addEventListener("click", e => { + e.stopPropagation(); + this.togglePopup(this.optionsPopup); + }, { signal }); } private requestGeneration(): void { @@ -134,6 +201,9 @@ export class GenerateToolbar extends BaseToolbar { events.on(name, handler); this.generationUnsubscribers.push(() => events.off(name, handler)); } + const onGeneratorChanged = (): void => this.syncState(); + events.on(InternalEvent.AssetGeneratorChanged, onGeneratorChanged); + this.generationUnsubscribers.push(() => events.off(InternalEvent.AssetGeneratorChanged, onGeneratorChanged)); const onEditChanged = (): void => this.syncState(); this.edit.events.on(EditEvent.EditChanged, onEditChanged); @@ -170,6 +240,144 @@ export class GenerateToolbar extends BaseToolbar { } as never); } + private selectModel(model: GenerationModelDefinition): void { + const rawAsset = record(this.edit.getDocumentClip(this.selectedTrackIdx, this.selectedClipIdx)?.asset); + const resolvedAsset = record(this.edit.getResolvedClip(this.selectedTrackIdx, this.selectedClipIdx)?.asset); + const options = reconcileGenerationOptions(model, record(rawAsset["options"]), record(resolvedAsset["options"])); + this.edit.updateClip(this.selectedTrackIdx, this.selectedClipIdx, { asset: { model: model.model, options } } as never); + this.closeAllPopups(); + } + + private renderModelPopup(models: readonly GenerationModelDefinition[], selected: string | undefined): void { + if (!this.modelPopup) return; + this.modelPopup.replaceChildren(); + for (const model of models) { + const button = document.createElement("button"); + button.type = "button"; + button.className = "ss-media-toolbar-popup-item"; + button.dataset["modelValue"] = model.model; + button.textContent = model.model; + button.classList.toggle("active", model.model === selected); + button.addEventListener("click", () => this.selectModel(model)); + this.modelPopup.appendChild(button); + } + } + + private createOptionControl(option: GenerationOptionDefinition, value: unknown): HTMLInputElement | HTMLSelectElement { + if (option.values) { + const select = document.createElement("select"); + const empty = document.createElement("option"); + empty.value = ""; + empty.textContent = "Select…"; + select.appendChild(empty); + for (const candidate of option.values) { + const item = document.createElement("option"); + item.value = candidate; + item.textContent = candidate; + select.appendChild(item); + } + select.value = typeof value === "string" ? value : ""; + return select; + } + + const input = document.createElement("input"); + input.type = option.format === "uri" ? "url" : OPTION_INPUT_TYPE[option.type]; + if (input.type === "checkbox") input.checked = value === true; + else input.value = value === undefined ? "" : String(value); + if (option.minimum !== undefined) input.min = String(option.minimum); + if (option.maximum !== undefined) input.max = String(option.maximum); + return input; + } + + private readOptionControl(control: HTMLInputElement | HTMLSelectElement, option: GenerationOptionDefinition): unknown { + if (control instanceof HTMLInputElement && control.type === "checkbox") return control.checked; + if (control.value === "") return undefined; + return option.type === "integer" ? Number(control.value) : control.value; + } + + private renderOptions(model: GenerationModelDefinition, values: Record): void { + if (!this.optionsPopup) return; + this.optionsPopup.replaceChildren(); + this.optionRows.clear(); + for (const option of model.options) { + const row = document.createElement("label"); + row.className = "ss-ai-option-row"; + row.dataset["optionRow"] = option.name; + const title = document.createElement("span"); + title.textContent = option.title; + const value = values[option.name] ?? (option.hasDefault ? option.defaultValue : undefined); + const control = this.createOptionControl(option, value); + control.dataset["option"] = option.name; + // A required boolean is satisfied by false, which an unchecked required checkbox reports as invalid. + control.required = option.required && option.type !== "boolean"; + control.addEventListener("change", () => { + // An empty required field is the missing-option state and must commit; malformed input must not. + if (!control.validity.valid && !control.validity.valueMissing) return; + const next = this.readOptionControl(control, option); + this.edit.updateClip(this.selectedTrackIdx, this.selectedClipIdx, { asset: { options: { [option.name]: next } } } as never); + this.syncMissingOptions(model, { ...values, [option.name]: next }); + }); + row.append(title, control); + this.optionRows.set(option.name, row); + this.optionsPopup.appendChild(row); + } + + for (const option of model.unsupported) { + const row = document.createElement("div"); + row.className = "ss-ai-option-row is-unsupported"; + row.title = "This option can only be set outside the editor."; + const title = document.createElement("span"); + title.textContent = option.title; + const state = document.createElement("span"); + state.className = "ss-ai-option-state"; + state.textContent = values[option.name] === undefined ? "Not set" : "Configured"; + row.append(title, state); + this.optionsPopup.appendChild(row); + } + } + + private syncMissingOptions(model: GenerationModelDefinition | undefined, values: Record): readonly string[] { + const missing = model ? missingGenerationOptions(model, values) : []; + this.optionsBtn?.classList.toggle("has-error", missing.length > 0); + if (this.optionsBtn) this.optionsBtn.title = missing.length > 0 ? `Missing: ${missing.join(", ")}` : "Generation options"; + if (model) { + for (const option of model.options) { + const row = this.optionRows.get(option.name); + row?.toggleAttribute("data-missing", option.required && !isGenerationOptionValueValid(option, values[option.name])); + } + } + return missing; + } + + private syncCatalogueControls(asset: Record): readonly string[] { + const type = typeof asset["type"] === "string" ? GENERATION_TYPE[asset["type"]] : undefined; + const models = type ? this.edit.getGenerationModels(type) : undefined; + const selected = typeof asset["model"] === "string" ? asset["model"] : undefined; + const selectedModel = models?.find(model => model.model === selected); + + if (this.modelBtn) { + this.modelBtn.hidden = models === undefined; + this.modelBtn.disabled = models?.length === 0; + this.modelBtn.classList.toggle("is-unavailable", selected !== undefined && selectedModel === undefined); + } + if (this.modelLabel) this.modelLabel.textContent = modelLabelText(models, selected, selectedModel); + this.renderModelPopup(models ?? [], selected); + + if (this.optionsBtn) { + const empty = selectedModel !== undefined && selectedModel.options.length === 0 && selectedModel.unsupported.length === 0; + this.optionsBtn.hidden = models === undefined || models.length === 0 || empty; + this.optionsBtn.disabled = selectedModel === undefined; + } + const values = record(asset["options"]); + if (selectedModel) { + this.renderOptions(selectedModel, values); + } else { + this.optionsPopup?.replaceChildren(); + this.optionRows.clear(); + } + return this.syncMissingOptions(selectedModel, values); + } + protected override syncState(): void { const clip = this.edit.getResolvedClip(this.selectedTrackIdx, this.selectedClipIdx); const asset = clip?.asset; @@ -185,6 +393,7 @@ export class GenerateToolbar extends BaseToolbar { this.promptInput.value = binding?.placeholder ?? (typeof value === "string" ? value : ""); } + const missing = this.syncCatalogueControls(record(asset)); const hasGenerator = this.edit.hasAssetGenerator(); this.generateBtn.hidden = !hasGenerator; if (this.generateNote) this.generateNote.hidden = hasGenerator; @@ -198,7 +407,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]"); - this.generateBtn.disabled = generating || !hasPrompt; + this.generateBtn.disabled = generating || !hasPrompt || missing.length > 0; this.generateBtn.classList.toggle("is-generating", generating); if (label) { if (generating) label.textContent = "Generating…"; @@ -213,7 +422,7 @@ export class GenerateToolbar extends BaseToolbar { } protected override getPopupList(): (HTMLElement | null)[] { - return []; + return [this.modelPopup, this.optionsPopup]; } override dispose(): void { @@ -232,5 +441,11 @@ export class GenerateToolbar extends BaseToolbar { this.generateBtn = null; this.generateError = null; this.generateNote = null; + this.modelBtn = null; + this.modelLabel = null; + this.modelPopup = null; + this.optionsBtn = null; + this.optionsPopup = null; + this.optionRows.clear(); } } diff --git a/src/core/ui/ui-controller.ts b/src/core/ui/ui-controller.ts index ff69f9f1..9cf63d90 100644 --- a/src/core/ui/ui-controller.ts +++ b/src/core/ui/ui-controller.ts @@ -391,6 +391,14 @@ export class UIController { this.generationListeners.push(() => internalEvents.off(name, handler)); } + // Registering a generator after mount can make the generate segment newly available. + const onGeneratorChanged = (): void => { + this.syncGenerateSegments(); + this.updateToolbarVisibility(); + }; + internalEvents.on(InternalEvent.AssetGeneratorChanged, onGeneratorChanged); + this.generationListeners.push(() => internalEvents.off(InternalEvent.AssetGeneratorChanged, onGeneratorChanged)); + // Position toolbars after DOM is ready // Using nested rAF to ensure layout is complete before measuring requestAnimationFrame(() => { diff --git a/src/styles/ui/generate-toolbar.css b/src/styles/ui/generate-toolbar.css index 83af2e61..30eaa13f 100644 --- a/src/styles/ui/generate-toolbar.css +++ b/src/styles/ui/generate-toolbar.css @@ -101,3 +101,112 @@ .ss-ai-error[hidden] { display: none; } + +.ss-ai-picker-wrap { + position: relative; +} + +.ss-ai-picker { + max-width: 180px; +} + +.ss-ai-picker[hidden] { + display: none; +} + +.ss-ai-picker:disabled { + opacity: 0.4; + cursor: default; +} + +.ss-ai-picker > [data-model-label] { + overflow: hidden; + text-overflow: ellipsis; +} + +/* An unavailable model is still the clip's real value, so its label stays legible. */ +.ss-ai-picker.is-unavailable > [data-model-label] { + color: #fcd34d; +} + +.ss-ai-picker.has-error { + color: #fca5a5; + background: rgba(248, 113, 113, 0.12); +} + +.ss-ai-model-popup > .ss-media-toolbar-popup-item { + width: 100%; + background: transparent; + border: none; + font-family: inherit; + text-align: left; +} + +.ss-ai-options-popup { + min-width: 240px; + display: none; + flex-direction: column; + gap: 8px; +} + +.ss-ai-options-popup.visible { + display: flex; +} + +.ss-ai-option-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 4px 8px; + font-size: 12px; + color: rgba(255, 255, 255, 0.85); +} + +.ss-ai-option-row[data-missing] { + color: #fca5a5; +} + +.ss-ai-option-row input:not([type="checkbox"]), +.ss-ai-option-row select { + width: 120px; + height: 26px; + box-sizing: border-box; + padding: 0 8px; + background: rgba(0, 0, 0, 0.3); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 6px; + color: #fafafa; + font-family: inherit; + font-size: 12px; +} + +.ss-ai-option-row input:focus, +.ss-ai-option-row select:focus { + outline: none; + border-color: rgba(255, 255, 255, 0.28); +} + +.ss-ai-option-row[data-missing] input:not([type="checkbox"]), +.ss-ai-option-row[data-missing] select { + border-color: rgba(248, 113, 113, 0.5); +} + +.ss-ai-option-row input[type="checkbox"] { + width: 14px; + height: 14px; + accent-color: #fafafa; +} + +/* Published but uneditable: legible enough to confirm the value survives, quiet enough + not to read as a control. */ +.ss-ai-option-row.is-unsupported { + color: rgba(255, 255, 255, 0.45); + cursor: default; +} + +.ss-ai-option-state { + font-size: 11px; + font-variant-numeric: tabular-nums; + color: rgba(255, 255, 255, 0.35); +} diff --git a/tests/generate-toolbar.test.ts b/tests/generate-toolbar.test.ts index a4457a9d..7165ab24 100644 --- a/tests/generate-toolbar.test.ts +++ b/tests/generate-toolbar.test.ts @@ -21,6 +21,7 @@ jest.mock("@styles/inject", () => ({ })); import { InternalEvent } from "@core/events/edit-events"; +import type { GenerationAssetType, GenerationModelDefinition, GenerationOptionDefinition } from "@core/generation/model-catalogue"; import { GenerateToolbar } from "@core/ui/generate-toolbar"; type MockEdit = ReturnType; @@ -30,6 +31,8 @@ function createMockEdit(asset: Record = { type: "image", prompt return { getClipId: jest.fn().mockReturnValue("clip-1"), getResolvedClip: jest.fn().mockReturnValue({ asset }), + getDocumentClip: jest.fn().mockReturnValue({ asset }), + getGenerationModels: jest.fn(), getDocument: jest.fn(), hasAssetGenerator: jest.fn().mockReturnValue(true), getClipGenerationState: jest.fn(), @@ -43,6 +46,25 @@ function createMockEdit(asset: Record = { type: "image", prompt }; } +const model = ( + name: string, + type: GenerationAssetType = "image", + options: readonly GenerationOptionDefinition[] = [], + unsupported: readonly { name: string; title: string }[] = [] +): GenerationModelDefinition => ({ + model: name, + type, + optionNames: [...options.map(option => option.name), ...unsupported.map(entry => entry.name)], + options, + unsupported +}); + +const option = ( + name: string, + type: GenerationOptionDefinition["type"], + overrides: Partial = {} +): GenerationOptionDefinition => ({ name, title: name, type, required: false, hasDefault: false, ...overrides }); + function mountToolbar(edit: MockEdit): { toolbar: GenerateToolbar; container: HTMLDivElement } { const container = document.createElement("div"); document.body.appendChild(container); @@ -137,6 +159,158 @@ describe("GenerateToolbar", () => { toolbar.dispose(); }); + it.each([ + [undefined, true, ""], + [[], false, "No models available"] + ])("distinguishes an absent catalogue from an empty one", (models, hidden, label) => { + const edit = createMockEdit(); + edit.getGenerationModels.mockReturnValue(models); + const { toolbar, container } = mountToolbar(edit); + const picker = container.querySelector("[data-model-picker]"); + expect(picker?.hidden).toBe(hidden); + expect(container.querySelector("[data-model-label]")?.textContent).toBe(label); + if (models) expect(picker?.disabled).toBe(true); + toolbar.dispose(); + }); + + it("shows available models without inventing an automatic entry", () => { + const edit = createMockEdit(); + edit.getGenerationModels.mockReturnValue([model("flux-schnell"), model("nano-banana-2")]); + const { toolbar, container } = mountToolbar(edit); + + 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.textContent).not.toContain("Automatic"); + toolbar.dispose(); + }); + + it("hides Options for a selected model with no controls", () => { + const edit = createMockEdit({ type: "image", prompt: "a cat", model: "flux-schnell" }); + edit.getGenerationModels.mockReturnValue([model("flux-schnell")]); + const { toolbar, container } = mountToolbar(edit); + expect(container.querySelector("[data-options-picker]")?.hidden).toBe(true); + toolbar.dispose(); + }); + + it("preserves an unavailable model until an available model is selected", () => { + const asset = { type: "image", prompt: "a cat", model: "host-model", options: { seed: 7 } }; + const edit = createMockEdit(asset); + edit.getGenerationModels.mockReturnValue([model("nano-banana-2")]); + const { toolbar, container } = mountToolbar(edit); + + expect(container.querySelector("[data-model-label]")?.textContent).toBe("host-model (Unavailable)"); + expect(edit.updateClip).not.toHaveBeenCalled(); + container.querySelector('[data-model-value="nano-banana-2"]')?.click(); + expect(edit.updateClip).toHaveBeenCalledWith(0, 0, { asset: { model: "nano-banana-2", options: { seed: undefined } } }); + toolbar.dispose(); + }); + + it("reconciles defaults and raw merge fields in one model update", () => { + const resolution = option("resolution", "string", { values: ["1K", "2K"], hasDefault: true, defaultValue: "1K" }); + const seed = option("seed", "integer"); + const edit = createMockEdit({ type: "image", prompt: "a cat", options: { seed: 7 } }); + edit.getDocumentClip.mockReturnValue({ asset: { type: "image", options: { resolution: "{{ SIZE }}", seed: 7 } } }); + edit.getResolvedClip.mockReturnValue({ asset: { type: "image", prompt: "a cat", options: { resolution: "2K", seed: 7 } } }); + edit.getGenerationModels.mockReturnValue([model("nano-banana-2", "image", [resolution, seed])]); + const { toolbar, container } = mountToolbar(edit); + + container.querySelector('[data-model-value="nano-banana-2"]')?.click(); + expect(edit.updateClip).toHaveBeenCalledWith(0, 0, { + asset: { model: "nano-banana-2", options: { resolution: "{{ SIZE }}", seed: 7 } } + }); + toolbar.dispose(); + }); + + it("renders each supported schema shape as a native control", () => { + const options = [ + option("resolution", "string", { values: ["720p", "1080p"] }), + option("generateAudio", "boolean"), + option("musicLengthMs", "integer", { minimum: 1000, maximum: 60000 }), + option("compositionPlan", "string"), + option("inputSrc", "string", { format: "uri" }) + ]; + const edit = createMockEdit({ type: "video", prompt: "a cat", model: "video-model", options: {} }); + edit.getGenerationModels.mockReturnValue([model("video-model", "video", options)]); + const { toolbar, container } = mountToolbar(edit); + + expect(container.querySelector('[data-option="resolution"]')?.tagName).toBe("SELECT"); + expect(container.querySelector('[data-option="generateAudio"]')?.type).toBe("checkbox"); + const number = container.querySelector('[data-option="musicLengthMs"]'); + expect([number?.type, number?.min, number?.max]).toEqual(["number", "1000", "60000"]); + expect(container.querySelector('[data-option="compositionPlan"]')?.type).toBe("text"); + expect(container.querySelector('[data-option="inputSrc"]')?.type).toBe("url"); + + number!.value = "5000"; + number?.dispatchEvent(new Event("change", { bubbles: true })); + expect(edit.updateClip).toHaveBeenCalledWith(0, 0, { asset: { options: { musicLengthMs: 5000 } } }); + toolbar.dispose(); + }); + + it("requires configured model options but permits the backend default model", () => { + const voice = option("voice", "string", { title: "Voice", required: true }); + const asset = { type: "audio", prompt: "hello", model: "speech", options: {} }; + const edit = createMockEdit(asset); + edit.getGenerationModels.mockReturnValue([model("speech", "audio", [voice])]); + const { toolbar, container } = mountToolbar(edit); + const generate = container.querySelector("[data-action='generate']"); + const optionsButton = container.querySelector("[data-options-picker]"); + + expect(generate?.disabled).toBe(true); + expect(optionsButton?.classList.contains("has-error")).toBe(true); + expect(optionsButton?.title).toContain("Voice"); + expect(container.querySelector('[data-option-row="voice"]')?.hasAttribute("data-missing")).toBe(true); + + asset.model = undefined as never; + toolbar.show(0, 0); + expect(generate?.disabled).toBe(false); + toolbar.dispose(); + }); + + it("commits a cleared required option and marks it missing", () => { + const voice = option("voice", "string", { title: "Voice", required: true }); + const edit = createMockEdit({ type: "audio", prompt: "hello", model: "speech", options: { voice: "Matthew" } }); + edit.getGenerationModels.mockReturnValue([model("speech", "audio", [voice])]); + const { toolbar, container } = mountToolbar(edit); + const control = container.querySelector('[data-option="voice"]'); + + control!.value = ""; + control?.dispatchEvent(new Event("change", { bubbles: true })); + + expect(edit.updateClip).toHaveBeenCalledWith(0, 0, { asset: { options: { voice: undefined } } }); + expect(container.querySelector('[data-option-row="voice"]')?.hasAttribute("data-missing")).toBe(true); + expect(container.querySelector("[data-options-picker]")?.classList.contains("has-error")).toBe(true); + toolbar.dispose(); + }); + + it("shows a published option it cannot render instead of dropping it", () => { + const plan = { name: "compositionPlan", title: "Composition plan" }; + const edit = createMockEdit({ type: "audio", prompt: "a score", model: "music", options: { compositionPlan: { sections: [] } } }); + edit.getGenerationModels.mockReturnValue([model("music", "audio", [option("forceInstrumental", "boolean", { title: "Instrumental only" })], [plan])]); + const { toolbar, container } = mountToolbar(edit); + + const row = container.querySelector(".ss-ai-option-row.is-unsupported"); + expect(row?.textContent).toContain("Composition plan"); + expect(row?.textContent).toContain("Configured"); + expect(row?.querySelector("input, select")).toBeNull(); + expect(container.querySelector("[data-options-picker]")?.hidden).toBe(false); + toolbar.dispose(); + }); + + it("keeps Options reachable when every published option is unrenderable", () => { + const edit = createMockEdit({ type: "audio", prompt: "a score", model: "music", options: {} }); + edit.getGenerationModels.mockReturnValue([model("music", "audio", [], [{ name: "compositionPlan", title: "Composition plan" }])]); + const { toolbar, container } = mountToolbar(edit); + + expect(container.querySelector("[data-options-picker]")?.hidden).toBe(false); + expect(container.querySelector(".ss-ai-option-row.is-unsupported")?.textContent).toContain("Not set"); + toolbar.dispose(); + }); + it("generates the selected clip when the action is pressed", () => { const edit = createMockEdit(); const { toolbar, container } = mountToolbar(edit); diff --git a/tests/toolbar.test.ts b/tests/toolbar.test.ts index 2a86a84e..595033d5 100644 --- a/tests/toolbar.test.ts +++ b/tests/toolbar.test.ts @@ -69,7 +69,7 @@ global.ResizeObserver = jest.fn().mockImplementation(() => ({ import { AssetToolbar } from "../src/core/ui/asset-toolbar"; import { CanvasToolbar } from "../src/core/ui/canvas-toolbar"; import { BUILT_IN_FONTS, FONT_SIZES } from "../src/core/ui/base-toolbar"; -import { EditEvent } from "../src/core/events/edit-events"; +import { EditEvent, InternalEvent } from "../src/core/events/edit-events"; import { UIController, type ToolbarButtonConfig } from "../src/core/ui/ui-controller"; type MockPlayer = { @@ -1980,6 +1980,27 @@ describe("Mode Toggle (Regression)", () => { cleanupTestContainer(container); }); + it("refreshes the generate segment when a generator is registered after mount", () => { + const hasAssetGenerator = jest.fn(() => false); + const mockEdit = createMockEdit({ + hasAssetGenerator, + getResolvedClip: jest.fn(() => ({ asset: { type: "image", src: "https://cdn/image.png" } })) + }); + const ui = UIController.minimal(mockEdit as never); + const container = createTestContainer(); + container.innerHTML = '
'; + ui.mount(container); + mockEdit.events.trigger(EditEvent.ClipSelected, { trackIndex: 0, clipIndex: 0 }); + expect(container.querySelector(".ss-toolbar-mode-toggle")?.hasAttribute("data-generative")).toBe(false); + + hasAssetGenerator.mockReturnValue(true); + mockEdit.events.trigger(InternalEvent.AssetGeneratorChanged); + expect(container.querySelector(".ss-toolbar-mode-toggle")?.hasAttribute("data-generative")).toBe(true); + + ui.dispose(); + cleanupTestContainer(container); + }); + it("activates the generate segment when a pending AI clip is selected", () => { const mockEdit = createMockEdit({ getResolvedClip: jest.fn(() => ({ asset: { type: "image", prompt: "a cat" } }))