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
4 changes: 4 additions & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,10 @@ edit.registerAssetGenerator(async ({ clipId, asset, signal }) => {
});
```

A host can trigger the same generation directly, without the toolbar: `await
edit.generateClip(clipId)` runs it for a prompt-bearing clip. Track progress with the events
`"clip:generationStarted"`, `"clip:generationCompleted"` and `"clip:generationFailed"`.

Pass a model catalogue as the registration's `catalogue` option to show model and option
controls. Entries must include their option schema; those without one are ignored. The
[Edit API](https://shotstack.io/docs/api/#shotstack-edit) returns this shape from
Expand Down
14 changes: 7 additions & 7 deletions src/components/canvas/players/generation/state-binding.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { Edit } from "@core/edit-session";
import { InternalEvent } from "@core/events/edit-events";
import { EditEvent } from "@core/events/edit-events";

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

Expand All @@ -20,13 +20,13 @@ export function bindGenerationState(edit: Edit, clipId: string | null, overlay:
};

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

return () => {
events.off(InternalEvent.ClipGenerationStarted, onStarted);
events.off(InternalEvent.ClipGenerationCompleted, onCompleted);
events.off(InternalEvent.ClipGenerationFailed, onFailed);
events.off(EditEvent.ClipGenerationStarted, onStarted);
events.off(EditEvent.ClipGenerationCompleted, onCompleted);
events.off(EditEvent.ClipGenerationFailed, onFailed);
};
}
12 changes: 6 additions & 6 deletions src/components/timeline/timeline-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,9 @@ export class TimelineStateManager {
// Listen on clip/timeline events
this.edit.events.on(EditEvent.ClipUpdated, this.invalidateCache);
this.edit.events.on(EditEvent.TimelineUpdated, this.invalidateCache);
this.edit.getInternalEvents().on(InternalEvent.ClipGenerationStarted, this.invalidateCache);
this.edit.getInternalEvents().on(InternalEvent.ClipGenerationCompleted, this.invalidateCache);
this.edit.getInternalEvents().on(InternalEvent.ClipGenerationFailed, this.invalidateCache);
this.edit.events.on(EditEvent.ClipGenerationStarted, this.invalidateCache);
this.edit.events.on(EditEvent.ClipGenerationCompleted, this.invalidateCache);
this.edit.events.on(EditEvent.ClipGenerationFailed, this.invalidateCache);

// Selection changes are UI state (not document mutations)
this.edit.events.on(EditEvent.ClipSelected, this.invalidateCache);
Expand Down Expand Up @@ -238,9 +238,9 @@ export class TimelineStateManager {
this.edit.getInternalEvents().off(InternalEvent.Resolved, this.invalidateCache);
this.edit.events.off(EditEvent.ClipUpdated, this.invalidateCache);
this.edit.events.off(EditEvent.TimelineUpdated, this.invalidateCache);
this.edit.getInternalEvents().off(InternalEvent.ClipGenerationStarted, this.invalidateCache);
this.edit.getInternalEvents().off(InternalEvent.ClipGenerationCompleted, this.invalidateCache);
this.edit.getInternalEvents().off(InternalEvent.ClipGenerationFailed, this.invalidateCache);
this.edit.events.off(EditEvent.ClipGenerationStarted, this.invalidateCache);
this.edit.events.off(EditEvent.ClipGenerationCompleted, this.invalidateCache);
this.edit.events.off(EditEvent.ClipGenerationFailed, this.invalidateCache);
this.edit.events.off(EditEvent.ClipSelected, this.invalidateCache);
this.edit.events.off(EditEvent.SelectionCleared, this.invalidateCache);
this.edit.getInternalEvents().off(InternalEvent.ClipFocused, this.onClipFocused);
Expand Down
12 changes: 6 additions & 6 deletions src/components/timeline/timeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,9 +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.getInternalEvents().on(InternalEvent.ClipGenerationStarted, this.handleClipGeneration);
this.edit.getInternalEvents().on(InternalEvent.ClipGenerationCompleted, this.handleClipGeneration);
this.edit.getInternalEvents().on(InternalEvent.ClipGenerationFailed, this.handleClipGeneration);
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();
Expand All @@ -281,9 +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.getInternalEvents().off(InternalEvent.ClipGenerationStarted, this.handleClipGeneration);
this.edit.getInternalEvents().off(InternalEvent.ClipGenerationCompleted, this.handleClipGeneration);
this.edit.getInternalEvents().off(InternalEvent.ClipGenerationFailed, this.handleClipGeneration);
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);
Expand Down
16 changes: 8 additions & 8 deletions src/core/edit-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,9 +173,9 @@ export class Edit {
this.assetGenerator = new AssetGenerator({
getClipAsset: clipId => this.getResolvedClipById(clipId)?.asset as Record<string, unknown> | undefined,
applyGeneratedSrc: (clipId, url) => this.applyGeneratedSrc(clipId, url),
emitStarted: clipId => this.internalEvents.emit(InternalEvent.ClipGenerationStarted, { clipId }),
emitCompleted: clipId => this.internalEvents.emit(InternalEvent.ClipGenerationCompleted, { clipId }),
emitFailed: (clipId, error) => this.internalEvents.emit(InternalEvent.ClipGenerationFailed, { clipId, error })
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);
Expand Down Expand Up @@ -466,12 +466,12 @@ export class Edit {
* Generate the asset for a prompt-bearing clip and write the result to it.
*
* Rejects only when no generator is registered or the clip has nothing to generate from.
* A generation failure resolves and surfaces as `failed` clip state plus a
* `ClipGenerationFailed` event. A clip removed mid-flight resolves writing nothing, silently.
* A second call while one is in flight for the same clip is ignored.
* @internal
* A generation failure resolves and surfaces as a `clip:generationFailed` event. Removing
* the clip, reloading the edit or disposing it resolves writing nothing, and no completed
* or failed event follows the `clip:generationStarted` already emitted. A second call while
* one is in flight for the same clip is ignored.
*/
public generateClipAsset(clipId: string): Promise<void> {
public generateClip(clipId: string): Promise<void> {
return this.assetGenerator.generate(clipId);
}

Expand Down
14 changes: 7 additions & 7 deletions src/core/events/edit-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,9 @@ export const EditEvent = {
ClipCaptureStarted: "clip:captureStarted",
ClipCaptureCompleted: "clip:captureCompleted",
ClipCaptureFailed: "clip:captureFailed",
ClipGenerationStarted: "clip:generationStarted",
ClipGenerationCompleted: "clip:generationCompleted",
ClipGenerationFailed: "clip:generationFailed",
ClipUnresolved: "clip:unresolved",

// Selection
Expand Down Expand Up @@ -135,10 +138,7 @@ export const InternalEvent = {
ClipBlurred: "clip:blurred",

// Asset generation UI
AssetGeneratorChanged: "assetGenerator:changed",
ClipGenerationStarted: "clip:generationStarted",
ClipGenerationCompleted: "clip:generationCompleted",
ClipGenerationFailed: "clip:generationFailed"
AssetGeneratorChanged: "assetGenerator:changed"
} as const;

// ─────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -166,6 +166,9 @@ export type EditEventMap = {
[EditEvent.ClipCaptureStarted]: { clipId: string | null; assetType: string };
[EditEvent.ClipCaptureCompleted]: { clipId: string | null; assetType: string; frameCount: number };
[EditEvent.ClipCaptureFailed]: { clipId: string | null; assetType: string; error: string; fallback: string };
[EditEvent.ClipGenerationStarted]: { clipId: string };
[EditEvent.ClipGenerationCompleted]: { clipId: string };
[EditEvent.ClipGenerationFailed]: { clipId: string; error: string };
[EditEvent.ClipUnresolved]: ClipLocation & { assetType: string; clipId: string };

// Selection
Expand Down Expand Up @@ -234,7 +237,4 @@ export type InternalEventMap = {

// Asset generation UI
[InternalEvent.AssetGeneratorChanged]: void;
[InternalEvent.ClipGenerationStarted]: { clipId: string };
[InternalEvent.ClipGenerationCompleted]: { clipId: string };
[InternalEvent.ClipGenerationFailed]: { clipId: string; error: string };
};
4 changes: 2 additions & 2 deletions src/core/ui/generate-toolbar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ export class GenerateToolbar extends BaseToolbar {
if (this.edit.getClipGenerationState(clipId)?.status === "generating") 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.generateClipAsset(clipId).catch((error: unknown) => {
this.edit.generateClip(clipId).catch((error: unknown) => {
console.warn(`Generate: ${error instanceof Error ? error.message : String(error)}`);
});
}
Expand All @@ -182,7 +182,7 @@ 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 = [EditEvent.ClipGenerationStarted, EditEvent.ClipGenerationCompleted, EditEvent.ClipGenerationFailed] as const;
for (const name of names) {
const handler = (payload: { clipId: string }): void => {
if (payload.clipId === this.getSelectedClipId()) this.syncState();
Expand Down
2 changes: 1 addition & 1 deletion src/core/ui/ui-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -385,7 +385,7 @@ export class UIController {

// Keep the generate segment's in-flight marker current.
const internalEvents = this.edit.getInternalEvents();
for (const name of [InternalEvent.ClipGenerationStarted, InternalEvent.ClipGenerationCompleted, InternalEvent.ClipGenerationFailed] as const) {
for (const name of [EditEvent.ClipGenerationStarted, EditEvent.ClipGenerationCompleted, EditEvent.ClipGenerationFailed] as const) {
const handler = (): void => this.syncGenerateSegments();
internalEvents.on(name, handler);
this.generationListeners.push(() => internalEvents.off(name, handler));
Expand Down
19 changes: 17 additions & 2 deletions test-package.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,15 @@ const CONTRACT = {
"clearSelection(",
"registerClipRenderer("
],
Edit: ["validateEdit(", "getTimelineFonts(", "getContentClipIdForLuma(", "getInternalEvents(", "getGenerationModels(", "pruneUnusedFonts("]
Edit: [
"validateEdit(",
"getTimelineFonts(",
"getContentClipIdForLuma(",
"getInternalEvents(",
"getGenerationModels(",
"pruneUnusedFonts(",
"getClipGenerationState("
]
},
dtsForbiddenTokens: [
"export declare class SelectionHandles",
Expand Down Expand Up @@ -128,7 +136,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;",
"generateClip(clipId: string): Promise<void>;"
]
},
{ className: "Canvas", tokens: ["load(): Promise<void>;"] },
{ className: "UIController", tokens: ["registerButton(config: ToolbarButtonConfig): this;"] },
{ className: "Timeline", tokens: ["load(): Promise<void>;"] }
Expand Down
24 changes: 12 additions & 12 deletions tests/edit-clip-operations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
*/

import { Edit } from "@core/edit-session";
import { InternalEvent } from "@core/events/edit-events";
import { EditEvent, InternalEvent } from "@core/events/edit-events";
import { PlayerType } from "@canvas/players/player";
import type { EventEmitter } from "@core/events/event-emitter";
import type { Clip, ResolvedClip } from "@schemas";
Expand Down Expand Up @@ -594,7 +594,7 @@ describe("Edit Clip Operations", () => {
const clip = mergedEdit.getEdit({ includeIds: true }).timeline.tracks[0]?.clips[0] as Clip & { id: string };
mergedEdit.registerAssetGenerator(async () => ({ url: "https://cdn.example.com/speech.mp3" }));

await mergedEdit.generateClipAsset(clip.id);
await mergedEdit.generateClip(clip.id);
expect(mergedEdit.getEdit().timeline.tracks[0]?.clips[0]?.asset).toMatchObject({
type: "audio",
prompt: "Hello {{ NAME }}"
Expand Down Expand Up @@ -629,7 +629,7 @@ describe("Edit Clip Operations", () => {
const clip = mergedEdit.getEdit({ includeIds: true }).timeline.tracks[0]?.clips[0] as Clip & { id: string };
mergedEdit.registerAssetGenerator(async () => ({ url: "https://cdn.example.com/speech.mp3" }));

await mergedEdit.generateClipAsset(clip.id);
await mergedEdit.generateClip(clip.id);

expect(mergedEdit.getEdit().timeline.tracks[0]?.clips[0]?.asset).toMatchObject({
type: "audio",
Expand Down Expand Up @@ -659,7 +659,7 @@ describe("Edit Clip Operations", () => {
const clip = mergedEdit.getEdit({ includeIds: true }).timeline.tracks[0]?.clips[0] as Clip & { id: string };
mergedEdit.registerAssetGenerator(async () => ({ url: "https://cdn.example.com/out.mp4" }));

await mergedEdit.generateClipAsset(clip.id);
await mergedEdit.generateClip(clip.id);

expect(mergedEdit.getEdit().timeline.tracks[0]?.clips[0]?.asset).toMatchObject({
type: "video",
Expand All @@ -683,12 +683,12 @@ describe("Edit Clip Operations", () => {
refusedEdit.registerAssetGenerator(async () => ({ url: "https://cdn.example.com/out.png" }));

const completed: string[] = [];
refusedEdit.getInternalEvents().on(InternalEvent.ClipGenerationCompleted, ({ clipId }) => completed.push(clipId));
refusedEdit.getInternalEvents().on(EditEvent.ClipGenerationCompleted, ({ clipId }) => completed.push(clipId));
jest
.spyOn(refusedEdit as unknown as { executeCommand: () => Promise<unknown> }, "executeCommand")
.mockResolvedValue({ status: "noop", message: "Invalid clip at 0/0" });

await refusedEdit.generateClipAsset(clip.id);
await refusedEdit.generateClip(clip.id);

expect(refusedEdit.getClipGenerationState(clip.id)).toEqual({ status: "failed", error: "Invalid clip at 0/0" });
expect(completed).toEqual([]);
Expand All @@ -710,7 +710,7 @@ describe("Edit Clip Operations", () => {
const doc = (edit as unknown as { document: { getClipId(t: number, c: number): string | null } }).document;
const id = doc.getClipId(0, 1) as string;

const pending = edit.generateClipAsset(id);
const pending = edit.generateClip(id);
expect(edit.getClipGenerationState(id)?.status).toBe("generating");

await edit.deleteClip(0, 1);
Expand Down Expand Up @@ -809,7 +809,7 @@ describe("Edit Clip Operations", () => {
const seen = blockingGenerator(edit);
const id = clipIdAt(edit, 1, 0);

const pending = edit.generateClipAsset(id);
const pending = edit.generateClip(id);
expect(edit.getClipGenerationState(id)?.status).toBe("generating");

await edit.deleteTrack(1);
Expand All @@ -824,7 +824,7 @@ describe("Edit Clip Operations", () => {
const seen = blockingGenerator(edit);
const id = clipIdAt(edit, 0, 1);

const pending = edit.generateClipAsset(id);
const pending = edit.generateClip(id);
expect(edit.getClipGenerationState(id)?.status).toBe("generating");

await edit.undo();
Expand All @@ -839,7 +839,7 @@ describe("Edit Clip Operations", () => {
const seen = blockingGenerator(edit);
const id = clipIdAt(edit, 0, 1);

const pending = edit.generateClipAsset(id);
const pending = edit.generateClip(id);
expect(edit.getClipGenerationState(id)?.status).toBe("generating");

await edit.loadEdit({
Expand All @@ -863,7 +863,7 @@ describe("Edit Clip Operations", () => {
const seen = blockingGenerator(solo);
const id = clipIdAt(solo, 0, 0);

const pending = solo.generateClipAsset(id);
const pending = solo.generateClip(id);
expect((await solo.deleteClip(0, 0)).status).toBe("noop");

expect(seen.aborted).toBe(false);
Expand Down Expand Up @@ -892,7 +892,7 @@ describe("Edit Clip Operations", () => {
});

const doc = (templated as unknown as { document: { getClipId(t: number, c: number): string | null } }).document;
await templated.generateClipAsset(doc.getClipId(0, 0) as string);
await templated.generateClip(doc.getClipId(0, 0) as string);

expect(received).toBe("an illustration of a red apple");
templated.dispose();
Expand Down
Loading
Loading