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 = `
+
+
+
+
+
+ Generates on render
+
+ `;
+
+ 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();