diff --git a/src/core/shared/ai-asset-utils.ts b/src/core/shared/ai-asset-utils.ts index 72615d7..d0a1856 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 cdb3050..26ee326 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 ad2eb41..95da902 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"; @@ -694,10 +694,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,12 +705,12 @@ 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 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)); @@ -741,7 +740,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 e0c96a6..bc18352 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 95db25f..a4457a9 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 1e28102..7901477 100644 --- a/tests/toolbar.test.ts +++ b/tests/toolbar.test.ts @@ -40,6 +40,10 @@ jest.mock("../src/components/canvas/players/player", () => { }; }); +jest.mock("../src/components/canvas/shotstack-canvas", () => ({ + Canvas: class MockCanvas {} +})); + // Mock ShotstackEdit to prevent circular dependency issues jest.mock("../src/core/shotstack-edit", () => ({ ShotstackEdit: class MockShotstackEdit {} @@ -65,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; @@ -142,6 +147,7 @@ function createMockEdit(overrides: Record = {}) { getPlayerClip: jest.fn((): MockPlayer | null => null), getClip: jest.fn(() => null), getClipId: jest.fn(() => "mock-clip-id"), + getClipGenerationState: jest.fn(() => undefined), getResolvedClip: jest.fn(() => null), getResolvedClipById: jest.fn(() => null), getDocumentClip: jest.fn(() => ({ start: 0, length: 1 })), @@ -163,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(), @@ -1920,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");