Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions src/core/shared/ai-asset-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
41 changes: 28 additions & 13 deletions src/core/ui/generate-toolbar.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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();
}
Expand All @@ -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.
Expand Down Expand Up @@ -105,15 +112,17 @@ 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) => {
console.warn(`Generate: ${error instanceof Error ? error.message : String(error)}`);
});
}

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();
Expand All @@ -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 {
Expand All @@ -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();
Expand All @@ -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…";
Expand Down
13 changes: 6 additions & 7 deletions src/core/ui/ui-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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));
}

/**
Expand All @@ -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));
Expand Down Expand Up @@ -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");
}
Expand Down
22 changes: 21 additions & 1 deletion tests/ai-asset-utils.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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);
Expand Down
64 changes: 64 additions & 0 deletions tests/generate-toolbar.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLButtonElement>("[data-action='generate']");
expect(btn?.hidden).toBe(false);
expect(btn?.disabled).toBe(true);
expect(container.querySelector<HTMLInputElement>("[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<HTMLButtonElement>("[data-action='generate']");
const input = container.querySelector<HTMLInputElement>("[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<HTMLInputElement>("[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<HTMLInputElement>("[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);
Expand Down
30 changes: 29 additions & 1 deletion tests/toolbar.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
Expand All @@ -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<string, unknown>;
Expand Down Expand Up @@ -142,6 +147,7 @@ function createMockEdit(overrides: Record<string, unknown> = {}) {
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 })),
Expand All @@ -163,6 +169,7 @@ function createMockEdit(overrides: Record<string, unknown> = {}) {
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(),
Expand Down Expand Up @@ -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 = '<div class="ss-toolbar-mode-toggle"><button class="ss-toolbar-mode-btn" data-mode="generate"></button></div>';

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");
Expand Down
Loading