Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 78 additions & 2 deletions src/components/canvas/players/generation/pending-overlay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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();
Expand All @@ -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 });
Expand All @@ -231,6 +261,8 @@ export class AiPendingOverlay {
private rebuild(): void {
this.container.removeChildren();
this.layers = [];
this.progressRing = null;
this.generationStatusText = null;
this.build();
}

Expand All @@ -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);
Expand Down Expand Up @@ -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;

Expand All @@ -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(`<svg viewBox="0 0 24 24"><path d="${AI_ICON_FILL_PATHS[icon]}" fill="#C084FC" /></svg>`);
const scale = BADGE_ICON_SIZE / 24;
Expand All @@ -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: {
Expand All @@ -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);
Expand Down
32 changes: 32 additions & 0 deletions src/components/canvas/players/generation/state-binding.ts
Original file line number Diff line number Diff line change
@@ -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);
};
}
13 changes: 9 additions & 4 deletions src/components/canvas/players/image-to-video-player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -14,6 +15,7 @@ export class ImageToVideoPlayer extends Player {
private texture: pixi.Texture<pixi.ImageSource> | 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);
Expand All @@ -33,10 +35,10 @@ 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;

if (!loaded) {
Expand All @@ -53,6 +55,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();
Expand Down Expand Up @@ -107,6 +110,8 @@ export class ImageToVideoPlayer extends Player {
this.placeholder?.destroy();
this.placeholder = null;

this.unbindGeneration?.();
this.unbindGeneration = null;
this.aiOverlay?.dispose();
this.aiOverlay = null;

Expand Down
5 changes: 5 additions & 0 deletions src/components/canvas/players/text-to-image-player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand Down
23 changes: 23 additions & 0 deletions src/components/timeline/components/clip/clip-component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
Expand Down
4 changes: 4 additions & 0 deletions src/components/timeline/components/track/track-component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) */
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions src/components/timeline/components/track/track-list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions src/components/timeline/timeline-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading