diff --git a/src/components/canvas/players/generation/pending-overlay.ts b/src/components/canvas/players/generation/pending-overlay.ts
index c9086bbe..93f289b4 100644
--- a/src/components/canvas/players/generation/pending-overlay.ts
+++ b/src/components/canvas/players/generation/pending-overlay.ts
@@ -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.
@@ -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();
@@ -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 });
@@ -231,6 +261,8 @@ export class AiPendingOverlay {
private rebuild(): void {
this.container.removeChildren();
this.layers = [];
+ this.progressRing = null;
+ this.generationStatusText = null;
this.build();
}
@@ -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);
@@ -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;
@@ -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(``);
const scale = BADGE_ICON_SIZE / 24;
@@ -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: {
@@ -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);
diff --git a/src/components/canvas/players/generation/state-binding.ts b/src/components/canvas/players/generation/state-binding.ts
new file mode 100644
index 00000000..9b41e966
--- /dev/null
+++ b/src/components/canvas/players/generation/state-binding.ts
@@ -0,0 +1,32 @@
+import type { Edit } from "@core/edit-session";
+import { InternalEvent } from "@core/events/edit-events";
+
+import type { AiPendingOverlay } from "./pending-overlay";
+
+export function bindGenerationState(edit: Edit, clipId: string | null, overlay: AiPendingOverlay): () => void {
+ if (!clipId) return () => {};
+ const state = edit.getClipGenerationState(clipId);
+ if (state?.status === "generating") overlay.setGenerating(true);
+ if (state?.status === "failed") overlay.setFailed(state.error ?? "Generation failed");
+
+ 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);
+ };
+}
diff --git a/src/components/canvas/players/image-to-video-player.ts b/src/components/canvas/players/image-to-video-player.ts
index 9d6d3600..e79c98a6 100644
--- a/src/components/canvas/players/image-to-video-player.ts
+++ b/src/components/canvas/players/image-to-video-player.ts
@@ -6,6 +6,7 @@ import { type ResolvedClip } from "@schemas";
import * as pixi from "pixi.js";
import { AiPendingOverlay } from "./generation/pending-overlay";
+import { bindGenerationState } from "./generation/state-binding";
import { createPlaceholderGraphic } from "./placeholder-graphic";
import { Player, PlayerType } from "./player";
@@ -14,6 +15,7 @@ export class ImageToVideoPlayer extends Player {
private texture: pixi.Texture | 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);
@@ -33,11 +35,14 @@ 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;
+ // Disposal during the texture fetch leaves nothing to attach to: an overlay built now
+ // would keep its own animation loop and event subscriptions alive for the session.
+ if (this.contentContainer.destroyed) return;
if (!loaded) {
this.placeholder = createPlaceholderGraphic(displaySize.width, displaySize.height);
@@ -53,6 +58,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();
@@ -107,6 +113,8 @@ export class ImageToVideoPlayer extends Player {
this.placeholder?.destroy();
this.placeholder = null;
+ this.unbindGeneration?.();
+ this.unbindGeneration = null;
this.aiOverlay?.dispose();
this.aiOverlay = null;
diff --git a/src/components/canvas/players/text-to-image-player.ts b/src/components/canvas/players/text-to-image-player.ts
index 2516ebca..7f8c4a25 100644
--- a/src/components/canvas/players/text-to-image-player.ts
+++ b/src/components/canvas/players/text-to-image-player.ts
@@ -4,10 +4,12 @@ import { type Size } from "@layouts/geometry";
import type { ResolvedClip } from "@schemas";
import { AiPendingOverlay } from "./generation/pending-overlay";
+import { bindGenerationState } from "./generation/state-binding";
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) {
@@ -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();
@@ -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();
diff --git a/src/components/timeline/components/clip/clip-component.ts b/src/components/timeline/components/clip/clip-component.ts
index 31e5d2ea..2ec9d9d2 100644
--- a/src/components/timeline/components/clip/clip-component.ts
+++ b/src/components/timeline/components/clip/clip-component.ts
@@ -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 */
@@ -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) {
@@ -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;
diff --git a/src/components/timeline/components/track/track-component.ts b/src/components/timeline/components/track/track-component.ts
index 0794bb8a..6112ef72 100644
--- a/src/components/timeline/components/track/track-component.ts
+++ b/src/components/timeline/components/track/track-component.ts
@@ -8,6 +8,8 @@ export interface TrackComponentOptions {
getClipRenderer: (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;
/** Check if content clip has an attached luma (pure function) */
hasAttachedLuma?: (trackIndex: number, clipIndex: number) => boolean;
/** Find attached luma for a content clip via timing match (pure function) */
@@ -152,6 +154,7 @@ export class TrackComponent {
onSelect: this.options.onClipSelect,
getRenderer: this.options.getClipRenderer,
getClipError: this.options.getClipError,
+ getClipGenerationState: this.options.getClipGenerationState,
aiAssetNumbers: this.options.aiAssetNumbers
});
this.clipComponents.set(clipState.id, clipComponent);
@@ -180,6 +183,7 @@ export class TrackComponent {
onSelect: this.options.onClipSelect,
getRenderer: this.options.getClipRenderer,
getClipError: this.options.getClipError,
+ getClipGenerationState: this.options.getClipGenerationState,
attachedLuma: attachedLuma ?? undefined,
onMaskClick: this.options.onMaskClick,
onMenuClick: this.options.onMenuClick,
diff --git a/src/components/timeline/components/track/track-list.ts b/src/components/timeline/components/track/track-list.ts
index edee3e5f..816f3156 100644
--- a/src/components/timeline/components/track/track-list.ts
+++ b/src/components/timeline/components/track/track-list.ts
@@ -9,6 +9,8 @@ export interface TrackListOptions {
getClipRenderer: (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;
/** Check if content clip has an attached luma */
hasAttachedLuma?: (trackIndex: number, clipIndex: number) => boolean;
/** Find attached luma for a content clip via timing match */
@@ -85,6 +87,7 @@ export class TrackListComponent {
onClipSelect: this.options.onClipSelect,
getClipRenderer: this.options.getClipRenderer,
getClipError: this.options.getClipError,
+ getClipGenerationState: this.options.getClipGenerationState,
hasAttachedLuma: this.options.hasAttachedLuma,
findAttachedLuma: this.options.findAttachedLuma,
onMaskClick: this.options.onMaskClick,
diff --git a/src/components/timeline/timeline-state.ts b/src/components/timeline/timeline-state.ts
index 7e775dce..240bf9e7 100644
--- a/src/components/timeline/timeline-state.ts
+++ b/src/components/timeline/timeline-state.ts
@@ -33,6 +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);
// Selection changes are UI state (not document mutations)
this.edit.events.on(EditEvent.ClipSelected, this.invalidateCache);
@@ -235,6 +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.ClipSelected, this.invalidateCache);
this.edit.events.off(EditEvent.SelectionCleared, this.invalidateCache);
this.edit.getInternalEvents().off(InternalEvent.ClipFocused, this.onClipFocused);
diff --git a/src/components/timeline/timeline.ts b/src/components/timeline/timeline.ts
index b606a842..c43ff6c1 100644
--- a/src/components/timeline/timeline.ts
+++ b/src/components/timeline/timeline.ts
@@ -65,6 +65,7 @@ export class Timeline {
private readonly handlePlaybackPause: () => void;
private readonly handleClipSelected: () => void;
private readonly handleClipLoadFailed: () => void;
+ private readonly handleClipGeneration: () => void;
private readonly handleClipUpdated: () => void;
private readonly handleClipFocusChanged: () => void;
private readonly handleRulerMouseMove: (e: MouseEvent) => void;
@@ -115,6 +116,7 @@ export class Timeline {
};
this.handleClipSelected = () => this.requestRender();
this.handleClipLoadFailed = () => this.requestRender();
+ this.handleClipGeneration = () => this.requestRender();
this.handleClipUpdated = () => this.requestRender();
this.handleClipFocusChanged = () => this.requestRender();
this.handleRulerMouseMove = (e: MouseEvent) => {
@@ -257,6 +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);
// Listen for focus changes (source popup hover-to-highlight)
const internal = this.edit.getInternalEvents();
@@ -276,6 +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);
const internal = this.edit.getInternalEvents();
internal.off(InternalEvent.ClipFocused, this.handleClipFocusChanged);
@@ -394,6 +402,7 @@ export class Timeline {
},
getClipRenderer: type => this.clipRenderers.get(type),
getClipError: (trackIndex, clipIndex) => this.edit.getClipError(trackIndex, clipIndex),
+ getClipGenerationState: clipId => this.edit.getClipGenerationState(clipId),
hasAttachedLuma: (trackIndex, clipIndex) => this.stateManager.hasAttachedLuma(trackIndex, clipIndex),
findAttachedLuma: (trackIndex, clipIndex) => this.stateManager.findAttachedLuma(trackIndex, clipIndex),
onMaskClick: (contentTrackIndex, contentClipIndex) => {
diff --git a/src/styles/timeline/timeline.css b/src/styles/timeline/timeline.css
index dda04582..392cc5f9 100644
--- a/src/styles/timeline/timeline.css
+++ b/src/styles/timeline/timeline.css
@@ -1156,3 +1156,25 @@
transform: translateX(2px);
}
}
+
+.ss-clip.ss-clip--generating .ss-clip-icon {
+ box-sizing: border-box;
+ width: 12px;
+ height: 12px;
+ border: 2px solid rgba(109, 40, 217, 0.25);
+ border-top-color: #6d28d9;
+ border-radius: 50%;
+ font-size: 0;
+ opacity: 1;
+ animation: ss-generation-spin 0.8s linear infinite;
+}
+
+.ss-clip.ss-clip--generating .ss-clip-icon svg {
+ display: none;
+}
+
+@keyframes ss-generation-spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
diff --git a/tests/ai-pending-overlay.test.ts b/tests/ai-pending-overlay.test.ts
new file mode 100644
index 00000000..eb7918b2
--- /dev/null
+++ b/tests/ai-pending-overlay.test.ts
@@ -0,0 +1,107 @@
+/**
+ * @jest-environment jsdom
+ */
+/* eslint-disable max-classes-per-file, @typescript-eslint/lines-between-class-members -- Pixi constructor stubs share one module mock. */
+
+const mockTextInstances: Array<{ text: string }> = [];
+
+jest.mock("pixi.js", () => {
+ class MockPoint {
+ set = jest.fn();
+ }
+
+ class MockContainer {
+ children: unknown[] = [];
+ position = new MockPoint();
+ scale = new MockPoint();
+ mask: unknown = null;
+ filters: unknown[] = [];
+
+ addChild = jest.fn((child: unknown) => {
+ this.children.push(child);
+ return child;
+ });
+
+ removeChildren = jest.fn(() => {
+ this.children = [];
+ });
+
+ destroy = jest.fn();
+ }
+
+ class MockGraphics extends MockContainer {
+ clear = jest.fn().mockReturnThis();
+ roundRect = jest.fn().mockReturnThis();
+ rect = jest.fn().mockReturnThis();
+ circle = jest.fn().mockReturnThis();
+ arc = jest.fn().mockReturnThis();
+ fill = jest.fn().mockReturnThis();
+ stroke = jest.fn().mockReturnThis();
+ svg = jest.fn().mockReturnThis();
+ }
+
+ class MockText extends MockContainer {
+ anchor = new MockPoint();
+ text: string;
+
+ constructor({ text }: { text: string }) {
+ super();
+ this.text = text;
+ mockTextInstances.push(this);
+ }
+ }
+
+ return {
+ Container: MockContainer,
+ Graphics: MockGraphics,
+ Text: MockText,
+ BlurFilter: class MockBlurFilter {}
+ };
+});
+
+// eslint-disable-next-line import/first -- Pixi must be mocked before loading the overlay.
+import { AiPendingOverlay } from "@canvas/players/generation/pending-overlay";
+
+describe("AiPendingOverlay generation status", () => {
+ let frames: FrameRequestCallback[];
+
+ beforeEach(() => {
+ frames = [];
+ mockTextInstances.length = 0;
+ jest.spyOn(window, "requestAnimationFrame").mockImplementation(callback => {
+ frames.push(callback);
+ return frames.length;
+ });
+ jest.spyOn(window, "cancelAnimationFrame").mockImplementation(() => undefined);
+ });
+
+ afterEach(() => {
+ jest.restoreAllMocks();
+ });
+
+ it("reassures the user when generation continues beyond ten seconds", () => {
+ const overlay = new AiPendingOverlay({
+ mode: "panel",
+ icon: "image",
+ width: 640,
+ height: 360,
+ prompt: "A lighthouse in a storm"
+ });
+ overlay.setGenerating(true);
+
+ const runFrame = (time: number): void => {
+ const callback = frames.shift();
+ if (!callback) throw new Error("No animation frame scheduled");
+ callback(time);
+ };
+
+ runFrame(0);
+ runFrame(9_999);
+ expect(mockTextInstances.at(-1)?.text).toBe("Generating…");
+
+ runFrame(10_000);
+ expect(mockTextInstances.at(-1)?.text).toBe("Still generating…");
+
+ overlay.dispose();
+ });
+});
diff --git a/tests/generation-state-binding.test.ts b/tests/generation-state-binding.test.ts
new file mode 100644
index 00000000..404287b3
--- /dev/null
+++ b/tests/generation-state-binding.test.ts
@@ -0,0 +1,41 @@
+import type { AiPendingOverlay } from "@canvas/players/generation/pending-overlay";
+import { bindGenerationState } from "@canvas/players/generation/state-binding";
+import type { Edit } from "@core/edit-session";
+import { EventEmitter } from "@core/events/event-emitter";
+import { InternalEvent, type InternalEventMap } from "@core/events/edit-events";
+
+function setup(state: { status: "generating" | "failed"; error?: string }) {
+ const events = new EventEmitter();
+ const edit = {
+ getClipGenerationState: jest.fn(() => state),
+ getInternalEvents: jest.fn(() => events)
+ } as unknown as Edit;
+ const overlay = {
+ setGenerating: jest.fn(),
+ setFailed: jest.fn()
+ } as unknown as AiPendingOverlay;
+ return { edit, events, overlay };
+}
+
+describe("generation state binding", () => {
+ it("initialises from current state and follows matching events", () => {
+ const { edit, events, overlay } = setup({ status: "generating" });
+ const unbind = bindGenerationState(edit, "clip-1", overlay);
+
+ expect(overlay.setGenerating).toHaveBeenCalledWith(true);
+ events.emit(InternalEvent.ClipGenerationFailed, { clipId: "clip-1", error: "model unavailable" });
+ expect(overlay.setFailed).toHaveBeenCalledWith("model unavailable");
+
+ unbind();
+ events.emit(InternalEvent.ClipGenerationStarted, { clipId: "clip-1" });
+ expect(overlay.setGenerating).toHaveBeenCalledTimes(1);
+ });
+
+ it("uses a generic message when failed state has no error", () => {
+ const { edit, overlay } = setup({ status: "failed" });
+
+ bindGenerationState(edit, "clip-1", overlay);
+
+ expect(overlay.setFailed).toHaveBeenCalledWith("Generation failed");
+ });
+});
diff --git a/tests/media-player-fallback.test.ts b/tests/media-player-fallback.test.ts
index 511abb2a..8dba5028 100644
--- a/tests/media-player-fallback.test.ts
+++ b/tests/media-player-fallback.test.ts
@@ -270,6 +270,52 @@ describe("media player fallbacks", () => {
});
});
+describe("player disposal during load", () => {
+ let warnSpy: jest.SpyInstance;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {});
+ });
+
+ afterEach(() => {
+ warnSpy.mockRestore();
+ });
+
+ it("does not subscribe to generation events when disposed mid-load", async () => {
+ const on = jest.fn();
+ const edit = Object.assign(createEdit(), {
+ getInternalEvents: () => ({ on, off: jest.fn() }),
+ getClipGenerationState: jest.fn(() => undefined)
+ });
+
+ let releaseTexture: (() => void) | undefined;
+ edit.assetLoader.load.mockImplementation(
+ () =>
+ new Promise(resolve => {
+ releaseTexture = () => resolve(null);
+ })
+ );
+
+ const clip = { asset: { type: "image-to-video", src: "https://example.com/in.jpg" }, start: 0, length: 5 } as ResolvedClip;
+ const player = new ImageToVideoPlayer(edit as never, clip);
+ player.clipId = "clip-1";
+
+ const loading = player.load();
+ // load() awaits super.load() before it reaches the texture fetch
+ await new Promise(resolve => {
+ setTimeout(resolve, 0);
+ });
+ expect(releaseTexture).toBeDefined();
+
+ player.dispose();
+ releaseTexture?.();
+ await loading;
+
+ expect(on).not.toHaveBeenCalled();
+ });
+});
+
describe("media player source URLs", () => {
// Presigned S3-style URL: any appended query parameter invalidates the signature
const signedSrc = "https://bucket.s3.amazonaws.com/media.mp4?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Signature=abc123";
diff --git a/tests/timeline-state-manager.test.ts b/tests/timeline-state-manager.test.ts
index e4c3c5b0..c9f9ff8a 100644
--- a/tests/timeline-state-manager.test.ts
+++ b/tests/timeline-state-manager.test.ts
@@ -161,6 +161,19 @@ describe("TimelineStateManager", () => {
expect(tracks2).not.toBe(tracks1);
});
+ it.each([InternalEvent.ClipGenerationStarted, InternalEvent.ClipGenerationCompleted, InternalEvent.ClipGenerationFailed])(
+ "invalidates cache when %s fires",
+ event => {
+ const edit = createMockEdit([[{ asset: { type: "image", prompt: "a lighthouse" }, start: 0, length: 5 }]]);
+ const stateManager = new TimelineStateManager(edit as never);
+ const tracks1 = stateManager.getTracks();
+
+ edit.getInternalEvents().emit(event);
+
+ expect(stateManager.getTracks()).not.toBe(tracks1);
+ }
+ );
+
it("unsubscribes from events on dispose", () => {
const edit = createMockEdit();
const stateManager = new TimelineStateManager(edit as never);
@@ -170,6 +183,9 @@ describe("TimelineStateManager", () => {
expect(edit.events.off).toHaveBeenCalledWith(InternalEvent.Resolved, expect.any(Function));
expect(edit.events.off).toHaveBeenCalledWith(EditEvent.ClipUpdated, expect.any(Function));
expect(edit.events.off).toHaveBeenCalledWith(EditEvent.TimelineUpdated, expect.any(Function));
+ expect(edit.getInternalEvents().off).toHaveBeenCalledWith(InternalEvent.ClipGenerationStarted, expect.any(Function));
+ expect(edit.getInternalEvents().off).toHaveBeenCalledWith(InternalEvent.ClipGenerationCompleted, expect.any(Function));
+ expect(edit.getInternalEvents().off).toHaveBeenCalledWith(InternalEvent.ClipGenerationFailed, expect.any(Function));
expect(edit.events.off).toHaveBeenCalledWith(EditEvent.ClipSelected, expect.any(Function));
expect(edit.events.off).toHaveBeenCalledWith(EditEvent.SelectionCleared, expect.any(Function));
});