diff --git a/package.json b/package.json
index f908e5a4..348ffa37 100644
--- a/package.json
+++ b/package.json
@@ -99,7 +99,7 @@
"vite-plugin-dts": "^4.5.4"
},
"dependencies": {
- "@shotstack/schemas": "1.14.1",
+ "@shotstack/schemas": "1.16.0",
"@shotstack/shotstack-canvas": "^2.10.2",
"howler": "^2.2.4",
"mediabunny": "^1.11.2",
diff --git a/readme.md b/readme.md
index 224e9f61..3f6d4cb0 100644
--- a/readme.md
+++ b/readme.md
@@ -183,6 +183,7 @@ Available event names:
| Playback | `playback:play`, `playback:pause` |
| Timeline | `timeline:updated`, `timeline:backgroundChanged`, `timeline:resized` |
| Clip lifecycle | `clip:added`, `clip:selected`, `clip:updated`, `clip:deleted`, `clip:restored`, `clip:copied`, `clip:loadFailed`, `clip:unresolved` |
+| Asset generation | `clip:generationStarted`, `clip:generationCompleted`, `clip:generationFailed` |
| Selection | `selection:cleared` |
| Edit state | `edit:changed`, `edit:undo`, `edit:redo` |
| Track | `track:added`, `track:removed` |
@@ -190,6 +191,33 @@ Available event names:
| Output | `output:resized`, `output:resolutionChanged`, `output:aspectRatioChanged`, `output:fpsChanged`, `output:formatChanged`, `output:destinationsChanged` |
| Merge fields | `mergefield:changed` |
+### Generating assets from prompts
+
+An image, video or audio asset can carry a `prompt` instead of a `src`. Those clips
+render as a placeholder until something fills the `src` in. Register a generator and
+the editor offers a generate action on the placeholder and in the toolbar:
+
+```typescript
+edit.registerAssetGenerator(async ({ clipId, asset, signal }) => {
+ const url = await myBackend.generate(asset, { signal });
+ return { url };
+});
+```
+
+The SDK writes the returned URL to the clip, so the change is undoable and autosaves
+like any other edit. It tracks whether a clip is generating or has failed, and renders
+those states; a rejection's message is shown as-is next to a retry action. Everything
+else — which models exist, what they cost, what an error means — stays with the host.
+
+```typescript
+edit.hasAssetGenerator(); // false until a handler is registered
+edit.generateClipAsset(clipId); // what the generate action calls
+edit.getClipGenerationState(clipId); // { status: "generating" | "failed", error? }
+```
+
+Generation state is transient: it is never saved to the edit, never part of undo, and
+gone on reload. Deleting a clip mid-generation aborts its request via the `signal`.
+
### Canvas
`Canvas` renders the current edit.
diff --git a/smoketest.html b/smoketest.html
new file mode 100644
index 00000000..8848e1fb
--- /dev/null
+++ b/smoketest.html
@@ -0,0 +1,57 @@
+
+
+
+
+
+
+
+ Shotstack Studio — generation smoketest
+
+
+
+
+
+
+
+
+
diff --git a/src/components/canvas/players/ai-generation-binding.ts b/src/components/canvas/players/ai-generation-binding.ts
new file mode 100644
index 00000000..186f33dc
--- /dev/null
+++ b/src/components/canvas/players/ai-generation-binding.ts
@@ -0,0 +1,49 @@
+import type { Edit } from "@core/edit-session";
+import { EditEvent } from "@core/events/edit-events";
+
+import type { AiPendingOverlay } from "./ai-pending-overlay";
+
+export function aiGenerateHandler(edit: Edit, clipId: string | null): (() => void) | undefined {
+ if (!clipId || !edit.hasAssetGenerator()) return undefined;
+ return () => {
+ edit.generateClipAsset(clipId).catch(() => {
+ // Failures surface as clip state; nothing to do here.
+ });
+ };
+}
+
+/** Hosts may register a generator after mount. Cheap to call every frame: only acts on a flip. */
+export function createGenerateActionSync(edit: Edit, overlay: AiPendingOverlay, clipId: string | null): () => void {
+ let last: boolean | null = null;
+ return () => {
+ const canGenerate = edit.hasAssetGenerator();
+ if (canGenerate === last) return;
+ last = canGenerate;
+ overlay.setOnGenerate(aiGenerateHandler(edit, clipId));
+ };
+}
+
+/** Mirrors generation state onto the overlay; returns its unsubscribe. */
+export function bindGenerationState(edit: Edit, clipId: string | null, overlay: AiPendingOverlay): () => void {
+ if (!clipId) return () => {};
+
+ const onStarted = ({ clipId: id }: { clipId: string }): void => {
+ if (id === clipId) overlay.setGenerating(true);
+ };
+ const onCompleted = ({ clipId: id }: { clipId: string }): void => {
+ if (id === clipId) overlay.setGenerating(false);
+ };
+ const onFailed = ({ clipId: id, error }: { clipId: string; error: string }): void => {
+ if (id === clipId) overlay.setFailed(error);
+ };
+
+ edit.events.on(EditEvent.ClipGenerationStarted, onStarted);
+ edit.events.on(EditEvent.ClipGenerationCompleted, onCompleted);
+ edit.events.on(EditEvent.ClipGenerationFailed, onFailed);
+
+ return () => {
+ edit.events.off(EditEvent.ClipGenerationStarted, onStarted);
+ edit.events.off(EditEvent.ClipGenerationCompleted, onCompleted);
+ edit.events.off(EditEvent.ClipGenerationFailed, onFailed);
+ };
+}
diff --git a/src/components/canvas/players/ai-pending-overlay.ts b/src/components/canvas/players/ai-pending-overlay.ts
index 3f667235..06b17e69 100644
--- a/src/components/canvas/players/ai-pending-overlay.ts
+++ b/src/components/canvas/players/ai-pending-overlay.ts
@@ -13,6 +13,8 @@ export interface AiPendingOverlayOptions {
assetNumber?: number;
prompt?: string;
assetType?: string;
+ /** Supplied only when the host can generate; omitting it hides the action. */
+ onGenerate?: () => void;
}
/**
@@ -197,6 +199,8 @@ export class AiPendingOverlay {
private time = 0;
private rafId: number | null = null;
private lastTime: number | null = null;
+ private generating = false;
+ private failure: string | null = null;
constructor(private options: AiPendingOverlayOptions) {
this.container = new pixi.Container();
@@ -221,6 +225,26 @@ export class AiPendingOverlay {
this.rebuild();
}
+ setGenerating(generating: boolean): void {
+ if (generating === this.generating) return;
+ this.generating = generating;
+ if (generating) this.failure = null;
+ this.rebuild();
+ }
+
+ setOnGenerate(onGenerate: (() => void) | undefined): void {
+ if (Boolean(onGenerate) === Boolean(this.options.onGenerate)) return;
+ this.options.onGenerate = onGenerate;
+ this.rebuild();
+ }
+
+ setFailed(message: string | null): void {
+ if (message === this.failure) return;
+ this.failure = message;
+ if (message !== null) this.generating = false;
+ this.rebuild();
+ }
+
dispose(): void {
this.stopAnimation();
this.container.destroy({ children: true });
@@ -375,7 +399,7 @@ export class AiPendingOverlay {
// Prompt text (if provided)
if (prompt) {
- const truncated = truncatePrompt(prompt, 60);
+ const truncated = this.failure ?? truncatePrompt(prompt, 60);
const promptText = new pixi.Text({
text: truncated,
style: {
@@ -394,6 +418,38 @@ export class AiPendingOverlay {
badge.addChild(promptText);
}
+ if (this.options.onGenerate && this.options.mode === "panel") {
+ badge.addChild(this.buildGenerateButton(prompt ? BADGE_SIZE + 90 : BADGE_SIZE + 45));
+ }
+
this.container.addChild(badge);
}
+
+ private buildGenerateButton(y: number): pixi.Container {
+ const label = this.generating ? "Generating…" : (this.failure && "Retry") || "Generate";
+ const button = new pixi.Container();
+ button.position.set(BADGE_SIZE / 2, y);
+
+ const text = new pixi.Text({
+ text: label,
+ style: { fontFamily: "Arial", fontSize: 15, fontWeight: "bold", fill: "#ffffff" }
+ });
+ text.anchor.set(0.5, 0.5);
+
+ const paddingX = 18;
+ const paddingY = 9;
+ const bg = new pixi.Graphics();
+ bg.roundRect(-text.width / 2 - paddingX, -text.height / 2 - paddingY, text.width + paddingX * 2, text.height + paddingY * 2, 999);
+ bg.fill({ color: this.generating ? "#3f3f46" : "#7C3AED", alpha: this.generating ? 0.7 : 0.95 });
+
+ button.addChild(bg, text);
+
+ if (!this.generating) {
+ button.eventMode = "static";
+ button.cursor = "pointer";
+ button.on("pointertap", () => this.options.onGenerate?.());
+ }
+
+ return button;
+ }
}
diff --git a/src/components/canvas/players/image-to-video-player.ts b/src/components/canvas/players/image-to-video-player.ts
index d377f13f..f4f9d2c6 100644
--- a/src/components/canvas/players/image-to-video-player.ts
+++ b/src/components/canvas/players/image-to-video-player.ts
@@ -4,6 +4,7 @@ import { type Size } from "@layouts/geometry";
import { type ResolvedClip } from "@schemas";
import * as pixi from "pixi.js";
+import { aiGenerateHandler, bindGenerationState, createGenerateActionSync } from "./ai-generation-binding";
import { AiPendingOverlay } from "./ai-pending-overlay";
import { createPlaceholderGraphic } from "./placeholder-graphic";
import { Player, PlayerType } from "./player";
@@ -13,6 +14,8 @@ export class ImageToVideoPlayer extends Player {
private texture: pixi.Texture | null = null;
private placeholder: pixi.Graphics | null = null;
private aiOverlay: AiPendingOverlay | null = null;
+ private unbindGeneration: (() => void) | null = null;
+ private syncGenerateAction: (() => void) | null = null;
constructor(edit: Edit, clipConfiguration: ResolvedClip) {
super(edit, clipConfiguration, PlayerType.ImageToVideo);
@@ -32,10 +35,10 @@ export class ImageToVideoPlayer extends Player {
const prompt = isAiAsset(asset) ? asset.prompt || "" : "";
const assetType = isAiAsset(asset) ? asset.type : "image-to-video";
- // Legacy image-to-video carries its input image in src; the unified
- // video asset carries it in seed (src holds the generated output)
- const { src, seed } = asset as { src?: string; seed?: string };
- const inputImage = seed ?? src;
+ // Legacy image-to-video carries its input image in src; the unified video
+ // asset carries it in options.inputSrc (src holds the generated output).
+ const { type, src, options } = asset as { type?: string; src?: string; options?: { inputSrc?: string } };
+ const inputImage = type === "image-to-video" ? src : options?.inputSrc;
const loaded = inputImage ? await this.tryLoadTexture(inputImage) : false;
if (!loaded) {
@@ -50,8 +53,11 @@ export class ImageToVideoPlayer extends Player {
height: displaySize.height,
assetNumber: assetNumber ?? undefined,
prompt,
- assetType
+ assetType,
+ onGenerate: aiGenerateHandler(this.edit, this.clipId ?? null)
});
+ this.syncGenerateAction = createGenerateActionSync(this.edit, this.aiOverlay, this.clipId ?? null);
+ this.unbindGeneration = bindGenerationState(this.edit, this.clipId ?? null, this.aiOverlay);
this.contentContainer.addChild(this.aiOverlay.getContainer());
this.configureKeyframes();
@@ -62,6 +68,7 @@ export class ImageToVideoPlayer extends Player {
const displaySize = this.getDisplaySize();
this.aiOverlay?.resize(displaySize.width, displaySize.height);
+ this.syncGenerateAction?.();
const overlayContainer = this.aiOverlay?.getContainer();
if (overlayContainer) {
@@ -106,6 +113,9 @@ export class ImageToVideoPlayer extends Player {
this.placeholder?.destroy();
this.placeholder = null;
+ this.unbindGeneration?.();
+ this.unbindGeneration = null;
+ this.syncGenerateAction = null;
this.aiOverlay?.dispose();
this.aiOverlay = null;
diff --git a/src/components/canvas/players/text-to-image-player.ts b/src/components/canvas/players/text-to-image-player.ts
index 410123b1..0b21d035 100644
--- a/src/components/canvas/players/text-to-image-player.ts
+++ b/src/components/canvas/players/text-to-image-player.ts
@@ -3,11 +3,14 @@ import { computeAiAssetNumber, isAiAsset } from "@core/shared/ai-asset-utils";
import { type Size } from "@layouts/geometry";
import type { ResolvedClip } from "@schemas";
+import { aiGenerateHandler, bindGenerationState, createGenerateActionSync } from "./ai-generation-binding";
import { AiPendingOverlay } from "./ai-pending-overlay";
import { Player, PlayerType } from "./player";
export class TextToImagePlayer extends Player {
private aiOverlay: AiPendingOverlay | null = null;
+ private unbindGeneration: (() => void) | null = null;
+ private syncGenerateAction: (() => void) | null = null;
private lastPrompt = "";
constructor(edit: Edit, clipConfiguration: ResolvedClip) {
@@ -35,8 +38,11 @@ export class TextToImagePlayer extends Player {
height,
assetNumber: assetNumber ?? undefined,
prompt,
- assetType
+ assetType,
+ onGenerate: aiGenerateHandler(this.edit, this.clipId ?? null)
});
+ this.syncGenerateAction = createGenerateActionSync(this.edit, this.aiOverlay, this.clipId ?? null);
+ this.unbindGeneration = bindGenerationState(this.edit, this.clipId ?? null, this.aiOverlay);
this.contentContainer.addChild(this.aiOverlay.getContainer());
this.configureKeyframes();
@@ -46,6 +52,7 @@ export class TextToImagePlayer extends Player {
super.update(deltaTime, elapsed);
const { width, height } = this.getSize();
this.aiOverlay?.resize(width, height);
+ this.syncGenerateAction?.();
// Sync prompt text only when it actually changes (e.g. via toolbar editing)
const { asset } = this.clipConfiguration;
@@ -65,6 +72,9 @@ export class TextToImagePlayer extends Player {
}
public override dispose(): void {
+ this.unbindGeneration?.();
+ this.unbindGeneration = null;
+ this.syncGenerateAction = null;
this.aiOverlay?.dispose();
this.aiOverlay = null;
super.dispose();
diff --git a/src/components/timeline/components/clip/clip-component.ts b/src/components/timeline/components/clip/clip-component.ts
index 4ae197b9..6eb136aa 100644
--- a/src/components/timeline/components/clip/clip-component.ts
+++ b/src/components/timeline/components/clip/clip-component.ts
@@ -17,6 +17,8 @@ export interface ClipComponentOptions {
getRenderer: (type: string) => ClipRenderer | undefined;
/** Get error state for a clip (if asset failed to load) */
getClipError?: (trackIndex: number, clipIndex: number) => { error: string; assetType: string } | null;
+ /** Get generation state for a clip (while an AI asset is being generated) */
+ getClipGenerationState?: (clipId: string) => { status: "generating" | "failed"; error?: string } | undefined;
/** Reference to attached luma (if this clip has a mask) */
attachedLuma?: LumaRef;
/** Callback when mask badge is clicked - passes the CONTENT clip indices */
@@ -192,6 +194,9 @@ export class ClipComponent {
// Update error state (show if asset failed to load)
this.updateErrorState();
+ // Update generation state (show while an AI asset is generating)
+ this.updateGenerationState();
+
// Apply custom renderer if available
const renderer = this.options.getRenderer(assetType);
if (renderer) {
@@ -224,6 +229,21 @@ export class ClipComponent {
}
}
+ /** Generation is transient: a failure shows the same treatment as a load error. */
+ private updateGenerationState(): void {
+ const { clipId } = this.element.dataset;
+ const state = clipId ? this.options.getClipGenerationState?.(clipId) : undefined;
+
+ this.element.classList.toggle("ss-clip--generating", state?.status === "generating");
+
+ if (state?.status === "failed") {
+ this.element.classList.add("ss-clip--error");
+ this.element.title = state.error ?? "Generation failed";
+ } else if (this.element.title && !this.currentError) {
+ this.element.title = "";
+ }
+ }
+
/** Show/hide error state based on clip error */
private updateErrorState(): void {
const error = this.currentState ? this.options.getClipError?.(this.currentState.trackIndex, this.currentState.clipIndex) : null;
diff --git a/src/components/timeline/components/track/track-component.ts b/src/components/timeline/components/track/track-component.ts
index 0794bb8a..6112ef72 100644
--- a/src/components/timeline/components/track/track-component.ts
+++ b/src/components/timeline/components/track/track-component.ts
@@ -8,6 +8,8 @@ export interface TrackComponentOptions {
getClipRenderer: (type: string) => ClipRenderer | undefined;
/** Get error state for a clip (if asset failed to load) */
getClipError?: (trackIndex: number, clipIndex: number) => { error: string; assetType: string } | null;
+ /** Get generation state for a clip (while an AI asset is being generated) */
+ getClipGenerationState?: (clipId: string) => { status: "generating" | "failed"; error?: string } | undefined;
/** Check if content clip has an attached luma (pure function) */
hasAttachedLuma?: (trackIndex: number, clipIndex: number) => boolean;
/** Find attached luma for a content clip via timing match (pure function) */
@@ -152,6 +154,7 @@ export class TrackComponent {
onSelect: this.options.onClipSelect,
getRenderer: this.options.getClipRenderer,
getClipError: this.options.getClipError,
+ getClipGenerationState: this.options.getClipGenerationState,
aiAssetNumbers: this.options.aiAssetNumbers
});
this.clipComponents.set(clipState.id, clipComponent);
@@ -180,6 +183,7 @@ export class TrackComponent {
onSelect: this.options.onClipSelect,
getRenderer: this.options.getClipRenderer,
getClipError: this.options.getClipError,
+ getClipGenerationState: this.options.getClipGenerationState,
attachedLuma: attachedLuma ?? undefined,
onMaskClick: this.options.onMaskClick,
onMenuClick: this.options.onMenuClick,
diff --git a/src/components/timeline/components/track/track-list.ts b/src/components/timeline/components/track/track-list.ts
index edee3e5f..816f3156 100644
--- a/src/components/timeline/components/track/track-list.ts
+++ b/src/components/timeline/components/track/track-list.ts
@@ -9,6 +9,8 @@ export interface TrackListOptions {
getClipRenderer: (type: string) => ClipRenderer | undefined;
/** Get error state for a clip (if asset failed to load) */
getClipError?: (trackIndex: number, clipIndex: number) => { error: string; assetType: string } | null;
+ /** Get generation state for a clip (while an AI asset is being generated) */
+ getClipGenerationState?: (clipId: string) => { status: "generating" | "failed"; error?: string } | undefined;
/** Check if content clip has an attached luma */
hasAttachedLuma?: (trackIndex: number, clipIndex: number) => boolean;
/** Find attached luma for a content clip via timing match */
@@ -85,6 +87,7 @@ export class TrackListComponent {
onClipSelect: this.options.onClipSelect,
getClipRenderer: this.options.getClipRenderer,
getClipError: this.options.getClipError,
+ getClipGenerationState: this.options.getClipGenerationState,
hasAttachedLuma: this.options.hasAttachedLuma,
findAttachedLuma: this.options.findAttachedLuma,
onMaskClick: this.options.onMaskClick,
diff --git a/src/components/timeline/timeline.ts b/src/components/timeline/timeline.ts
index b606a842..3c1741c4 100644
--- a/src/components/timeline/timeline.ts
+++ b/src/components/timeline/timeline.ts
@@ -65,6 +65,7 @@ export class Timeline {
private readonly handlePlaybackPause: () => void;
private readonly handleClipSelected: () => void;
private readonly handleClipLoadFailed: () => void;
+ private readonly handleClipGeneration: () => void;
private readonly handleClipUpdated: () => void;
private readonly handleClipFocusChanged: () => void;
private readonly handleRulerMouseMove: (e: MouseEvent) => void;
@@ -115,6 +116,7 @@ export class Timeline {
};
this.handleClipSelected = () => this.requestRender();
this.handleClipLoadFailed = () => this.requestRender();
+ this.handleClipGeneration = () => this.requestRender();
this.handleClipUpdated = () => this.requestRender();
this.handleClipFocusChanged = () => this.requestRender();
this.handleRulerMouseMove = (e: MouseEvent) => {
@@ -257,6 +259,9 @@ export class Timeline {
// Listen for clip load failures (to show error badge on timeline)
this.edit.events.on(EditEvent.ClipLoadFailed, this.handleClipLoadFailed);
+ this.edit.events.on(EditEvent.ClipGenerationStarted, this.handleClipGeneration);
+ this.edit.events.on(EditEvent.ClipGenerationCompleted, this.handleClipGeneration);
+ this.edit.events.on(EditEvent.ClipGenerationFailed, this.handleClipGeneration);
// Listen for focus changes (source popup hover-to-highlight)
const internal = this.edit.getInternalEvents();
@@ -276,6 +281,9 @@ export class Timeline {
this.edit.events.off(EditEvent.ClipSelected, this.handleClipSelected);
this.edit.events.off(EditEvent.ClipUpdated, this.handleClipUpdated);
this.edit.events.off(EditEvent.ClipLoadFailed, this.handleClipLoadFailed);
+ this.edit.events.off(EditEvent.ClipGenerationStarted, this.handleClipGeneration);
+ this.edit.events.off(EditEvent.ClipGenerationCompleted, this.handleClipGeneration);
+ this.edit.events.off(EditEvent.ClipGenerationFailed, this.handleClipGeneration);
const internal = this.edit.getInternalEvents();
internal.off(InternalEvent.ClipFocused, this.handleClipFocusChanged);
@@ -394,6 +402,7 @@ export class Timeline {
},
getClipRenderer: type => this.clipRenderers.get(type),
getClipError: (trackIndex, clipIndex) => this.edit.getClipError(trackIndex, clipIndex),
+ getClipGenerationState: clipId => this.edit.getClipGenerationState(clipId),
hasAttachedLuma: (trackIndex, clipIndex) => this.stateManager.hasAttachedLuma(trackIndex, clipIndex),
findAttachedLuma: (trackIndex, clipIndex) => this.stateManager.findAttachedLuma(trackIndex, clipIndex),
onMaskClick: (contentTrackIndex, contentClipIndex) => {
diff --git a/src/core/edit-session.ts b/src/core/edit-session.ts
index cccb4160..fe0de0b4 100644
--- a/src/core/edit-session.ts
+++ b/src/core/edit-session.ts
@@ -57,6 +57,7 @@ import * as pixi from "pixi.js";
import { CommandQueue } from "./commands/command-queue";
import { CommandNoop, type EditCommand, type CommandContext, type CommandResult } from "./commands/types";
import { EditDocument } from "./edit-document";
+import { AssetGenerator, type AssetGeneratorHandler, type ClipGenerationState } from "./generation/asset-generator";
import { PlayerReconciler } from "./player-reconciler";
import { resolve as resolveDocument, resolveClip as resolveClipById, type SingleClipContext } from "./resolver";
import { InvalidAssetUrlError, extractClipUrls, extractTrackUrls } from "./url-validation";
@@ -102,6 +103,7 @@ export class Edit {
private timingManager!: TimingManager;
private lumaMaskController: LumaMaskController;
private playerReconciler: PlayerReconciler;
+ private assetGenerator: AssetGenerator;
private outputSettings!: OutputSettingsManager;
private selectionManager!: SelectionManager;
/** @internal */
@@ -151,6 +153,17 @@ export class Edit {
this.internalEvents
);
this.playerReconciler = new PlayerReconciler(this);
+ this.assetGenerator = new AssetGenerator({
+ getClipAsset: clipId => this.getClipById(clipId)?.asset as Record | undefined,
+ applyGeneratedSrc: async (clipId, url) => {
+ const asset = this.getClipById(clipId)?.asset;
+ if (!asset) return;
+ await this.updateClipById(clipId, { asset: { ...asset, src: url } } as Partial);
+ },
+ emitStarted: clipId => this.internalEvents.emit(EditEvent.ClipGenerationStarted, { clipId }),
+ emitCompleted: clipId => this.internalEvents.emit(EditEvent.ClipGenerationCompleted, { clipId }),
+ emitFailed: (clipId, error) => this.internalEvents.emit(EditEvent.ClipGenerationFailed, { clipId, error })
+ });
this.mergeFieldService = new MergeFieldService(this.internalEvents);
this.outputSettings = new OutputSettingsManager(this);
this.selectionManager = new SelectionManager(this);
@@ -263,6 +276,7 @@ export class Edit {
/** @internal */
public dispose(): void {
this.clearClips();
+ this.assetGenerator.abortAll();
this.lumaMaskController.dispose();
this.playerReconciler.dispose();
@@ -410,6 +424,41 @@ export class Edit {
return clip ? structuredClone(clip) : null;
}
+ /**
+ * Register the handler that turns a prompt-bearing clip into a generated asset.
+ *
+ * The SDK owns the pending, generating and failed states and writes the returned
+ * URL back to the clip; the host owns how generation happens and what a failure
+ * message says. Without a handler, no generate affordance is shown.
+ */
+ public registerAssetGenerator(handler: AssetGeneratorHandler): void {
+ this.assetGenerator.register(handler);
+ }
+
+ /** Whether a generator handler has been registered. */
+ public hasAssetGenerator(): boolean {
+ return this.assetGenerator.hasHandler();
+ }
+
+ /**
+ * Generate the asset for a prompt-bearing clip and write the result to it.
+ *
+ * Resolves once the clip is updated or the attempt has failed; inspect
+ * `getClipGenerationState` or the `clip:generation*` events for the outcome.
+ * A second call while one is in flight for the same clip is ignored.
+ */
+ public generateClipAsset(clipId: string): Promise {
+ return this.assetGenerator.generate(clipId);
+ }
+
+ /**
+ * Transient generation state for a clip, or undefined when idle. Not part of the
+ * edit: never saved, never undone, cleared on reload.
+ */
+ public getClipGenerationState(clipId: string): ClipGenerationState | undefined {
+ return this.assetGenerator.getState(clipId);
+ }
+
/**
* Look up the (trackIndex, clipIndex) position of a clip by its stable ID.
*/
@@ -937,6 +986,11 @@ export class Edit {
const clipToDelete = track[clipIdx];
if (!clipToDelete) return CommandNoop(`No clip at track ${trackIdx}, index ${clipIdx}`);
+ // Every deletion funnels through here, so in-flight generation is dropped
+ // whichever way the clip goes (toolbar, keyboard, or by id).
+ const deletedClipId = this.document.getClipId(trackIdx, clipIdx);
+ if (deletedClipId) this.assetGenerator.abort(deletedClipId);
+
// Check if this is a content clip (not a luma)
const isContentClip = clipToDelete.playerType !== PlayerType.Luma;
diff --git a/src/core/events/edit-events.ts b/src/core/events/edit-events.ts
index fb6d2658..89228e2b 100644
--- a/src/core/events/edit-events.ts
+++ b/src/core/events/edit-events.ts
@@ -71,6 +71,9 @@ export const EditEvent = {
ClipCaptureCompleted: "clip:captureCompleted",
ClipCaptureFailed: "clip:captureFailed",
ClipUnresolved: "clip:unresolved",
+ ClipGenerationStarted: "clip:generationStarted",
+ ClipGenerationCompleted: "clip:generationCompleted",
+ ClipGenerationFailed: "clip:generationFailed",
// Selection
SelectionCleared: "selection:cleared",
@@ -161,6 +164,9 @@ export type EditEventMap = {
[EditEvent.ClipCaptureCompleted]: { clipId: string | null; assetType: string; frameCount: number };
[EditEvent.ClipCaptureFailed]: { clipId: string | null; assetType: string; error: string; fallback: string };
[EditEvent.ClipUnresolved]: ClipLocation & { assetType: string; clipId: string };
+ [EditEvent.ClipGenerationStarted]: { clipId: string };
+ [EditEvent.ClipGenerationCompleted]: { clipId: string };
+ [EditEvent.ClipGenerationFailed]: { clipId: string; error: string };
// Selection
[EditEvent.SelectionCleared]: void;
diff --git a/src/core/generation/asset-generator.ts b/src/core/generation/asset-generator.ts
new file mode 100644
index 00000000..d03159de
--- /dev/null
+++ b/src/core/generation/asset-generator.ts
@@ -0,0 +1,101 @@
+import { isAiAsset } from "@core/shared/ai-asset-utils";
+
+/** Passed to the host handler for one generation. */
+export interface AssetGenerationRequest {
+ clipId: string;
+ /** Snapshot of the clip's asset when generation started. */
+ asset: Record;
+ /** Aborted when the clip is deleted or the edit is disposed. */
+ signal: AbortSignal;
+}
+
+/** Resolves with the URL of the generated asset. */
+export type AssetGeneratorHandler = (request: AssetGenerationRequest) => Promise<{ url: string }>;
+
+export interface ClipGenerationState {
+ status: "generating" | "failed";
+ /** Host-supplied message, shown as-is. */
+ error?: string;
+}
+
+export interface AssetGeneratorDeps {
+ getClipAsset: (clipId: string) => Record | undefined;
+ applyGeneratedSrc: (clipId: string, url: string) => Promise;
+ emitStarted: (clipId: string) => void;
+ emitCompleted: (clipId: string) => void;
+ emitFailed: (clipId: string, error: string) => void;
+}
+
+/**
+ * Owns the generation lifecycle: one host handler, one in-flight request per
+ * clip, and the transient state the UI renders. State lives here rather than in
+ * the document so it is never autosaved, undone, or restored on reload.
+ */
+export class AssetGenerator {
+ private handler?: AssetGeneratorHandler;
+ private readonly states = new Map();
+ private readonly controllers = new Map();
+
+ constructor(private readonly deps: AssetGeneratorDeps) {}
+
+ public register(handler: AssetGeneratorHandler): void {
+ this.handler = handler;
+ }
+
+ public hasHandler(): boolean {
+ return this.handler !== undefined;
+ }
+
+ public getState(clipId: string): ClipGenerationState | undefined {
+ return this.states.get(clipId);
+ }
+
+ public async generate(clipId: string): Promise {
+ if (!this.handler) {
+ console.warn("generateClipAsset: no asset generator registered");
+ return;
+ }
+ if (this.states.get(clipId)?.status === "generating") return;
+
+ const asset = this.deps.getClipAsset(clipId);
+ if (!asset || !isAiAsset(asset)) {
+ console.warn(`generateClipAsset: clip ${clipId} has no generatable asset`);
+ return;
+ }
+
+ const controller = new AbortController();
+ this.controllers.set(clipId, controller);
+ this.states.set(clipId, { status: "generating" });
+ this.deps.emitStarted(clipId);
+
+ try {
+ const { url } = await this.handler({
+ clipId,
+ asset: structuredClone(asset) as Record,
+ signal: controller.signal
+ });
+ if (controller.signal.aborted) return;
+ this.states.delete(clipId);
+ await this.deps.applyGeneratedSrc(clipId, url);
+ this.deps.emitCompleted(clipId);
+ } catch (error) {
+ if (controller.signal.aborted) return;
+ const message = error instanceof Error ? error.message : String(error);
+ this.states.set(clipId, { status: "failed", error: message });
+ this.deps.emitFailed(clipId, message);
+ } finally {
+ this.controllers.delete(clipId);
+ }
+ }
+
+ public abort(clipId: string): void {
+ this.controllers.get(clipId)?.abort();
+ this.controllers.delete(clipId);
+ this.states.delete(clipId);
+ }
+
+ public abortAll(): void {
+ for (const clipId of [...this.controllers.keys()]) this.abort(clipId);
+ this.states.clear();
+ }
+}
diff --git a/src/core/merge/merge-field-service.ts b/src/core/merge/merge-field-service.ts
index 445681f7..1734088f 100644
--- a/src/core/merge/merge-field-service.ts
+++ b/src/core/merge/merge-field-service.ts
@@ -91,8 +91,11 @@ export class MergeFieldService {
resolveToNumber(input: string): number | null {
if (!this.isMergeFieldTemplate(input)) return null;
- const resolved = this.resolve(input);
- const num = parseFloat(resolved);
+ // Whole-string match only: parseFloat takes a numeric prefix, so a
+ // resolved "03 image of a cat" would collapse to the number 3.
+ const resolved = this.resolve(input).trim();
+ if (resolved === "") return null;
+ const num = Number(resolved);
return Number.isFinite(num) ? num : null;
}
diff --git a/src/core/resolver.ts b/src/core/resolver.ts
index 1df2e621..83f72599 100644
--- a/src/core/resolver.ts
+++ b/src/core/resolver.ts
@@ -69,7 +69,7 @@ interface ClipLocation {
* - Tries numeric conversion first (for timing, scale, offset, etc.)
* - Falls back to string resolution (for text content)
*/
-const STRING_ONLY_KEYS = new Set(["text", "src"]);
+const STRING_ONLY_KEYS = new Set(["text", "src", "prompt"]);
function resolveMergeFieldsInClip(clip: InternalClip, mergeFields: MergeFieldService): InternalClip {
function processValue(value: unknown, key?: string): unknown {
@@ -81,7 +81,7 @@ function resolveMergeFieldsInClip(clip: InternalClip, mergeFields: MergeFieldSer
return num !== null ? num : mergeFields.resolve(value);
}
if (Array.isArray(value)) {
- return value.map((item) => processValue(item, key));
+ return value.map(item => processValue(item, key));
}
if (value !== null && typeof value === "object") {
const result: Record = {};
diff --git a/src/core/ui/media-toolbar.ts b/src/core/ui/media-toolbar.ts
index 09cc1bb1..d579af9b 100644
--- a/src/core/ui/media-toolbar.ts
+++ b/src/core/ui/media-toolbar.ts
@@ -11,6 +11,7 @@ import {
} from "@core/animations/opacity-keyframes";
import type { Edit } from "@core/edit-session";
import { EditEvent } from "@core/events/edit-events";
+import { isAiAsset, isPendingAiAsset } from "@core/shared/ai-asset-utils";
import { hasKeyframedVisualProperty } from "@core/shared/clip-utils";
import { validateAssetUrl } from "@core/shared/utils";
import { ShotstackEdit } from "@core/shotstack-edit";
@@ -66,6 +67,7 @@ type MediaAssetType = "video" | "image" | "audio" | "text-to-image" | "image-to-
const VISUAL_ASSET_TYPES: ReadonlySet = new Set(["video", "image", "text-to-image", "image-to-video"]);
/** Asset types that have volume controls */
+const PROMPT_DEBOUNCE_MS = 300;
const VOLUME_ASSET_TYPES: ReadonlySet = new Set(["video", "audio", "text-to-speech"]);
/** Asset types that have audio fade controls (audio-only types) */
@@ -158,6 +160,14 @@ export class MediaToolbar extends BaseToolbar {
private volumeDisplayInput: HTMLInputElement | null = null;
private volumeSection: HTMLDivElement | null = null;
private visualSection: HTMLDivElement | null = null;
+ private aiSection: HTMLDivElement | null = null;
+ private aiDivider: HTMLDivElement | null = null;
+ private promptTextarea: HTMLTextAreaElement | null = null;
+ private promptPopup: HTMLDivElement | null = null;
+ private generateBtn: HTMLButtonElement | null = null;
+ private generateError: HTMLElement | null = null;
+ private promptDebounceTimer: ReturnType | null = null;
+ private generationUnsubscribers: (() => void)[] = [];
private audioSection: HTMLDivElement | null = null;
private speedSection: HTMLDivElement | null = null;
private speedSlider: HTMLInputElement | null = null;
@@ -209,6 +219,27 @@ export class MediaToolbar extends BaseToolbar {
+
+