Skip to content
Closed
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
29 changes: 29 additions & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,35 @@ 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`. Rendering generates from the prompt;
`src`, when present, is the editor preview. Register a generator and the editor offers a
generate action in the toolbar's generate pane:

```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.

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`.

A prompt is the only thing that makes a clip generate. Add one to a plain `image`, `video`
or `audio` asset to make it generative; clear it and the clip keeps its current `src` and
stops generating, which is how you hold on to a result you want.

Generation is content-addressed, so the same prompt, model and options resolve to the same
asset on every render. A generator that does not go through Shotstack's own generation
hands back a preview that the render replaces.

### Canvas

`Canvas` renders the current edit.
Expand Down
57 changes: 57 additions & 0 deletions smoketest.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/assets/images/typescript.svg" />
<link rel="stylesheet" href="/src/styles/main.css" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Shotstack Studio — generation smoketest</title>
<style>
.smoketest-panel {
position: fixed;
top: 12px;
right: 12px;
z-index: 9999;
display: flex;
flex-direction: column;
gap: 6px;
padding: 12px 14px;
border-radius: 10px;
background: rgba(24, 24, 27, 0.95);
border: 1px solid rgba(255, 255, 255, 0.1);
color: #fafafa;
font: 12px/1.5 system-ui, sans-serif;
}
.smoketest-panel label {
display: flex;
align-items: center;
gap: 6px;
}
.smoketest-panel button {
margin-top: 4px;
padding: 5px 8px;
border-radius: 6px;
border: 1px solid rgba(255, 255, 255, 0.15);
background: transparent;
color: inherit;
cursor: pointer;
}
#smoketest-log {
margin-top: 6px;
padding-top: 6px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
white-space: pre;
font-family: ui-monospace, monospace;
font-size: 11px;
color: rgba(255, 255, 255, 0.65);
min-height: 40px;
}
</style>
</head>
<body>
<div data-shotstack-studio class="c-shotstack-studio"></div>
<div data-shotstack-timeline class="c-shotstack-timeline"></div>

<script type="module" src="/src/smoketest.ts"></script>
</body>
</html>
30 changes: 30 additions & 0 deletions src/components/canvas/players/ai-generation-binding.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import type { Edit } from "@core/edit-session";
import { InternalEvent } from "@core/events/edit-events";

import type { AiPendingOverlay } from "./ai-pending-overlay";

/** 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);
};

const events = edit.getInternalEvents();
events.on(InternalEvent.ClipGenerationStarted, onStarted);
events.on(InternalEvent.ClipGenerationCompleted, onCompleted);
events.on(InternalEvent.ClipGenerationFailed, onFailed);

return () => {
events.off(InternalEvent.ClipGenerationStarted, onStarted);
events.off(InternalEvent.ClipGenerationCompleted, onCompleted);
events.off(InternalEvent.ClipGenerationFailed, onFailed);
};
}
80 changes: 78 additions & 2 deletions src/components/canvas/players/ai-pending-overlay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,10 @@ const COLUMN_COUNT = 64;

const BADGE_SIZE = 72;
const BADGE_ICON_SIZE = 42;
const PROGRESS_CYCLE_SECONDS = 1.4;
const PROGRESS_MIN_SWEEP = Math.PI / 8;
const PROGRESS_MAX_SWEEP = Math.PI * 1.5;
const STILL_GENERATING_SECONDS = 10;

/**
* Visual overlay indicating an AI asset is awaiting generation.
Expand All @@ -197,6 +201,12 @@ export class AiPendingOverlay {
private time = 0;
private rafId: number | null = null;
private lastTime: number | null = null;
private progressRing: pixi.Graphics | null = null;
private generationStatusText: pixi.Text | null = null;
private generationElapsed = 0;
private stillGenerating = false;
private generating = false;
private failure: string | null = null;

constructor(private options: AiPendingOverlayOptions) {
this.container = new pixi.Container();
Expand All @@ -221,6 +231,26 @@ export class AiPendingOverlay {
this.rebuild();
}

setGenerating(generating: boolean): void {
if (generating === this.generating) return;
this.generating = generating;
this.generationElapsed = 0;
this.stillGenerating = false;
if (generating) this.failure = null;
this.rebuild();
}

setFailed(message: string | null): void {
if (message === this.failure) return;
this.failure = message;
if (message !== null) {
this.generating = false;
this.generationElapsed = 0;
this.stillGenerating = false;
}
this.rebuild();
}

dispose(): void {
this.stopAnimation();
this.container.destroy({ children: true });
Expand All @@ -231,6 +261,8 @@ export class AiPendingOverlay {
private rebuild(): void {
this.container.removeChildren();
this.layers = [];
this.progressRing = null;
this.generationStatusText = null;
this.build();
}

Expand All @@ -239,7 +271,15 @@ export class AiPendingOverlay {
if (this.lastTime !== null) {
const deltaSec = (now - this.lastTime) / 1000;
this.time += deltaSec;
if (this.generating) {
this.generationElapsed += deltaSec;
if (!this.stillGenerating && this.generationElapsed >= STILL_GENERATING_SECONDS) {
this.stillGenerating = true;
if (this.generationStatusText) this.generationStatusText.text = "Still generating…";
}
}
this.drawAurora();
this.drawProgressRing();
}
this.lastTime = now;
this.rafId = requestAnimationFrame(tick);
Expand Down Expand Up @@ -334,6 +374,26 @@ export class AiPendingOverlay {
}
}

private drawProgressRing(): void {
if (!this.progressRing) return;

const cycle = this.time / PROGRESS_CYCLE_SECONDS;
const phase = cycle % 1;
const halfPhase = phase < 0.5 ? phase * 2 : (phase - 0.5) * 2;
const eased = (1 - Math.cos(halfPhase * Math.PI)) / 2;
const sweep =
phase < 0.5
? PROGRESS_MIN_SWEEP + (PROGRESS_MAX_SWEEP - PROGRESS_MIN_SWEEP) * eased
: PROGRESS_MAX_SWEEP - (PROGRESS_MAX_SWEEP - PROGRESS_MIN_SWEEP) * eased;
const cycleOffset = Math.floor(cycle) * (PROGRESS_MAX_SWEEP - PROGRESS_MIN_SWEEP);
const tailOffset = phase < 0.5 ? 0 : PROGRESS_MAX_SWEEP - sweep;
const start = this.time * 0.6 + cycleOffset + tailOffset;

this.progressRing.clear();
this.progressRing.arc(0, 0, BADGE_SIZE / 2 + 6, start, start + sweep);
this.progressRing.stroke({ color: "#C084FC", width: 4, cap: "round" });
}

private buildBadge(): void {
const { width, height, icon, assetNumber, prompt, assetType } = this.options;

Expand All @@ -345,6 +405,20 @@ export class AiPendingOverlay {
bg.fill({ color: "#000000", alpha: 0.5 });
badge.addChild(bg);

if (this.generating) {
const track = new pixi.Graphics();
track.circle(0, 0, BADGE_SIZE / 2 + 6);
track.stroke({ color: "#C084FC", alpha: 0.2, width: 4 });
track.position.set(BADGE_SIZE / 2, BADGE_SIZE / 2);
badge.addChild(track);

const ring = new pixi.Graphics();
ring.position.set(BADGE_SIZE / 2, BADGE_SIZE / 2);
badge.addChild(ring);
this.progressRing = ring;
this.drawProgressRing();
}

const iconGraphics = new pixi.Graphics();
iconGraphics.svg(`<svg viewBox="0 0 24 24"><path d="${AI_ICON_FILL_PATHS[icon]}" fill="#C084FC" /></svg>`);
const scale = BADGE_ICON_SIZE / 24;
Expand All @@ -369,13 +443,14 @@ export class AiPendingOverlay {
}
});
numberText.anchor.set(0.5, 0.5);
numberText.position.set(BADGE_SIZE / 2, BADGE_SIZE + 15);
numberText.position.set(BADGE_SIZE / 2, BADGE_SIZE + 24);
badge.addChild(numberText);
}

// Prompt text (if provided)
if (prompt) {
const truncated = truncatePrompt(prompt, 60);
const generationStatus = this.stillGenerating ? "Still generating…" : "Generating…";
const truncated = this.failure ?? (this.generating ? generationStatus : truncatePrompt(prompt, 60));
const promptText = new pixi.Text({
text: truncated,
style: {
Expand All @@ -392,6 +467,7 @@ export class AiPendingOverlay {
promptText.anchor.set(0.5, 0);
promptText.position.set(BADGE_SIZE / 2, BADGE_SIZE + 40);
badge.addChild(promptText);
if (this.generating) this.generationStatusText = promptText;
}

this.container.addChild(badge);
Expand Down
13 changes: 9 additions & 4 deletions src/components/canvas/players/image-to-video-player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { type Size } from "@layouts/geometry";
import { type ResolvedClip } from "@schemas";
import * as pixi from "pixi.js";

import { bindGenerationState } from "./ai-generation-binding";
import { AiPendingOverlay } from "./ai-pending-overlay";
import { createPlaceholderGraphic } from "./placeholder-graphic";
import { Player, PlayerType } from "./player";
Expand All @@ -14,6 +15,7 @@ export class ImageToVideoPlayer extends Player {
private texture: pixi.Texture<pixi.ImageSource> | null = null;
private placeholder: pixi.Graphics | null = null;
private aiOverlay: AiPendingOverlay | null = null;
private unbindGeneration: (() => void) | null = null;

constructor(edit: Edit, clipConfiguration: ResolvedClip) {
super(edit, clipConfiguration, PlayerType.ImageToVideo);
Expand All @@ -33,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) {
Expand All @@ -53,6 +55,7 @@ export class ImageToVideoPlayer extends Player {
prompt,
assetType
});
this.unbindGeneration = bindGenerationState(this.edit, this.clipId ?? null, this.aiOverlay);

this.contentContainer.addChild(this.aiOverlay.getContainer());
this.configureKeyframes();
Expand Down Expand Up @@ -107,6 +110,8 @@ export class ImageToVideoPlayer extends Player {
this.placeholder?.destroy();
this.placeholder = null;

this.unbindGeneration?.();
this.unbindGeneration = null;
this.aiOverlay?.dispose();
this.aiOverlay = null;

Expand Down
5 changes: 5 additions & 0 deletions src/components/canvas/players/text-to-image-player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@ import { computeAiAssetNumber, isAiAsset } from "@core/shared/ai-asset-utils";
import { type Size } from "@layouts/geometry";
import type { ResolvedClip } from "@schemas";

import { bindGenerationState } 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 lastPrompt = "";

constructor(edit: Edit, clipConfiguration: ResolvedClip) {
Expand Down Expand Up @@ -37,6 +39,7 @@ export class TextToImagePlayer extends Player {
prompt,
assetType
});
this.unbindGeneration = bindGenerationState(this.edit, this.clipId ?? null, this.aiOverlay);
this.contentContainer.addChild(this.aiOverlay.getContainer());

this.configureKeyframes();
Expand Down Expand Up @@ -65,6 +68,8 @@ export class TextToImagePlayer extends Player {
}

public override dispose(): void {
this.unbindGeneration?.();
this.unbindGeneration = null;
this.aiOverlay?.dispose();
this.aiOverlay = null;
super.dispose();
Expand Down
23 changes: 23 additions & 0 deletions src/components/timeline/components/clip/clip-component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -224,6 +229,24 @@ 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;
const generating = state?.status === "generating";

this.element.classList.toggle("ss-clip--generating", generating);
if (generating) this.element.setAttribute("aria-busy", "true");
else this.element.removeAttribute("aria-busy");

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