Skip to content
Merged
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
62 changes: 61 additions & 1 deletion src/core/edit-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,12 @@ import type { MergeFieldBinding } from "@core/edit-document";
import { EditEvent, InternalEvent, type EditEventMap, type InternalEventMap } from "@core/events/edit-events";
import { EventEmitter, type ReadonlyEventEmitter } from "@core/events/event-emitter";
import { parseFontFamily } from "@core/fonts/font-config";
import type { GenerationConfig, GenerationStatus, GenerationStatusProvider } from "@core/generation/generation-status";
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 @@ -123,6 +125,7 @@ export class Edit {
// ─── Internal Bookkeeping ─────────────────────────────────────────────────
private clipsToDispose = new Set<Player>();
private clipErrors = new Map<string, { error: string; assetType: string }>();
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 @@ -140,6 +143,39 @@ 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)) {
if (this.lastGenerationConfigKey !== null) {
this.lastGenerationConfigKey = null;
this.assetGenerator.describe(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.assetGenerator.describe(config);
};

/**
* Create an Edit instance from a template configuration.
*/
Expand Down Expand Up @@ -174,7 +210,8 @@ export class Edit {
applyGeneratedSrc: (clipId, url) => this.applyGeneratedSrc(clipId, url),
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 })
emitFailed: (clipId, error) => this.internalEvents.emit(EditEvent.ClipGenerationFailed, { clipId, error }),
emitStatusChanged: clipId => this.internalEvents.emit(InternalEvent.GenerationStatusChanged, { clipId })
});
this.mergeFieldService = new MergeFieldService(this.internalEvents);
this.outputSettings = new OutputSettingsManager(this);
Expand Down Expand Up @@ -291,6 +328,7 @@ export class Edit {
this.generationSettings = undefined;
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.lumaMaskController.dispose();
this.playerReconciler.dispose();
Expand Down Expand Up @@ -452,6 +490,19 @@ export class Edit {
this.internalEvents.emit(InternalEvent.AssetGeneratorChanged);
}

/** @internal */
public registerGenerationStatus(provider: GenerationStatusProvider): () => void {
const unregister = this.assetGenerator.registerStatus(provider);
this.lastGenerationConfigKey = null;
this.emitGenerationConfig();
return unregister;
}

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

/** @internal */
public registerGenerationSettings(handler: GenerationSettingsHandler): () => void {
const callback: GenerationSettingsHandler = request => handler(request);
Expand Down Expand Up @@ -2704,8 +2755,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
4 changes: 3 additions & 1 deletion src/core/events/edit-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,8 @@ export const InternalEvent = {
ClipBlurred: "clip:blurred",

// Asset generation UI
AssetGeneratorChanged: "assetGenerator:changed"
AssetGeneratorChanged: "assetGenerator:changed",
GenerationStatusChanged: "generation:statusChanged"
} as const;

// ─────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -237,4 +238,5 @@ export type InternalEventMap = {

// Asset generation UI
[InternalEvent.AssetGeneratorChanged]: void;
[InternalEvent.GenerationStatusChanged]: { clipId: string };
};
57 changes: 57 additions & 0 deletions src/core/generation/asset-generator.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { isAiAsset } from "@core/shared/ai-asset-utils";

import type { GenerationConfig, GenerationStatus, GenerationStatusProvider } from "./generation-status";
import {
type GenerationAssetType,
type GenerationModelCatalogueResponse,
Expand Down Expand Up @@ -40,6 +41,7 @@ export interface AssetGeneratorDeps {
emitStarted: (clipId: string) => void;
emitCompleted: (clipId: string) => void;
emitFailed: (clipId: string, error: string) => void;
emitStatusChanged: (clipId: string) => void;
}

/**
Expand All @@ -50,6 +52,9 @@ export interface AssetGeneratorDeps {
export class AssetGenerator {
private handler?: AssetGeneratorHandler;
private models?: readonly GenerationModelDefinition[];
private statusProvider?: GenerationStatusProvider;
private statusController: AbortController | null = null;
private status: { clipId: string; value: GenerationStatus } | null = null;
private readonly states = new Map<string, ClipGenerationState>();
private readonly controllers = new Map<string, AbortController>();

Expand All @@ -60,6 +65,57 @@ export class AssetGenerator {
this.models = options?.catalogue === undefined ? undefined : readGenerationModels(options.catalogue);
}

public registerStatus(provider: GenerationStatusProvider): () => void {
const callback: GenerationStatusProvider = request => provider(request);
this.statusProvider = callback;
this.describe(null);
return () => {
if (this.statusProvider !== callback) return;
this.statusProvider = undefined;
this.describe(null);
};
}

public getStatus(clipId: string): GenerationStatus | undefined {
return this.status?.clipId === clipId ? this.status.value : undefined;
}

public describe(config: GenerationConfig | null): void {
this.statusController?.abort();
this.statusController = null;
if (!config || !this.statusProvider) {
this.setStatus(null);
return;
}
// A pending refresh must not lift an existing block for this clip.
if (this.status?.clipId !== config.clipId || this.status.value.tone !== "error") this.setStatus(null);
const controller = new AbortController();
this.statusController = controller;
const settle = (value: GenerationStatus | undefined): void => {
if (controller.signal.aborted) return;
this.setStatus(value ? { clipId: config.clipId, value } : null);
};
const fail = (error: unknown): void => {
if (controller.signal.aborted) return;
console.warn(`Generation status: ${error instanceof Error ? error.message : String(error)}`);
settle(undefined);
};
try {
const result = this.statusProvider({ ...structuredClone(config), signal: controller.signal });
if (result instanceof Promise) result.then(settle, fail);
else settle(result);
} catch (error) {
fail(error);
}
}

private setStatus(next: { clipId: string; value: GenerationStatus } | null): void {
const previous = this.status;
this.status = next;
const changed = next ?? previous;
if (changed) this.deps.emitStatusChanged(changed.clipId);
}

public getModels(type: GenerationAssetType): readonly GenerationModelDefinition[] | undefined {
return this.models?.filter(model => model.type === type);
}
Expand Down Expand Up @@ -125,5 +181,6 @@ export class AssetGenerator {
public abortAll(): void {
for (const clipId of [...this.controllers.keys()]) this.abort(clipId);
this.states.clear();
this.describe(null);
}
}
26 changes: 26 additions & 0 deletions src/core/generation/generation-status.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import type { Edit } from "@core/edit-session";

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";
};

export type GenerationStatusRequest = GenerationConfig & {
/** Aborted when the configuration changes again or the clip is deselected. */
signal: AbortSignal;
};

export type GenerationStatusProvider = (request: GenerationStatusRequest) => GenerationStatus | undefined | Promise<GenerationStatus | undefined>;

export function registerGenerationStatus(edit: Edit, provider: GenerationStatusProvider): () => void {
return edit.registerGenerationStatus(provider);
}
36 changes: 30 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 @@ -175,6 +174,7 @@ export class GenerateToolbar extends BaseToolbar {
if (!clipId) return;
if ((this.promptInput?.value ?? "").trim() === "") return;
if (this.edit.getClipGenerationState(clipId)?.status === "generating") return;
if (this.edit.getGenerationStatus(clipId)?.tone === "error") 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.generateClip(clipId).catch((error: unknown) => {
Expand All @@ -186,7 +186,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 = [EditEvent.ClipGenerationStarted, EditEvent.ClipGenerationCompleted, EditEvent.ClipGenerationFailed] as const;
const names = [
EditEvent.ClipGenerationStarted,
EditEvent.ClipGenerationCompleted,
EditEvent.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 @@ -411,7 +416,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 @@ -422,7 +445,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
3 changes: 3 additions & 0 deletions src/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,6 @@ export type { MergeField, MergeFieldService } from "@core/merge";
export { InvalidAssetUrlError } from "@core/url-validation";

export { registerGenerationSettings } from "@core/generation/generation-settings";

export { registerGenerationStatus } from "@core/generation/generation-status";
export type { GenerationConfig, GenerationStatus, GenerationStatusRequest, GenerationStatusProvider } from "@core/generation/generation-status";
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
16 changes: 11 additions & 5 deletions test-package.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ const CONTRACT = {
}
},
runtimeExports: ["Edit", "Canvas", "Controls", "Timeline", "UIController", "VideoExporter", "VERSION"],
internalRuntimeExports: ["Edit", "ShotstackEdit", "registerGenerationSettings"],
internalRuntimeExports: ["Edit", "ShotstackEdit", "registerGenerationSettings", "registerGenerationStatus"],
dtsHiddenMembersByClass: {
UIController: [
"updateOverlays(",
Expand Down Expand Up @@ -67,10 +67,16 @@ const CONTRACT = {
"getInternalEvents(",
"getGenerationModels(",
"pruneUnusedFonts(",
"getClipGenerationState("
"getClipGenerationState(",
"getGenerationStatus("
]
},
dtsForbiddenTokens: [
"registerGenerationStatus",
"setGenerationStatus",
"type GenerationConfig =",
"type GenerationStatus =",
"generation:configChanged",
"registerGenerationSettings",
"generationSettings",
"GenerationSettingsHandler",
Expand Down Expand Up @@ -252,7 +258,7 @@ const checkInternalDeclarationSurface = () => {
const dtsPath = resolve(__dirname, "dist/internal.d.ts");
const dtsContent = readFileSync(dtsPath, "utf-8");
const errors = [];
const requiredTokens = ["export declare class Edit", "export declare class ShotstackEdit extends Edit", "export declare class MergeFieldService", "export declare function registerGenerationSettings"];
const requiredTokens = ["export declare class Edit", "export declare class ShotstackEdit extends Edit", "export declare class MergeFieldService", "export declare function registerGenerationSettings", "export declare function registerGenerationStatus"];
const isEntryStubOnly = /^\s*export\s+\*\s+from\s+['"]\.\/internal['"]\s*;\s*export\s*\{\s*\}\s*;?\s*$/.test(dtsContent);

for (const token of requiredTokens) {
Expand Down Expand Up @@ -384,8 +390,8 @@ const checkBundleSizes = () => {
const runRuntimeExportSmokeTest = async (name, modulePath, expectedExports) => {
try {
const module = await import(modulePath);
if (modulePath === "./dist/shotstack-studio.es.js" && "registerGenerationSettings" in module) {
failWithDetails(name, ["Internal settings hook leaked into public exports"]);
if (modulePath === "./dist/shotstack-studio.es.js" && ["registerGenerationSettings", "registerGenerationStatus"].some(symbol => symbol in module)) {
failWithDetails(name, ["Internal generation hook leaked into public exports"]);
}
const missing = expectedExports.filter(symbol => !module[symbol]);

Expand Down
Loading
Loading