diff --git a/src/core/shared/ai-asset-utils.ts b/src/core/shared/ai-asset-utils.ts index 72615d7d..d0a18562 100644 --- a/src/core/shared/ai-asset-utils.ts +++ b/src/core/shared/ai-asset-utils.ts @@ -50,6 +50,18 @@ export function isAiAsset(asset: unknown): asset is AiAsset { return PROMPTABLE_MEDIA_TYPES.has(type) && hasText((asset as AiAsset).prompt); } +/** + * Whether an asset can carry a prompt, whether or not it does yet. A prompt is the only + * thing that makes a clip generate, so this is equally the set of clips a prompt can be + * added to or removed from: adding one turns a plain asset generative, clearing it turns + * a generated asset back into a fixed `src`. + */ +export function canCarryPrompt(asset: unknown): asset is AiAsset { + if (typeof asset !== "object" || asset === null || !("type" in asset)) return false; + const { type } = asset as { type: unknown }; + return typeof type === "string" && (AI_ASSET_TYPES.has(type) || PROMPTABLE_MEDIA_TYPES.has(type)); +} + /** * Whether an AI asset is still awaiting generation. Legacy generative types * are always pending (realisation replaces them with a media type); a diff --git a/src/core/ui/generate-toolbar.ts b/src/core/ui/generate-toolbar.ts index cdb3050e..26ee3268 100644 --- a/src/core/ui/generate-toolbar.ts +++ b/src/core/ui/generate-toolbar.ts @@ -1,6 +1,6 @@ -import { InternalEvent } from "@core/events/edit-events"; +import { EditEvent, InternalEvent } from "@core/events/edit-events"; import { MERGE_FIELD_TEST_PATTERN } from "@core/merge/merge-field-service"; -import { isAiAsset } from "@core/shared/ai-asset-utils"; +import { canCarryPrompt } from "@core/shared/ai-asset-utils"; import { injectShotstackStyles } from "@styles/inject"; import { BaseToolbar } from "./base-toolbar"; @@ -67,7 +67,7 @@ export class GenerateToolbar extends BaseToolbar { this.generateNote = this.container.querySelector("[data-generate-note]"); this.setupEventListeners(); - this.subscribeToGeneration(); + this.subscribeToEditState(); this.enableDrag(); this.appendDeleteButton(); } @@ -77,7 +77,14 @@ export class GenerateToolbar extends BaseToolbar { this.abortController = new AbortController(); const { signal } = this.abortController; - this.promptInput?.addEventListener("input", () => this.schedulePromptCommit(), { signal }); + this.promptInput?.addEventListener( + "input", + () => { + this.schedulePromptCommit(); + this.syncState(); + }, + { signal } + ); // Enter is the only way to generate without leaving the keyboard, so the pending // prompt has to land on the clip first or the run would use the previous value. @@ -105,7 +112,9 @@ export class GenerateToolbar extends BaseToolbar { private requestGeneration(): void { const clipId = this.getSelectedClipId(); - if (!clipId || this.generateBtn?.disabled) return; + if (!clipId) return; + if ((this.promptInput?.value ?? "").trim() === "") return; + if (this.edit.getClipGenerationState(clipId)?.status === "generating") 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.generateClipAsset(clipId).catch((error: unknown) => { @@ -113,7 +122,7 @@ export class GenerateToolbar extends BaseToolbar { }); } - private subscribeToGeneration(): void { + private subscribeToEditState(): void { // mount() can run more than once on an instance; never stack listeners. if (this.generationUnsubscribers.length > 0) return; const events = this.edit.getInternalEvents(); @@ -125,6 +134,10 @@ export class GenerateToolbar extends BaseToolbar { events.on(name, handler); this.generationUnsubscribers.push(() => events.off(name, handler)); } + + const onEditChanged = (): void => this.syncState(); + this.edit.events.on(EditEvent.EditChanged, onEditChanged); + this.generationUnsubscribers.push(() => this.edit.events.off(EditEvent.EditChanged, onEditChanged)); } private schedulePromptCommit(): void { @@ -141,28 +154,29 @@ export class GenerateToolbar extends BaseToolbar { const property = promptProperty(clip.asset); const path = `asset.${property}`; const rawText = this.promptInput?.value ?? ""; + const cleared = rawText.trim() === ""; const resolvedText = this.edit.resolveMergeFields(rawText); const document = this.edit.getDocument(); const clipId = this.getSelectedClipId(); if (clipId && document) { - if (MERGE_FIELD_TEST_PATTERN.test(rawText)) { + if (!cleared && MERGE_FIELD_TEST_PATTERN.test(rawText)) { document.setClipBinding(clipId, path, { placeholder: rawText, resolvedValue: resolvedText }); } else { document.removeClipBinding(clipId, path); } } this.edit.updateClip(this.selectedTrackIdx, this.selectedClipIdx, { - asset: { ...clip.asset, [property]: resolvedText } + asset: { ...clip.asset, [property]: cleared ? undefined : resolvedText } } as never); } protected override syncState(): void { const clip = this.edit.getResolvedClip(this.selectedTrackIdx, this.selectedClipIdx); const asset = clip?.asset; - if (!isAiAsset(asset) || !this.generateBtn) return; - - // Never overwrite what is being typed; the debounce has not committed it yet. - if (this.promptInput && this.promptInput !== window.document.activeElement) { + if (!canCarryPrompt(asset) || !this.generateBtn) return; + // While a commit is pending the field holds newer text than the asset, so the field + // wins. The enabled state below reads the field, so it must not be overwritten here. + if (this.promptInput && this.promptDebounceTimer === null) { const property = promptProperty(asset); const document = this.edit.getDocument(); const clipId = this.getSelectedClipId(); @@ -182,8 +196,9 @@ export class GenerateToolbar extends BaseToolbar { const clipId = this.getSelectedClipId(); const state = clipId ? this.edit.getClipGenerationState(clipId) : undefined; const generating = state?.status === "generating"; + const hasPrompt = (this.promptInput?.value ?? "").trim() !== ""; const label = this.generateBtn.querySelector("[data-generate-label]"); - this.generateBtn.disabled = generating; + this.generateBtn.disabled = generating || !hasPrompt; this.generateBtn.classList.toggle("is-generating", generating); if (label) { if (generating) label.textContent = "Generating…"; diff --git a/src/core/ui/ui-controller.ts b/src/core/ui/ui-controller.ts index 0aac1736..ff69f9f1 100644 --- a/src/core/ui/ui-controller.ts +++ b/src/core/ui/ui-controller.ts @@ -2,7 +2,7 @@ import { Canvas } from "@canvas/shotstack-canvas"; import type { Edit } from "@core/edit-session"; import { EditEvent, InternalEvent } from "@core/events/edit-events"; import { EventEmitter } from "@core/events/event-emitter"; -import { isAiAsset, isPendingAiAsset } from "@core/shared/ai-asset-utils"; +import { canCarryPrompt, isAiAsset, isPendingAiAsset } from "@core/shared/ai-asset-utils"; import { ShotstackEdit } from "@core/shotstack-edit"; import type * as pixi from "pixi.js"; @@ -646,15 +646,6 @@ export class UIController { */ private setToolbarMode(mode: ToolbarMode): void { this.toolbarMode = mode; - - // Update all toggle UIs - this.container?.querySelectorAll(".ss-toolbar-mode-toggle").forEach(toggle => { - toggle.setAttribute("data-mode", mode); - toggle.querySelectorAll(".ss-toolbar-mode-btn").forEach(btn => { - btn.classList.toggle("active", (btn as HTMLElement).dataset["mode"] === mode); - }); - }); - this.syncGenerateSegments(); this.updateToolbarVisibility(); } @@ -694,10 +685,9 @@ export class UIController { } } - /** Whether the current selection can be generated from a prompt. */ - private isGenerativeSelection(): boolean { + private isGenerateModeAvailable(): boolean { const clip = this.edit.getResolvedClip(this.currentTrackIndex, this.currentClipIndex); - return isAiAsset(clip?.asset); + return isAiAsset(clip?.asset) || (this.edit.hasAssetGenerator() && canCarryPrompt(clip?.asset)); } /** @@ -706,16 +696,23 @@ export class UIController { * returning to a generative clip lands back on generate. */ private effectiveMode(): ToolbarMode { - return this.toolbarMode === "generate" && !this.isGenerativeSelection() ? "asset" : this.toolbarMode; + return this.toolbarMode === "generate" && !this.isGenerateModeAvailable() ? "asset" : this.toolbarMode; } /** Queries the document because toolbar mode toggles mount outside this.container. */ private syncGenerateSegments(): void { - const generative = this.isGenerativeSelection(); + const generative = this.isGenerateModeAvailable(); + const mode = this.effectiveMode(); const clipId = this.edit.getClipId(this.currentTrackIndex, this.currentClipIndex); const generating = clipId ? this.edit.getClipGenerationState(clipId)?.status === "generating" : false; - document.querySelectorAll(".ss-toolbar-mode-toggle").forEach(toggle => toggle.toggleAttribute("data-generative", generative)); - document.querySelectorAll('.ss-toolbar-mode-btn[data-mode="generate"]').forEach(btn => btn.classList.toggle("is-generating", generating)); + document.querySelectorAll(".ss-toolbar-mode-toggle").forEach(toggle => { + toggle.setAttribute("data-mode", mode); + toggle.toggleAttribute("data-generative", generative); + toggle.querySelectorAll(".ss-toolbar-mode-btn").forEach(btn => { + btn.classList.toggle("active", btn.dataset["mode"] === mode); + if (btn.dataset["mode"] === "generate") btn.classList.toggle("is-generating", generating); + }); + }); } /** @@ -741,7 +738,7 @@ export class UIController { const isBacktick = e.key === "`" || e.code === "Backquote"; if (isBacktick && this.hasVisibleToolbar() && !this.isInputFocused()) { e.preventDefault(); - const modes: ToolbarMode[] = this.isGenerativeSelection() ? ["asset", "clip", "generate"] : ["asset", "clip"]; + const modes: ToolbarMode[] = this.isGenerateModeAvailable() ? ["asset", "clip", "generate"] : ["asset", "clip"]; const next = modes[(modes.indexOf(this.effectiveMode()) + 1) % modes.length]; this.setToolbarMode(next ?? "asset"); } diff --git a/tests/ai-asset-utils.test.ts b/tests/ai-asset-utils.test.ts index e0c96a62..bc18352d 100644 --- a/tests/ai-asset-utils.test.ts +++ b/tests/ai-asset-utils.test.ts @@ -1,4 +1,4 @@ -import { isAiAsset, isPendingAiAsset, aiAssetKind, computeAiAssetNumber, getAiAssetTypeLabel } from "@core/shared/ai-asset-utils"; +import { canCarryPrompt, isAiAsset, isPendingAiAsset, aiAssetKind, computeAiAssetNumber, getAiAssetTypeLabel } from "@core/shared/ai-asset-utils"; import type { ResolvedClip } from "@schemas"; describe("ai-asset-utils", () => { @@ -31,6 +31,26 @@ describe("ai-asset-utils", () => { }); }); + describe("canCarryPrompt", () => { + it("accepts media types that have no prompt yet, so one can be added", () => { + expect(canCarryPrompt({ type: "image", src: "https://cdn/uploaded.png" })).toBe(true); + expect(canCarryPrompt({ type: "video" })).toBe(true); + expect(canCarryPrompt({ type: "audio" })).toBe(true); + }); + + it("accepts legacy generative types", () => { + expect(canCarryPrompt({ type: "text-to-image" })).toBe(true); + expect(canCarryPrompt({ type: "text-to-speech" })).toBe(true); + }); + + it("rejects types a prompt means nothing for", () => { + expect(canCarryPrompt({ type: "rich-text", text: "Title" })).toBe(false); + expect(canCarryPrompt({ type: "luma" })).toBe(false); + expect(canCarryPrompt(null)).toBe(false); + expect(canCarryPrompt({})).toBe(false); + }); + }); + describe("isPendingAiAsset", () => { it("is pending while a prompt-bearing media asset has no src", () => { expect(isPendingAiAsset({ type: "image", prompt: "a cat" })).toBe(true); diff --git a/tests/generate-toolbar.test.ts b/tests/generate-toolbar.test.ts index 95db25f7..a4457a9d 100644 --- a/tests/generate-toolbar.test.ts +++ b/tests/generate-toolbar.test.ts @@ -248,6 +248,70 @@ describe("GenerateToolbar", () => { toolbar.dispose(); }); + it("offers the pane on a plain asset, with nothing to run yet", () => { + const { toolbar, container } = mountToolbar(createMockEdit({ type: "image", src: "https://cdn/uploaded.png" })); + + const btn = container.querySelector("[data-action='generate']"); + expect(btn?.hidden).toBe(false); + expect(btn?.disabled).toBe(true); + expect(container.querySelector("[data-prompt-input]")?.value).toBe(""); + + toolbar.dispose(); + }); + + it("enables the action as soon as a prompt is typed, and disables it again when cleared", () => { + const edit = createMockEdit({ type: "image", src: "https://cdn/uploaded.png" }); + const { toolbar, container } = mountToolbar(edit); + + const btn = container.querySelector("[data-action='generate']"); + const input = container.querySelector("[data-prompt-input]"); + expect(btn?.disabled).toBe(true); + + input!.value = "a golden sunset"; + input?.dispatchEvent(new Event("input", { bubbles: true })); + expect(btn?.disabled).toBe(false); + + btn?.click(); + expect(edit.generateClipAsset).toHaveBeenCalledWith("clip-1"); + + input!.value = ""; + input?.dispatchEvent(new Event("input", { bubbles: true })); + expect(btn?.disabled).toBe(true); + + toolbar.dispose(); + }); + + it("turns a plain asset generative when a prompt is typed", () => { + const edit = createMockEdit({ type: "image", src: "https://cdn/uploaded.png" }); + const { toolbar, container } = mountToolbar(edit); + + const input = container.querySelector("[data-prompt-input]"); + input!.value = "a golden sunset"; + input?.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + + expect(edit.updateClip).toHaveBeenCalledWith(0, 0, expect.objectContaining({ asset: expect.objectContaining({ prompt: "a golden sunset" }) })); + expect(edit.generateClipAsset).toHaveBeenCalledWith("clip-1"); + + toolbar.dispose(); + }); + + it("removes the prompt when the field is cleared, so the asset stops regenerating", () => { + const edit = createMockEdit({ type: "image", prompt: "a cat", src: "https://cdn/out.png" }); + const document = { setClipBinding: jest.fn(), removeClipBinding: jest.fn(), getClipBinding: jest.fn() }; + edit.getDocument.mockReturnValue(document); + const { toolbar, container } = mountToolbar(edit); + + const input = container.querySelector("[data-prompt-input]"); + input!.value = " "; + input?.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + + expect(edit.updateClip).toHaveBeenCalledWith(0, 0, { asset: expect.objectContaining({ prompt: undefined }) }); + expect(document.removeClipBinding).toHaveBeenCalledWith("clip-1", "asset.prompt"); + expect(edit.generateClipAsset).not.toHaveBeenCalled(); + + toolbar.dispose(); + }); + it("does not stack generation listeners when mounted twice", () => { const edit = createMockEdit(); const { toolbar, container } = mountToolbar(edit); diff --git a/tests/toolbar.test.ts b/tests/toolbar.test.ts index 343236fc..2a86a84e 100644 --- a/tests/toolbar.test.ts +++ b/tests/toolbar.test.ts @@ -69,7 +69,8 @@ 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 type { ToolbarButtonConfig } from "../src/core/ui/ui-controller"; +import { EditEvent } from "../src/core/events/edit-events"; +import { UIController, type ToolbarButtonConfig } from "../src/core/ui/ui-controller"; type MockPlayer = { clipConfiguration: Record; @@ -168,6 +169,7 @@ function createMockEdit(overrides: Record = {}) { canDeleteClip: jest.fn(() => true), getToolbarButtons: jest.fn((): ToolbarButtonConfig[] => []), getSelectedClipInfo: jest.fn((): { trackIndex: number; clipIndex: number } | null => null), + hasAssetGenerator: jest.fn(() => false), mergeFields: { getAll: jest.fn(() => []), register: jest.fn(), @@ -1925,6 +1927,27 @@ describe("Mode Toggle (Regression)", () => { * Fix: Changed query to use document.querySelectorAll instead of this.container */ describe("button discoverability for click handling", () => { + it.each([ + ["plain media without a generator", { type: "image", src: "https://example.com/image.jpg" }, false, false], + ["configured AI media without a generator", { type: "image", prompt: "a cat" }, false, true], + ["plain media with a generator", { type: "image", src: "https://example.com/image.jpg" }, true, true] + ])("marks %s as generative only when AI is available", (_name, asset, hasGenerator, expected) => { + const mockEdit = createMockEdit({ + getResolvedClip: jest.fn(() => ({ asset })), + hasAssetGenerator: jest.fn(() => hasGenerator) + }); + const ui = UIController.minimal(mockEdit as never); + const container = createTestContainer(); + container.innerHTML = '
'; + + mockEdit.events.trigger(EditEvent.ClipSelected, { trackIndex: 0, clipIndex: 0 }); + + expect(container.querySelector(".ss-toolbar-mode-toggle")?.hasAttribute("data-generative")).toBe(expected); + + ui.dispose(); + cleanupTestContainer(container); + }); + it("mode toggle buttons are discoverable via document.querySelectorAll after mount", async () => { const mockEdit = createMockEdit(); const { MediaToolbar } = await import("../src/core/ui/media-toolbar"); @@ -1957,9 +1980,7 @@ describe("Mode Toggle (Regression)", () => { cleanupTestContainer(container); }); - it("activates the generate segment when a pending AI clip is selected", async () => { - const { EditEvent } = await import("../src/core/events/edit-events"); - const { UIController } = await import("../src/core/ui/ui-controller"); + it("activates the generate segment when a pending AI clip is selected", () => { const mockEdit = createMockEdit({ getResolvedClip: jest.fn(() => ({ asset: { type: "image", prompt: "a cat" } })) }); @@ -1984,6 +2005,33 @@ describe("Mode Toggle (Regression)", () => { cleanupTestContainer(container); }); + it("shows asset mode when generate mode is unavailable for the selected clip", () => { + const getResolvedClip = jest.fn(() => ({ asset: { type: "image", prompt: "a cat" } })); + const mockEdit = createMockEdit({ getResolvedClip }); + const ui = UIController.minimal(mockEdit as never); + const container = createTestContainer(); + container.innerHTML = ` +
+ + + +
+ `; + ui.mount(container); + + mockEdit.events.trigger(EditEvent.ClipSelected, { trackIndex: 0, clipIndex: 0 }); + getResolvedClip.mockReturnValue({ asset: { type: "image", src: "https://example.com/image.jpg" } } as never); + mockEdit.events.trigger(EditEvent.ClipSelected, { trackIndex: 0, clipIndex: 1 }); + + const toggle = container.querySelector(".ss-toolbar-mode-toggle"); + expect(toggle?.getAttribute("data-mode")).toBe("asset"); + expect(toggle?.querySelector('[data-mode="asset"]')?.classList.contains("active")).toBe(true); + expect(toggle?.querySelector('[data-mode="generate"]')?.classList.contains("active")).toBe(false); + + ui.dispose(); + cleanupTestContainer(container); + }); + it("mode toggle buttons have data-mode attribute for click handling", async () => { const mockEdit = createMockEdit(); const { MediaToolbar } = await import("../src/core/ui/media-toolbar");