diff --git a/src/core/ui/clip-toolbar.ts b/src/core/ui/clip-toolbar.ts index c17e6eab..9a7758b0 100644 --- a/src/core/ui/clip-toolbar.ts +++ b/src/core/ui/clip-toolbar.ts @@ -48,6 +48,11 @@ export class ClipToolbar extends BaseToolbar { +
diff --git a/src/core/ui/generate-toolbar.ts b/src/core/ui/generate-toolbar.ts new file mode 100644 index 00000000..cdb3050e --- /dev/null +++ b/src/core/ui/generate-toolbar.ts @@ -0,0 +1,221 @@ +import { 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 { injectShotstackStyles } from "@styles/inject"; + +import { BaseToolbar } from "./base-toolbar"; + +const PROMPT_DEBOUNCE_MS = 300; + +const promptProperty = (asset: { type: string }): "prompt" | "text" => (asset.type === "text-to-speech" ? "text" : "prompt"); + +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 promptDebounceTimer: ReturnType | null = null; + private generationUnsubscribers: (() => void)[] = []; + private abortController: AbortController | null = null; + + override mount(parent: HTMLElement): void { + injectShotstackStyles(); + + this.container = document.createElement("div"); + this.container.className = "ss-generate-toolbar"; + + this.container.innerHTML = ` +
+ + + + +
+
+ + + + + + `; + + parent.insertBefore(this.container, parent.firstChild); + + this.promptInput = this.container.querySelector("[data-prompt-input]"); + 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.setupEventListeners(); + this.subscribeToGeneration(); + this.enableDrag(); + this.appendDeleteButton(); + } + + private setupEventListeners(): void { + this.abortController?.abort(); + this.abortController = new AbortController(); + const { signal } = this.abortController; + + this.promptInput?.addEventListener("input", () => this.schedulePromptCommit(), { 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. + this.promptInput?.addEventListener( + "keydown", + e => { + if (e.key !== "Enter") return; + e.preventDefault(); + this.commitPrompt(); + this.requestGeneration(); + }, + { signal } + ); + + this.generateBtn?.addEventListener( + "click", + e => { + e.stopPropagation(); + this.commitPrompt(); + this.requestGeneration(); + }, + { signal } + ); + } + + private requestGeneration(): void { + const clipId = this.getSelectedClipId(); + if (!clipId || this.generateBtn?.disabled) 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) => { + console.warn(`Generate: ${error instanceof Error ? error.message : String(error)}`); + }); + } + + private subscribeToGeneration(): void { + // 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 = [InternalEvent.ClipGenerationStarted, InternalEvent.ClipGenerationCompleted, InternalEvent.ClipGenerationFailed] as const; + for (const name of names) { + const handler = (payload: { clipId: string }): void => { + if (payload.clipId === this.getSelectedClipId()) this.syncState(); + }; + events.on(name, handler); + this.generationUnsubscribers.push(() => events.off(name, handler)); + } + } + + private schedulePromptCommit(): void { + if (this.promptDebounceTimer) clearTimeout(this.promptDebounceTimer); + this.promptDebounceTimer = setTimeout(() => this.commitPrompt(), PROMPT_DEBOUNCE_MS); + } + + private commitPrompt(): void { + if (this.promptDebounceTimer) clearTimeout(this.promptDebounceTimer); + this.promptDebounceTimer = null; + + const clip = this.edit.getResolvedClip(this.selectedTrackIdx, this.selectedClipIdx); + if (!clip) return; + const property = promptProperty(clip.asset); + const path = `asset.${property}`; + const rawText = this.promptInput?.value ?? ""; + 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)) { + 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 } + } 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) { + const property = promptProperty(asset); + const document = this.edit.getDocument(); + const clipId = this.getSelectedClipId(); + const binding = clipId ? document?.getClipBinding(clipId, `asset.${property}`) : undefined; + const value = (asset as unknown as Record)[property]; + this.promptInput.value = binding?.placeholder ?? (typeof value === "string" ? value : ""); + } + + const hasGenerator = this.edit.hasAssetGenerator(); + this.generateBtn.hidden = !hasGenerator; + if (this.generateNote) this.generateNote.hidden = hasGenerator; + if (!hasGenerator) { + if (this.generateError) this.generateError.hidden = true; + return; + } + + const clipId = this.getSelectedClipId(); + const state = clipId ? this.edit.getClipGenerationState(clipId) : undefined; + const generating = state?.status === "generating"; + const label = this.generateBtn.querySelector("[data-generate-label]"); + this.generateBtn.disabled = generating; + this.generateBtn.classList.toggle("is-generating", generating); + if (label) { + if (generating) label.textContent = "Generating…"; + else if (state?.status === "failed") label.textContent = "Retry"; + else label.textContent = "Generate"; + } + if (this.generateError) { + const message = state?.status === "failed" ? (state.error ?? "Generation failed") : ""; + this.generateError.textContent = message; + this.generateError.hidden = message === ""; + } + } + + protected override getPopupList(): (HTMLElement | null)[] { + return []; + } + + override dispose(): void { + this.abortController?.abort(); + this.abortController = null; + + if (this.promptDebounceTimer) clearTimeout(this.promptDebounceTimer); + this.promptDebounceTimer = null; + + for (const off of this.generationUnsubscribers) off(); + this.generationUnsubscribers = []; + + super.dispose(); + + this.promptInput = null; + this.generateBtn = null; + this.generateError = null; + this.generateNote = null; + } +} diff --git a/src/core/ui/media-toolbar.ts b/src/core/ui/media-toolbar.ts index 09cc1bb1..a70c077c 100644 --- a/src/core/ui/media-toolbar.ts +++ b/src/core/ui/media-toolbar.ts @@ -205,6 +205,11 @@ export class MediaToolbar extends BaseToolbar { +
diff --git a/src/core/ui/ui-controller.ts b/src/core/ui/ui-controller.ts index 404fd33e..0aac1736 100644 --- a/src/core/ui/ui-controller.ts +++ b/src/core/ui/ui-controller.ts @@ -1,13 +1,15 @@ import { Canvas } from "@canvas/shotstack-canvas"; import type { Edit } from "@core/edit-session"; -import { EditEvent } from "@core/events/edit-events"; +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 { ShotstackEdit } from "@core/shotstack-edit"; import type * as pixi from "pixi.js"; import { AssetToolbar } from "./asset-toolbar"; import { CanvasToolbar } from "./canvas-toolbar"; import { ClipToolbar } from "./clip-toolbar"; +import { GenerateToolbar } from "./generate-toolbar"; import { MediaToolbar } from "./media-toolbar"; import { RichCaptionToolbar } from "./rich-caption-toolbar"; import { RichTextToolbar } from "./rich-text-toolbar"; @@ -23,6 +25,9 @@ const TOOLBAR_WIDTH = 48; const TOOLBAR_PADDING = 12; const TOOLBAR_MIN_Y = 80; // Minimum Y to avoid overlapping with top navigation +/** Which pane the top toolbar shows. */ +type ToolbarMode = "asset" | "clip" | "generate"; + /** * Configuration for a toolbar button. */ @@ -127,7 +132,9 @@ export class UIController { // Toolbar mode switching private clipToolbar: ClipToolbar | null = null; - private toolbarMode: "asset" | "clip" = "asset"; + private generateToolbar: GenerateToolbar | null = null; + private toolbarMode: ToolbarMode = "asset"; + private generationListeners: Array<() => void> = []; private currentAssetType: string | null = null; private currentTrackIndex = -1; private currentClipIndex = -1; @@ -265,8 +272,8 @@ export class UIController { this.assetToolbar = new AssetToolbar(this); this.registerUtility(this.assetToolbar); - // ClipToolbar - managed separately for mode toggle this.clipToolbar = new ClipToolbar(this.edit); + this.generateToolbar = new GenerateToolbar(this.edit); } // ─── Public API ───────────────────────────────────────────────────────────── @@ -337,6 +344,7 @@ export class UIController { // Mount ClipToolbar to canvas container (managed separately for mode toggle) this.clipToolbar?.mount(canvasContainer); + this.generateToolbar?.mount(canvasContainer); // Mount utilities, passing drag reset to sidebar toolbars const resetPositions = () => this.updateToolbarPositions(); @@ -362,7 +370,7 @@ export class UIController { requestAnimationFrame(() => { document.querySelectorAll(".ss-toolbar-mode-btn").forEach(btn => { const handler = (): void => { - const mode = (btn as HTMLElement).dataset["mode"] as "asset" | "clip"; + const mode = (btn as HTMLElement).dataset["mode"] as ToolbarMode; if (mode) { this.setToolbarMode(mode); } @@ -375,6 +383,14 @@ export class UIController { // Backtick key shortcut for mode toggle document.addEventListener("keydown", this.onKeyDownBound); + // Keep the generate segment's in-flight marker current. + const internalEvents = this.edit.getInternalEvents(); + for (const name of [InternalEvent.ClipGenerationStarted, InternalEvent.ClipGenerationCompleted, InternalEvent.ClipGenerationFailed] as const) { + const handler = (): void => this.syncGenerateSegments(); + internalEvents.on(name, handler); + this.generationListeners.push(() => internalEvents.off(name, handler)); + } + // Position toolbars after DOM is ready // Using nested rAF to ensure layout is complete before measuring requestAnimationFrame(() => { @@ -479,8 +495,11 @@ export class UIController { } } - // Dispose ClipToolbar (managed separately) this.clipToolbar?.dispose(); + this.generateToolbar?.dispose(); + + for (const off of this.generationListeners) off(); + this.generationListeners = []; // Dispose utilities for (const utility of this.utilities) { @@ -625,7 +644,7 @@ export class UIController { * Set the toolbar mode and update visibility accordingly. * @param mode - "asset" shows asset-specific toolbar, "clip" shows ClipToolbar */ - private setToolbarMode(mode: "asset" | "clip"): void { + private setToolbarMode(mode: ToolbarMode): void { this.toolbarMode = mode; // Update all toggle UIs @@ -636,6 +655,7 @@ export class UIController { }); }); + this.syncGenerateSegments(); this.updateToolbarVisibility(); } @@ -651,6 +671,7 @@ export class UIController { } } this.clipToolbar?.hide?.(); + this.generateToolbar?.hide?.(); } /** @@ -662,7 +683,10 @@ export class UIController { // No selection = nothing to show if (this.currentTrackIndex < 0 || this.currentClipIndex < 0) return; - if (this.toolbarMode === "clip") { + const mode = this.effectiveMode(); + if (mode === "generate") { + this.generateToolbar?.show?.(this.currentTrackIndex, this.currentClipIndex); + } else if (mode === "clip") { this.clipToolbar?.show?.(this.currentTrackIndex, this.currentClipIndex); } else if (this.currentAssetType) { const toolbar = this.toolbars.get(this.currentAssetType); @@ -670,6 +694,30 @@ export class UIController { } } + /** Whether the current selection can be generated from a prompt. */ + private isGenerativeSelection(): boolean { + const clip = this.edit.getResolvedClip(this.currentTrackIndex, this.currentClipIndex); + return isAiAsset(clip?.asset); + } + + /** + * The mode actually shown. `generate` is intent rather than state: selecting a clip + * that cannot be generated falls back to `asset` without discarding the choice, so + * returning to a generative clip lands back on generate. + */ + private effectiveMode(): ToolbarMode { + return this.toolbarMode === "generate" && !this.isGenerativeSelection() ? "asset" : this.toolbarMode; + } + + /** Queries the document because toolbar mode toggles mount outside this.container. */ + private syncGenerateSegments(): void { + const generative = this.isGenerativeSelection(); + 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)); + } + /** * Check if any toolbar is currently visible (clip is selected). */ @@ -693,7 +741,9 @@ export class UIController { const isBacktick = e.key === "`" || e.code === "Backquote"; if (isBacktick && this.hasVisibleToolbar() && !this.isInputFocused()) { e.preventDefault(); - this.setToolbarMode(this.toolbarMode === "asset" ? "clip" : "asset"); + const modes: ToolbarMode[] = this.isGenerativeSelection() ? ["asset", "clip", "generate"] : ["asset", "clip"]; + const next = modes[(modes.indexOf(this.effectiveMode()) + 1) % modes.length]; + this.setToolbarMode(next ?? "asset"); } } @@ -708,7 +758,15 @@ export class UIController { this.currentTrackIndex = trackIndex; this.currentClipIndex = clipIndex; + // A prompt-bearing clip with no output yet opens on generate: until it has been + // generated there is nothing for the other modes to act on. + if (isPendingAiAsset(clip?.asset)) { + this.setToolbarMode("generate"); + return; + } + // Update visibility based on mode + this.syncGenerateSegments(); this.updateToolbarVisibility(); }; diff --git a/src/styles/index.css b/src/styles/index.css index 5c0468aa..8bb59087 100644 --- a/src/styles/index.css +++ b/src/styles/index.css @@ -12,6 +12,7 @@ @import "./ui/rich-text-toolbar.css"; @import "./ui/svg-toolbar.css"; @import "./ui/media-toolbar.css"; +@import "./ui/generate-toolbar.css"; @import "./ui/text-to-image-toolbar.css"; @import "./ui/text-to-speech-toolbar.css"; @import "./ui/clip-toolbar.css"; diff --git a/src/styles/ui/clip-toolbar.css b/src/styles/ui/clip-toolbar.css index b3ff2983..21df9ff5 100644 --- a/src/styles/ui/clip-toolbar.css +++ b/src/styles/ui/clip-toolbar.css @@ -348,6 +348,29 @@ transform: translateX(calc(100% + 2px)); } +.ss-toolbar-mode-toggle[data-mode="generate"] .ss-toolbar-mode-indicator { + transform: translateX(calc(200% + 4px)); +} + +.ss-toolbar-mode-toggle:not([data-generative]) .ss-toolbar-mode-btn[data-mode="generate"] { + display: none; +} + +/* Progress stays legible from the other modes, so switching is never required to see it. */ +@keyframes ss-generate-pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.45; + } +} + +.ss-toolbar-mode-btn[data-mode="generate"].is-generating { + animation: ss-generate-pulse 1.4s ease-in-out infinite; +} + /* Divider between toggle and content */ .ss-toolbar-mode-divider { width: 1px; @@ -361,4 +384,3 @@ .ss-toolbar-mode-container { display: none !important; } - diff --git a/src/styles/ui/generate-toolbar.css b/src/styles/ui/generate-toolbar.css new file mode 100644 index 00000000..83af2e61 --- /dev/null +++ b/src/styles/ui/generate-toolbar.css @@ -0,0 +1,103 @@ +.ss-generate-toolbar { + display: none; + position: absolute; + top: 12px; + left: 50%; + transform: translateX(-50%); + width: fit-content; + background: rgba(24, 24, 27, 0.95); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 10px; + padding: 6px 8px; + gap: 6px; + z-index: 10; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; + align-items: center; + box-shadow: + 0 4px 24px rgba(0, 0, 0, 0.4), + 0 0 0 1px rgba(0, 0, 0, 0.2), + inset 0 1px 0 rgba(255, 255, 255, 0.05); +} + +.ss-generate-toolbar.visible { + display: flex; +} + +/* The field receives content, so it sits inset — darker than the surface around it. */ +.ss-ai-prompt-input { + width: 280px; + height: 28px; + box-sizing: border-box; + padding: 0 10px; + 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-prompt-input::placeholder { + color: rgba(255, 255, 255, 0.45); +} + +.ss-ai-prompt-input:hover { + border-color: rgba(255, 255, 255, 0.18); +} + +.ss-ai-prompt-input:focus { + outline: none; + border-color: rgba(255, 255, 255, 0.28); + background: rgba(0, 0, 0, 0.45); +} + +/* The only action on this surface, so it carries the weight the toolbar reserves for one. */ +.ss-ai-generate-btn { + padding: 0 16px; + font-weight: 600; + letter-spacing: -0.01em; + color: rgba(255, 255, 255, 0.95); + background: rgba(255, 255, 255, 0.08); +} + +.ss-ai-generate-btn:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.16); + color: #fff; +} + +.ss-ai-generate-btn[hidden] { + display: none; +} + +.ss-ai-generate-btn.is-generating { + opacity: 0.65; + cursor: default; +} + +/* Not an error: the prompt is functional, it simply has no local preview. */ +.ss-ai-note { + padding: 0 6px; + font-size: 11px; + color: rgba(255, 255, 255, 0.45); + white-space: nowrap; +} + +.ss-ai-note[hidden] { + display: none; +} + +.ss-ai-error { + max-width: 180px; + padding: 0 2px; + font-size: 11px; + color: #fca5a5; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ss-ai-error[hidden] { + display: none; +} diff --git a/tests/generate-toolbar.test.ts b/tests/generate-toolbar.test.ts new file mode 100644 index 00000000..95db25f7 --- /dev/null +++ b/tests/generate-toolbar.test.ts @@ -0,0 +1,263 @@ +/** + * @jest-environment jsdom + */ +/* eslint-disable import/first */ +/* eslint-disable max-classes-per-file -- the two stub classes are jest.mock factories, not real types */ + +import type { Edit } from "@core/edit-session"; + +if (typeof structuredClone === "undefined") { + global.structuredClone = (obj: unknown) => JSON.parse(JSON.stringify(obj)); +} + +jest.mock("pixi.js", () => ({})); +jest.mock("../src/components/canvas/players/player", () => ({ + Player: class MockPlayer {}, + PlayerType: {} +})); +jest.mock("../src/core/edit-session", () => ({})); +jest.mock("@styles/inject", () => ({ + injectShotstackStyles: jest.fn() +})); + +import { InternalEvent } from "@core/events/edit-events"; +import { GenerateToolbar } from "@core/ui/generate-toolbar"; + +type MockEdit = ReturnType; + +function createMockEdit(asset: Record = { type: "image", prompt: "a cat" }) { + const internalEvents = { on: jest.fn(), off: jest.fn() }; + return { + getClipId: jest.fn().mockReturnValue("clip-1"), + getResolvedClip: jest.fn().mockReturnValue({ asset }), + getDocument: jest.fn(), + hasAssetGenerator: jest.fn().mockReturnValue(true), + getClipGenerationState: jest.fn(), + generateClipAsset: jest.fn().mockResolvedValue(undefined), + resolveMergeFields: jest.fn((value: string) => value), + updateClip: jest.fn(), + deleteClip: jest.fn(), + canDeleteClip: jest.fn(() => true), + getInternalEvents: jest.fn(() => internalEvents), + events: { on: jest.fn(), off: jest.fn() } + }; +} + +function mountToolbar(edit: MockEdit): { toolbar: GenerateToolbar; container: HTMLDivElement } { + const container = document.createElement("div"); + document.body.appendChild(container); + const toolbar = new GenerateToolbar(edit as unknown as Edit); + toolbar.mount(container); + toolbar.show(0, 0); + return { toolbar, container }; +} + +describe("GenerateToolbar", () => { + afterEach(() => { + document.body.innerHTML = ""; + }); + + it("offers all three modes so the other panes stay reachable", () => { + const { toolbar, container } = mountToolbar(createMockEdit()); + + const modes = [...container.querySelectorAll(".ss-toolbar-mode-btn")].map(b => (b as HTMLElement).dataset["mode"]); + expect(modes).toEqual(["asset", "clip", "generate"]); + expect(container.querySelector(".ss-toolbar-mode-btn.active")?.getAttribute("data-mode")).toBe("generate"); + + toolbar.dispose(); + }); + + it("marks its own toggle generative, since it only ever shows for such a clip", () => { + const { toolbar, container } = mountToolbar(createMockEdit()); + + expect(container.querySelector(".ss-toolbar-mode-toggle")?.hasAttribute("data-generative")).toBe(true); + + toolbar.dispose(); + }); + + it("calls the action Generate whether or not the clip already has output", () => { + const assets: Array> = [ + { type: "image", prompt: "a cat" }, + { type: "image", prompt: "a cat", src: "https://cdn/out.png" } + ]; + + assets.forEach(asset => { + const { toolbar, container } = mountToolbar(createMockEdit(asset)); + expect(container.querySelector("[data-generate-label]")?.textContent).toBe("Generate"); + toolbar.dispose(); + document.body.innerHTML = ""; + }); + }); + + it("disables the action while a generation is in flight", () => { + const edit = createMockEdit(); + edit.getClipGenerationState.mockReturnValue({ status: "generating" }); + const { toolbar, container } = mountToolbar(edit); + + const btn = container.querySelector("[data-action='generate']"); + expect(btn?.disabled).toBe(true); + expect(container.querySelector("[data-generate-label]")?.textContent).toBe("Generating…"); + + toolbar.dispose(); + }); + + it("offers a retry and the reason when generation failed", () => { + const edit = createMockEdit(); + edit.getClipGenerationState.mockReturnValue({ status: "failed", error: "model unavailable" }); + const { toolbar, container } = mountToolbar(edit); + + expect(container.querySelector("[data-generate-label]")?.textContent).toBe("Retry"); + const error = container.querySelector("[data-generate-error]"); + expect(error?.hidden).toBe(false); + expect(error?.textContent).toBe("model unavailable"); + + toolbar.dispose(); + }); + + it("says the prompt still generates on render when no generator is registered", () => { + const edit = createMockEdit(); + edit.hasAssetGenerator.mockReturnValue(false); + const { toolbar, container } = mountToolbar(edit); + + expect(container.querySelector("[data-action='generate']")?.hidden).toBe(true); + const note = container.querySelector("[data-generate-note]"); + expect(note?.hidden).toBe(false); + expect(note?.textContent?.trim()).toBe("Generates on render"); + expect(container.querySelector("[data-prompt-input]")?.disabled).toBe(false); + + toolbar.dispose(); + }); + + it("drops the note once a generator can preview it here", () => { + const { toolbar, container } = mountToolbar(createMockEdit()); + + expect(container.querySelector("[data-generate-note]")?.hidden).toBe(true); + expect(container.querySelector("[data-action='generate']")?.hidden).toBe(false); + + toolbar.dispose(); + }); + + it("generates the selected clip when the action is pressed", () => { + const edit = createMockEdit(); + const { toolbar, container } = mountToolbar(edit); + + container.querySelector("[data-action='generate']")?.click(); + + expect(edit.generateClipAsset).toHaveBeenCalledWith("clip-1"); + + toolbar.dispose(); + }); + + it("puts the whole prompt in an editable field, not a truncated label", () => { + const prompt = "a very long prompt that would not have fitted in a button label"; + const { toolbar, container } = mountToolbar(createMockEdit({ type: "image", prompt })); + + const input = container.querySelector("[data-prompt-input]"); + expect(input?.value).toBe(prompt); + + toolbar.dispose(); + }); + + it("asks for a prompt when the clip has none", () => { + const { toolbar, container } = mountToolbar(createMockEdit({ type: "text-to-image" })); + + const input = container.querySelector("[data-prompt-input]"); + expect(input?.value).toBe(""); + expect(input?.placeholder).toBe("Describe what to generate…"); + + toolbar.dispose(); + }); + + it("commits an edited prompt before generating, so Enter never runs the stale one", () => { + const edit = createMockEdit(); + const { toolbar, container } = mountToolbar(edit); + + const input = container.querySelector("[data-prompt-input]"); + input!.value = "a dog instead"; + input?.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + + expect(edit.updateClip).toHaveBeenCalledWith(0, 0, expect.objectContaining({ asset: expect.objectContaining({ prompt: "a dog instead" }) })); + expect(edit.generateClipAsset).toHaveBeenCalledWith("clip-1"); + + toolbar.dispose(); + }); + + it("edits the legacy text-to-speech text property", () => { + const document = { + setClipBinding: jest.fn(), + removeClipBinding: jest.fn(), + getClipBinding: jest.fn() + }; + const edit = createMockEdit({ type: "text-to-speech", text: "Welcome" }); + edit.getDocument.mockReturnValue(document); + const { toolbar, container } = mountToolbar(edit); + + const input = container.querySelector("[data-prompt-input]"); + expect(input?.value).toBe("Welcome"); + input!.value = "Updated welcome"; + input?.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + + expect(document.removeClipBinding).toHaveBeenCalledWith("clip-1", "asset.text"); + expect(edit.updateClip).toHaveBeenCalledWith(0, 0, expect.objectContaining({ asset: expect.objectContaining({ text: "Updated welcome" }) })); + + toolbar.dispose(); + }); + + it("keeps a merge-field prompt editable when using the base Edit class", () => { + const rawPrompt = "a calm image of {{ SUBJECT }}"; + const resolvedPrompt = "a calm image of a red apple"; + let binding: { placeholder: string; resolvedValue: string } | undefined; + const document = { + setClipBinding: jest.fn((_clipId: string, _path: string, value: { placeholder: string; resolvedValue: string }) => { + binding = value; + }), + removeClipBinding: jest.fn(), + getClipBinding: jest.fn(() => binding) + }; + const edit = createMockEdit(); + edit.getDocument.mockReturnValue(document); + edit.resolveMergeFields.mockReturnValue(resolvedPrompt); + const { toolbar, container } = mountToolbar(edit); + + const input = container.querySelector("[data-prompt-input]"); + input!.value = rawPrompt; + input?.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + + expect(document.setClipBinding).toHaveBeenCalledWith("clip-1", "asset.prompt", { + placeholder: rawPrompt, + resolvedValue: resolvedPrompt + }); + expect(edit.updateClip).toHaveBeenCalledWith(0, 0, expect.objectContaining({ asset: expect.objectContaining({ prompt: resolvedPrompt }) })); + + input?.blur(); + toolbar.show(0, 0); + expect(input?.value).toBe(rawPrompt); + + toolbar.dispose(); + }); + + it("ignores a second request while one is already running", () => { + const edit = createMockEdit(); + edit.getClipGenerationState.mockReturnValue({ status: "generating" }); + const { toolbar, container } = mountToolbar(edit); + + container.querySelector("[data-action='generate']")?.click(); + + expect(edit.generateClipAsset).not.toHaveBeenCalled(); + + toolbar.dispose(); + }); + + it("does not stack generation listeners when mounted twice", () => { + const edit = createMockEdit(); + const { toolbar, container } = mountToolbar(edit); + + toolbar.mount(container); + + [InternalEvent.ClipGenerationStarted, InternalEvent.ClipGenerationCompleted, InternalEvent.ClipGenerationFailed].forEach(event => { + expect(edit.getInternalEvents().on.mock.calls.filter(([name]) => name === event)).toHaveLength(1); + }); + + toolbar.dispose(); + }); +}); diff --git a/tests/toolbar.test.ts b/tests/toolbar.test.ts index 3fe96f68..343236fc 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 {} @@ -142,6 +146,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 })), @@ -1936,6 +1941,49 @@ describe("Mode Toggle (Regression)", () => { cleanupTestContainer(container); }); + it("carries a generate segment, hidden until the controller marks the clip generative", async () => { + const mockEdit = createMockEdit(); + const { MediaToolbar } = await import("../src/core/ui/media-toolbar"); + const toolbar = new MediaToolbar(mockEdit as never); + const container = createTestContainer(); + + toolbar.mount(container); + + const toggle = container.querySelector(".ss-toolbar-mode-toggle"); + expect(toggle?.querySelector('.ss-toolbar-mode-btn[data-mode="generate"]')).not.toBeNull(); + expect(toggle?.hasAttribute("data-generative")).toBe(false); + + toolbar.dispose(); + 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"); + const mockEdit = createMockEdit({ + getResolvedClip: jest.fn(() => ({ asset: { type: "image", prompt: "a cat" } })) + }); + const ui = UIController.minimal(mockEdit as never); + const container = createTestContainer(); + container.innerHTML = ` +
+ + +
+ `; + ui.mount(container); + + mockEdit.events.trigger(EditEvent.ClipSelected, { trackIndex: 0, clipIndex: 0 }); + + const toggle = container.querySelector(".ss-toolbar-mode-toggle"); + expect(toggle?.getAttribute("data-mode")).toBe("generate"); + expect(toggle?.querySelector('[data-mode="clip"]')?.classList.contains("active")).toBe(false); + expect(toggle?.querySelector('[data-mode="generate"]')?.classList.contains("active")).toBe(true); + + 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"); @@ -1947,7 +1995,7 @@ describe("Mode Toggle (Regression)", () => { const buttons = container.querySelectorAll(".ss-toolbar-mode-btn"); buttons.forEach(btn => { const { mode } = (btn as HTMLElement).dataset; - expect(mode === "asset" || mode === "clip").toBe(true); + expect(mode === "asset" || mode === "clip" || mode === "generate").toBe(true); }); toolbar.dispose();