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