Skip to content
8 changes: 8 additions & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,7 @@ Available event names:
| Duration | `duration:changed` |
| Output | `output:resized`, `output:resolutionChanged`, `output:aspectRatioChanged`, `output:fpsChanged`, `output:formatChanged`, `output:destinationsChanged` |
| Merge fields | `mergefield:changed` |
| Generation | `generation:configChanged` |

### Generating assets from prompts

Expand All @@ -209,6 +210,13 @@ controls. Entries must include their option schema; those without one are ignore
`GET /models?expand=options`. The SDK stores a snapshot; fetching and refreshing it remain
the host's responsibility.

Whenever the selected clip's model, options, length or prompt changes, the editor emits
`generation:configChanged` with what it knows: `clipId`, `type`, `model`, `options`, the
resolved `length` in seconds (`undefined` for `auto`), and the resolved `prompt`. Reply with
`edit.setGenerationStatus(clipId, { text, tone })` to show a line beside Generate; a `tone`
of `error` also disables it. Pass `undefined` to clear. Clearing the prompt or deleting the
clip emits nothing, so also clear any held state on `clip:selected` or `edit:changed`.

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
Expand Down
69 changes: 68 additions & 1 deletion src/core/edit-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,21 @@ import { SetUpdatedClipCommand } from "@core/commands/set-updated-clip-command";
import { type TimingUpdateParams, UpdateClipTimingCommand } from "@core/commands/update-clip-timing-command";
import { UpdateTextContentCommand } from "@core/commands/update-text-content-command";
import type { MergeFieldBinding } from "@core/edit-document";
import { EditEvent, InternalEvent, type EditEventMap, type InternalEventMap } from "@core/events/edit-events";
import {
EditEvent,
InternalEvent,
type EditEventMap,
type InternalEventMap,
type GenerationConfig,
type GenerationStatus
} from "@core/events/edit-events";
import { EventEmitter, type ReadonlyEventEmitter } from "@core/events/event-emitter";
import { parseFontFamily } from "@core/fonts/font-config";
import { LumaMaskController } from "@core/luma-mask-controller";
import { MergeFieldService, type SerializedMergeField } from "@core/merge";
import { calculateSizeFromPreset, OutputSettingsManager } from "@core/output-settings-manager";
import { SelectionManager } from "@core/selection-manager";
import { GENERATION_TYPE, isAiAsset, promptProperty } from "@core/shared/ai-asset-utils";
import { findEligibleSourceClips, ensureClipAlias } from "@core/shared/source-clip-finder";
import { deepMerge, nextFrame, setNestedValue, toLoadUrl } from "@core/shared/utils";
import { calculateTimelineEnd, resolveAutoLength, resolveAutoStart } from "@core/timing/resolver";
Expand Down Expand Up @@ -124,6 +132,8 @@ export class Edit {
// ─── Internal Bookkeeping ─────────────────────────────────────────────────
private clipsToDispose = new Set<Player>();
private clipErrors = new Map<string, { error: string; assetType: string }>();
private generationStatuses = new Map<string, GenerationStatus>();
private lastGenerationConfigKey: string | null = null;
private playerByClipId = new Map<string, Player>();
private lumaContentRelations = new Map<string, string>();
private fontMetadata = new Map<string, { baseFamilyName: string; weight: number }>();
Expand All @@ -141,6 +151,40 @@ export class Edit {
this.assetGenerator.abortMissing(live);
};

/**
* Announce the selected prompt-bearing clip's configuration, deduped so only a real change
* reaches a host. Clearing the key on deselection means re-selecting the same clip announces
* it again, for a host that discarded what it knew.
*/
private emitGenerationConfig = (): void => {
const clipId = this.getSelectedClipInfo()?.player.clipId ?? null;
const resolved = clipId ? this.getResolvedClipById(clipId) : null;
const raw = clipId ? this.getDocumentClipById(clipId) : null;
if (!clipId || !resolved || !raw || !isAiAsset(resolved.asset)) {
this.lastGenerationConfigKey = null;
return;
}

const asset = resolved.asset as unknown as Record<string, unknown>;
const type = GENERATION_TYPE[String(asset["type"])];
const promptValue = asset[promptProperty(resolved.asset)];
const { options } = asset;
const config: GenerationConfig = {
clipId,
type,
...(typeof asset["model"] === "string" ? { model: asset["model"] } : {}),
options: typeof options === "object" && options !== null && !Array.isArray(options) ? (options as Record<string, unknown>) : {},
// The resolver fabricates a placeholder for "auto", so only the raw clip can say the length is unknown.
length: raw.length === "auto" ? undefined : resolved.length,
prompt: typeof promptValue === "string" ? promptValue : ""
};

const key = JSON.stringify(config);
if (key === this.lastGenerationConfigKey) return;
this.lastGenerationConfigKey = key;
this.internalEvents.emit(EditEvent.GenerationConfigChanged, config);
};

/**
* Create an Edit instance from a template configuration.
*/
Expand Down Expand Up @@ -291,7 +335,9 @@ export class Edit {
public dispose(): void {
this.clearClips();
this.internalEvents.off(InternalEvent.Resolved, this.onResolvedForGeneration);
for (const name of Edit.GenerationConfigTriggers) this.internalEvents.off(name, this.emitGenerationConfig);
this.assetGenerator.abortAll();
this.generationStatuses.clear();
this.lumaMaskController.dispose();
this.playerReconciler.dispose();

Expand Down Expand Up @@ -452,6 +498,18 @@ export class Edit {
this.internalEvents.emit(InternalEvent.AssetGeneratorChanged);
}

/** Show a host-supplied line beside the Generate action for one clip. `undefined` clears it. */
public setGenerationStatus(clipId: string, status: GenerationStatus | undefined): void {
if (status === undefined) this.generationStatuses.delete(clipId);
else this.generationStatuses.set(clipId, status);
this.internalEvents.emit(InternalEvent.GenerationStatusChanged, { clipId });
}

/** @internal */
public getGenerationStatus(clipId: string): GenerationStatus | undefined {
return this.generationStatuses.get(clipId);
}

/** @internal */
public hasAssetGenerator(): boolean {
return this.assetGenerator.hasHandler();
Expand Down Expand Up @@ -2678,8 +2736,17 @@ export class Edit {

// ─── Event Listeners ─────────────────────────────────────────────────────────

private static readonly GenerationConfigTriggers = [
EditEvent.ClipSelected,
EditEvent.SelectionCleared,
EditEvent.EditChanged,
EditEvent.MergeFieldChanged,
EditEvent.TimelineUpdated
] as const;

private setupGenerationListeners(): void {
this.internalEvents.on(InternalEvent.Resolved, this.onResolvedForGeneration);
for (const name of Edit.GenerationConfigTriggers) this.internalEvents.on(name, this.emitGenerationConfig);
}

private setupIntentListeners(): void {
Expand Down
29 changes: 28 additions & 1 deletion src/core/events/edit-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,9 @@ export const EditEvent = {
// Merge fields
MergeFieldChanged: "mergefield:changed",

// Generation
GenerationConfigChanged: "generation:configChanged",

// Timeline UI
TimelineResized: "timeline:resized"
} as const;
Expand Down Expand Up @@ -138,9 +141,29 @@ export const InternalEvent = {
AssetGeneratorChanged: "assetGenerator:changed",
ClipGenerationStarted: "clip:generationStarted",
ClipGenerationCompleted: "clip:generationCompleted",
ClipGenerationFailed: "clip:generationFailed"
ClipGenerationFailed: "clip:generationFailed",
GenerationStatusChanged: "generation:statusChanged"
} as const;

/** What the editor knows about a prompt-bearing clip's generation, for a host to act on. */
export type GenerationConfig = {
clipId: string;
type: "image" | "video" | "audio";
/** Absent when the clip relies on the backend default model. */
model?: string;
options: Record<string, unknown>;
/** Seconds. `undefined` when the length cannot be known before the asset exists. */
length: number | undefined;
prompt: string;
};

/** A host-supplied line shown beside the Generate action. */
export type GenerationStatus = {
text: string;
/** `error` also disables Generate. */
tone?: "neutral" | "warning" | "error";
};

// ─────────────────────────────────────────────────────────────
// Event Payload Maps
// ─────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -194,6 +217,9 @@ export type EditEventMap = {
// Merge fields
[EditEvent.MergeFieldChanged]: { fields: MergeField[] };

// Generation
[EditEvent.GenerationConfigChanged]: GenerationConfig;

// Timeline UI
[EditEvent.TimelineResized]: { height: number };
};
Expand Down Expand Up @@ -237,4 +263,5 @@ export type InternalEventMap = {
[InternalEvent.ClipGenerationStarted]: { clipId: string };
[InternalEvent.ClipGenerationCompleted]: { clipId: string };
[InternalEvent.ClipGenerationFailed]: { clipId: string; error: string };
[InternalEvent.GenerationStatusChanged]: { clipId: string };
};
35 changes: 29 additions & 6 deletions src/core/ui/generate-toolbar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { injectShotstackStyles } from "@styles/inject";
import { BaseToolbar, TOOLBAR_ICONS } from "./base-toolbar";

const PROMPT_DEBOUNCE_MS = 300;

const OPTION_INPUT_TYPE: Readonly<Record<GenerationOptionDefinition["type"], string>> = {
boolean: "checkbox",
integer: "number",
Expand Down Expand Up @@ -94,9 +95,7 @@ export class GenerateToolbar extends BaseToolbar {
<button class="ss-media-toolbar-btn ss-ai-generate-btn" data-action="generate">
<span data-generate-label>Generate</span>
</button>
<span class="ss-ai-note" data-generate-note hidden
title="Rendering generates this from the prompt. Register an asset generator to preview it here."
>Generates on render</span>
<span class="ss-ai-note" data-generate-note hidden>Generates on render</span>
<span class="ss-ai-error" data-generate-error hidden></span>
`;

Expand Down Expand Up @@ -182,7 +181,12 @@ export class GenerateToolbar extends BaseToolbar {
// 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;
const names = [
InternalEvent.ClipGenerationStarted,
InternalEvent.ClipGenerationCompleted,
InternalEvent.ClipGenerationFailed,
InternalEvent.GenerationStatusChanged
] as const;
for (const name of names) {
const handler = (payload: { clipId: string }): void => {
if (payload.clipId === this.getSelectedClipId()) this.syncState();
Expand Down Expand Up @@ -385,7 +389,25 @@ export class GenerateToolbar extends BaseToolbar {
const missing = this.syncCatalogueControls(record(asset));
const hasGenerator = this.edit.hasAssetGenerator();
this.generateBtn.hidden = !hasGenerator;
if (this.generateNote) this.generateNote.hidden = hasGenerator;
const status = hasGenerator ? this.edit.getGenerationStatus(this.getSelectedClipId() ?? "") : undefined;
if (this.generateNote) {
if (!hasGenerator) {
this.generateNote.textContent = "Generates on render";
this.generateNote.title = "Rendering generates this from the prompt. Register an asset generator to preview it here.";
delete this.generateNote.dataset["tone"];
this.generateNote.hidden = false;
} else if (status) {
this.generateNote.textContent = status.text;
this.generateNote.dataset["tone"] = status.tone ?? "neutral";
this.generateNote.removeAttribute("title");
this.generateNote.hidden = false;
} else {
this.generateNote.textContent = "";
delete this.generateNote.dataset["tone"];
this.generateNote.removeAttribute("title");
this.generateNote.hidden = true;
}
}
if (!hasGenerator) {
if (this.generateError) this.generateError.hidden = true;
return;
Expand All @@ -396,7 +418,8 @@ export class GenerateToolbar extends BaseToolbar {
const generating = state?.status === "generating";
const hasPrompt = (this.promptInput?.value ?? "").trim() !== "";
const label = this.generateBtn.querySelector("[data-generate-label]");
this.generateBtn.disabled = generating || !hasPrompt || missing.length > 0;
const blocked = status?.tone === "error";
this.generateBtn.disabled = generating || !hasPrompt || missing.length > 0 || blocked;
this.generateBtn.classList.toggle("is-generating", generating);
if (label) {
if (generating) label.textContent = "Generating…";
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,6 @@ export type { UIControllerOptions, ToolbarButtonConfig } from "@core/ui/ui-contr
export type { AssetGenerationRequest, AssetGeneratorHandler, AssetGeneratorOptions } from "@core/generation/asset-generator";
export type { EditConfig } from "@core/schemas";
export type { CommandResult } from "@core/commands/types";
export type { GenerationConfig, GenerationStatus } from "@core/events/edit-events";

export const VERSION = pkg.version;
8 changes: 8 additions & 0 deletions src/styles/ui/generate-toolbar.css
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,14 @@
display: none;
}

.ss-ai-note[data-tone="warning"] {
color: #fcd34d;
}

.ss-ai-note[data-tone="error"] {
color: #fca5a5;
}

.ss-ai-error {
max-width: 180px;
padding: 0 2px;
Expand Down
11 changes: 9 additions & 2 deletions test-package.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ const CONTRACT = {
"clearSelection(",
"registerClipRenderer("
],
Edit: ["validateEdit(", "getTimelineFonts(", "getContentClipIdForLuma(", "getInternalEvents(", "getGenerationModels(", "pruneUnusedFonts("]
Edit: ["validateEdit(", "getTimelineFonts(", "getContentClipIdForLuma(", "getInternalEvents(", "getGenerationModels(", "pruneUnusedFonts(", "getGenerationStatus("]
},
dtsForbiddenTokens: [
"export declare class SelectionHandles",
Expand Down Expand Up @@ -128,7 +128,14 @@ const CONTRACT = {
"export declare type Seconds ="
],
dtsPublicAnchors: [
{ className: "Edit", tokens: ["load(): Promise<void>;", "registerAssetGenerator(handler: AssetGeneratorHandler, options?: AssetGeneratorOptions): void;"] },
{
className: "Edit",
tokens: [
"load(): Promise<void>;",
"registerAssetGenerator(handler: AssetGeneratorHandler, options?: AssetGeneratorOptions): void;",
"setGenerationStatus(clipId: string, status: GenerationStatus | undefined): void;"
]
},
{ className: "Canvas", tokens: ["load(): Promise<void>;"] },
{ className: "UIController", tokens: ["registerButton(config: ToolbarButtonConfig): this;"] },
{ className: "Timeline", tokens: ["load(): Promise<void>;"] }
Expand Down
Loading