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
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
64 changes: 63 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,36 @@ export class Edit {
this.assetGenerator.abortMissing(live);
};

// Clearing the key on deselection lets a re-selected clip announce itself again.
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 +331,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 +494,17 @@ export class Edit {
this.internalEvents.emit(InternalEvent.AssetGeneratorChanged);
}

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 +2731,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
24 changes: 23 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,24 @@ export const InternalEvent = {
AssetGeneratorChanged: "assetGenerator:changed",
ClipGenerationStarted: "clip:generationStarted",
ClipGenerationCompleted: "clip:generationCompleted",
ClipGenerationFailed: "clip:generationFailed"
ClipGenerationFailed: "clip:generationFailed",
GenerationStatusChanged: "generation:statusChanged"
} as const;

export type GenerationConfig = {
clipId: string;
type: "image" | "video" | "audio";
model?: string;
options: Record<string, unknown>;
length: number | undefined;
prompt: string;
};

export type GenerationStatus = {
text: string;
tone?: "neutral" | "warning" | "error";
};

// ─────────────────────────────────────────────────────────────
// Event Payload Maps
// ─────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -194,6 +212,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 +258,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
Loading