diff --git a/readme.md b/readme.md index 224e9f61..51b3fa3d 100644 --- a/readme.md +++ b/readme.md @@ -190,6 +190,35 @@ Available event names: | Output | `output:resized`, `output:resolutionChanged`, `output:aspectRatioChanged`, `output:fpsChanged`, `output:formatChanged`, `output:destinationsChanged` | | Merge fields | `mergefield:changed` | +### Generating assets from prompts + +An image, video or audio asset can carry a `prompt`. Rendering generates from the prompt; +`src`, when present, is the editor preview. Register a generator and the editor offers a +generate action in the toolbar's generate pane: + +```typescript +edit.registerAssetGenerator(async ({ clipId, asset, signal }) => { + const url = await myBackend.generate(asset, { signal }); + return { url }; +}); +``` + +The SDK writes the returned URL to the clip, so the change is undoable and autosaves +like any other edit. It tracks whether a clip is generating or has failed, and renders +those states; a rejection's message is shown as-is next to a retry action. Everything +else — which models exist, what they cost, what an error means — stays with the host. + +Generation state is transient: it is never saved to the edit, never part of undo, and +gone on reload. Deleting a clip mid-generation aborts its request via the `signal`. + +A prompt is the only thing that makes a clip generate. Add one to a plain `image`, `video` +or `audio` asset to make it generative; clear it and the clip keeps its current `src` and +stops generating, which is how you hold on to a result you want. + +Generation is content-addressed, so the same prompt, model and options resolve to the same +asset on every render. A generator that does not go through Shotstack's own generation +hands back a preview that the render replaces. + ### Canvas `Canvas` renders the current edit. diff --git a/smoketest.html b/smoketest.html new file mode 100644 index 00000000..8848e1fb --- /dev/null +++ b/smoketest.html @@ -0,0 +1,57 @@ + + + + + + + + Shotstack Studio — generation smoketest + + + +
+
+ + + + diff --git a/src/components/canvas/players/ai-generation-binding.ts b/src/components/canvas/players/ai-generation-binding.ts new file mode 100644 index 00000000..64c67c75 --- /dev/null +++ b/src/components/canvas/players/ai-generation-binding.ts @@ -0,0 +1,30 @@ +import type { Edit } from "@core/edit-session"; +import { InternalEvent } from "@core/events/edit-events"; + +import type { AiPendingOverlay } from "./ai-pending-overlay"; + +/** Mirrors generation state onto the overlay; returns its unsubscribe. */ +export function bindGenerationState(edit: Edit, clipId: string | null, overlay: AiPendingOverlay): () => void { + if (!clipId) return () => {}; + + 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/ai-pending-overlay.ts b/src/components/canvas/players/ai-pending-overlay.ts index 3f667235..3d758a21 100644 --- a/src/components/canvas/players/ai-pending-overlay.ts +++ b/src/components/canvas/players/ai-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/image-to-video-player.ts b/src/components/canvas/players/image-to-video-player.ts index 081aec44..1cc26074 100644 --- a/src/components/canvas/players/image-to-video-player.ts +++ b/src/components/canvas/players/image-to-video-player.ts @@ -5,6 +5,7 @@ import { type Size } from "@layouts/geometry"; import { type ResolvedClip } from "@schemas"; import * as pixi from "pixi.js"; +import { bindGenerationState } from "./ai-generation-binding"; import { AiPendingOverlay } from "./ai-pending-overlay"; 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,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) { @@ -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(); @@ -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; diff --git a/src/components/canvas/players/text-to-image-player.ts b/src/components/canvas/players/text-to-image-player.ts index 410123b1..1c434c7f 100644 --- a/src/components/canvas/players/text-to-image-player.ts +++ b/src/components/canvas/players/text-to-image-player.ts @@ -3,11 +3,13 @@ import { computeAiAssetNumber, isAiAsset } from "@core/shared/ai-asset-utils"; import { type Size } from "@layouts/geometry"; import type { ResolvedClip } from "@schemas"; +import { bindGenerationState } from "./ai-generation-binding"; import { AiPendingOverlay } from "./ai-pending-overlay"; 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 4ae197b9..00e368cd 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/core/commands/set-updated-clip-command.ts b/src/core/commands/set-updated-clip-command.ts index e1d593d8..43c171ad 100644 --- a/src/core/commands/set-updated-clip-command.ts +++ b/src/core/commands/set-updated-clip-command.ts @@ -11,6 +11,8 @@ type ClipType = ResolvedClip; export interface SetUpdatedClipOptions { trackIndex?: number; clipIndex?: number; + /** Keep exported placeholders and live resolution attached across property renames. */ + bindingPathMoves?: Readonly>; } /** @@ -26,6 +28,7 @@ export class SetUpdatedClipCommand implements EditCommand { private previousDocClip: Clip | null = null; private trackIndex: number; private clipIndex: number; + private bindingPathMoves: Readonly>; constructor( private initialClipConfig: ClipType | null, @@ -34,6 +37,7 @@ export class SetUpdatedClipCommand implements EditCommand { ) { this.trackIndex = options?.trackIndex ?? -1; this.clipIndex = options?.clipIndex ?? -1; + this.bindingPathMoves = options?.bindingPathMoves ?? {}; } async execute(context?: CommandContext): Promise { @@ -67,6 +71,11 @@ export class SetUpdatedClipCommand implements EditCommand { // Save bindings before modification (for undo) - read from document (source of truth) const docBindings = this.clipId ? context.getClipBindings(this.clipId) : undefined; this.storedInitialBindings = docBindings ? new Map(docBindings) : new Map(); + const movedBindings = new Map(); + for (const [from, to] of Object.entries(this.bindingPathMoves)) { + const binding = docBindings?.get(from); + if (binding) movedBindings.set(to, binding); + } // Use provided indices or calculate from player const trackIndex = this.trackIndex >= 0 ? this.trackIndex : player.layer - 1; @@ -89,6 +98,10 @@ export class SetUpdatedClipCommand implements EditCommand { context.removeClipBinding(this.clipId, path); } } + if (this.clipId) { + for (const from of Object.keys(this.bindingPathMoves)) context.removeClipBinding(this.clipId, from); + for (const [to, binding] of movedBindings) context.setClipBinding(this.clipId, to, binding); + } // Check if asset src changed (fallback to constructor params for redo case) const initialConfig = this.storedInitialConfig ?? this.initialClipConfig; diff --git a/src/core/edit-session.ts b/src/core/edit-session.ts index 0ff7b4fb..cabafb8f 100644 --- a/src/core/edit-session.ts +++ b/src/core/edit-session.ts @@ -57,6 +57,8 @@ import * as pixi from "pixi.js"; import { CommandQueue } from "./commands/command-queue"; import { CommandNoop, type EditCommand, type CommandContext, type CommandResult } from "./commands/types"; import { EditDocument } from "./edit-document"; +import { AssetGenerator, type AssetGeneratorHandler, type ClipGenerationState } from "./generation/asset-generator"; +import { migrateLegacyGeneratedAsset } from "./generation/legacy-asset-migration"; import { PlayerReconciler } from "./player-reconciler"; import { resolve as resolveDocument, resolveClip as resolveClipById, type SingleClipContext } from "./resolver"; import { InvalidAssetUrlError, extractClipUrls, extractTrackUrls } from "./url-validation"; @@ -102,6 +104,7 @@ export class Edit { private timingManager!: TimingManager; private lumaMaskController: LumaMaskController; private playerReconciler: PlayerReconciler; + private assetGenerator: AssetGenerator; private outputSettings!: OutputSettingsManager; private selectionManager!: SelectionManager; /** @internal */ @@ -151,6 +154,13 @@ export class Edit { this.internalEvents ); this.playerReconciler = new PlayerReconciler(this); + this.assetGenerator = new AssetGenerator({ + getClipAsset: clipId => this.getClipById(clipId)?.asset as Record | 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 }) + }); this.mergeFieldService = new MergeFieldService(this.internalEvents); this.outputSettings = new OutputSettingsManager(this); this.selectionManager = new SelectionManager(this); @@ -263,6 +273,7 @@ export class Edit { /** @internal */ public dispose(): void { this.clearClips(); + this.assetGenerator.abortAll(); this.lumaMaskController.dispose(); this.playerReconciler.dispose(); @@ -410,6 +421,60 @@ export class Edit { return clip ? structuredClone(clip) : null; } + /** + * Register the handler that turns a prompt-bearing clip into a generated asset. + * + * The SDK owns the pending, generating and failed states and writes the returned + * URL back to the clip; the host owns how generation happens and what a failure + * message says. Without a handler, no generate affordance is shown. + */ + public registerAssetGenerator(handler: AssetGeneratorHandler): void { + this.assetGenerator.register(handler); + } + + /** @internal */ + public hasAssetGenerator(): boolean { + return this.assetGenerator.hasHandler(); + } + + /** + * Generate the asset for a prompt-bearing clip and write the result to it. + * + * Rejects when the handler fails or the generated URL cannot be applied. A second + * call while one is in flight for the same clip is ignored. + * @internal + */ + public generateClipAsset(clipId: string): Promise { + return this.assetGenerator.generate(clipId); + } + + private async applyGeneratedSrc(clipId: string, url: string): Promise { + const found = this.document.getClipById(clipId); + if (!found) return; + const initialConfig = structuredClone(found.clip) as ResolvedClip; + const migration = migrateLegacyGeneratedAsset(initialConfig.asset, url); + const finalConfig = { + ...initialConfig, + asset: migration?.asset ?? { ...initialConfig.asset, src: url } + } as ResolvedClip; + ResolvedClipSchema.parse(finalConfig); + const command = new SetUpdatedClipCommand(initialConfig, finalConfig, { + trackIndex: found.trackIndex, + clipIndex: found.clipIndex, + bindingPathMoves: migration?.bindingPathMoves + }); + await this.executeCommand(command); + } + + /** + * Transient generation state for a clip, or undefined when idle. Not part of the + * edit: never saved, never undone, cleared on reload. + * @internal + */ + public getClipGenerationState(clipId: string): ClipGenerationState | undefined { + return this.assetGenerator.getState(clipId); + } + /** * Look up the (trackIndex, clipIndex) position of a clip by its stable ID. */ @@ -937,6 +1002,11 @@ export class Edit { const clipToDelete = track[clipIdx]; if (!clipToDelete) return CommandNoop(`No clip at track ${trackIdx}, index ${clipIdx}`); + // Every deletion funnels through here, so in-flight generation is dropped + // whichever way the clip goes (toolbar, keyboard, or by id). + const deletedClipId = this.document.getClipId(trackIdx, clipIdx); + if (deletedClipId) this.assetGenerator.abort(deletedClipId); + // Check if this is a content clip (not a luma) const isContentClip = clipToDelete.playerType !== PlayerType.Luma; diff --git a/src/core/events/edit-events.ts b/src/core/events/edit-events.ts index fb6d2658..0b591484 100644 --- a/src/core/events/edit-events.ts +++ b/src/core/events/edit-events.ts @@ -132,7 +132,12 @@ export const InternalEvent = { // Focus (visual highlight without selection change) ClipFocused: "clip:focused", - ClipBlurred: "clip:blurred" + ClipBlurred: "clip:blurred", + + // Asset generation UI + ClipGenerationStarted: "clip:generationStarted", + ClipGenerationCompleted: "clip:generationCompleted", + ClipGenerationFailed: "clip:generationFailed" } as const; // ───────────────────────────────────────────────────────────── @@ -225,4 +230,9 @@ export type InternalEventMap = { // Focus [InternalEvent.ClipFocused]: { trackIndex: number; clipIndex: number }; [InternalEvent.ClipBlurred]: void; + + // Asset generation UI + [InternalEvent.ClipGenerationStarted]: { clipId: string }; + [InternalEvent.ClipGenerationCompleted]: { clipId: string }; + [InternalEvent.ClipGenerationFailed]: { clipId: string; error: string }; }; diff --git a/src/core/generation/asset-generator.ts b/src/core/generation/asset-generator.ts new file mode 100644 index 00000000..118d25ed --- /dev/null +++ b/src/core/generation/asset-generator.ts @@ -0,0 +1,100 @@ +import { isAiAsset } from "@core/shared/ai-asset-utils"; + +/** Passed to the host handler for one generation. */ +export interface AssetGenerationRequest { + clipId: string; + /** Snapshot of the clip's asset when generation started. */ + asset: Record; + /** Aborted when the clip is deleted or the edit is disposed. */ + signal: AbortSignal; +} + +/** Resolves with the URL of the generated asset. */ +export type AssetGeneratorHandler = (request: AssetGenerationRequest) => Promise<{ url: string }>; + +export interface ClipGenerationState { + status: "generating" | "failed"; + /** Host-supplied message, shown as-is. */ + error?: string; +} + +export interface AssetGeneratorDeps { + getClipAsset: (clipId: string) => Record | undefined; + applyGeneratedSrc: (clipId: string, url: string) => Promise; + emitStarted: (clipId: string) => void; + emitCompleted: (clipId: string) => void; + emitFailed: (clipId: string, error: string) => void; +} + +/** + * Owns the generation lifecycle: one host handler, one in-flight request per + * clip, and the transient state the UI renders. State lives here rather than in + * the document so it is never autosaved, undone, or restored on reload. + */ +export class AssetGenerator { + private handler?: AssetGeneratorHandler; + private readonly states = new Map(); + private readonly controllers = new Map(); + + constructor(private readonly deps: AssetGeneratorDeps) {} + + public register(handler: AssetGeneratorHandler): void { + this.handler = handler; + } + + public hasHandler(): boolean { + return this.handler !== undefined; + } + + public getState(clipId: string): ClipGenerationState | undefined { + return this.states.get(clipId); + } + + public async generate(clipId: string): Promise { + if (!this.handler) { + throw new Error("No asset generator registered"); + } + if (this.states.get(clipId)?.status === "generating") return; + + const asset = this.deps.getClipAsset(clipId); + if (!asset || !isAiAsset(asset)) { + throw new Error(`Clip ${clipId} has no generatable asset`); + } + + const controller = new AbortController(); + this.controllers.set(clipId, controller); + this.states.set(clipId, { status: "generating" }); + this.deps.emitStarted(clipId); + + try { + const { url } = await this.handler({ + clipId, + asset: structuredClone(asset) as Record, + signal: controller.signal + }); + if (controller.signal.aborted) return; + this.states.delete(clipId); + await this.deps.applyGeneratedSrc(clipId, url); + this.deps.emitCompleted(clipId); + } catch (error) { + if (controller.signal.aborted) return; + const message = error instanceof Error ? error.message : String(error); + this.states.set(clipId, { status: "failed", error: message }); + this.deps.emitFailed(clipId, message); + throw error; + } finally { + this.controllers.delete(clipId); + } + } + + public abort(clipId: string): void { + this.controllers.get(clipId)?.abort(); + this.controllers.delete(clipId); + this.states.delete(clipId); + } + + public abortAll(): void { + for (const clipId of [...this.controllers.keys()]) this.abort(clipId); + this.states.clear(); + } +} diff --git a/src/core/generation/legacy-asset-migration.ts b/src/core/generation/legacy-asset-migration.ts new file mode 100644 index 00000000..5282208a --- /dev/null +++ b/src/core/generation/legacy-asset-migration.ts @@ -0,0 +1,44 @@ +import type { Clip } from "@schemas"; + +export function migrateLegacyGeneratedAsset( + asset: Clip["asset"], + src: string +): { asset: Clip["asset"]; bindingPathMoves?: Readonly> } | null { + if (asset.type === "text-to-image") { + return { asset: { type: "image", src, prompt: asset.prompt, model: "flux-schnell", crop: asset.crop } }; + } + if (asset.type === "image-to-video") { + return { + asset: { + type: "video", + src, + prompt: asset.prompt, + model: "shotstack-itv-mini", + options: { inputSrc: asset.src }, + speed: asset.speed, + crop: asset.crop + } + }; + } + if (asset.type === "text-to-speech") { + return { + asset: { + type: "audio", + src, + prompt: asset.text, + model: "polly-neural", + options: { + voice: asset.voice, + ...(asset.language === undefined ? {} : { language: asset.language }), + ...(asset.newscaster === undefined ? {} : { newscaster: asset.newscaster }) + }, + trim: asset.trim, + volume: asset.volume, + speed: asset.speed, + effect: asset.effect + }, + bindingPathMoves: { "asset.text": "asset.prompt" } + }; + } + return null; +} diff --git a/src/core/resolver.ts b/src/core/resolver.ts index 90d7095c..83f72599 100644 --- a/src/core/resolver.ts +++ b/src/core/resolver.ts @@ -69,7 +69,7 @@ interface ClipLocation { * - Tries numeric conversion first (for timing, scale, offset, etc.) * - Falls back to string resolution (for text content) */ -const STRING_ONLY_KEYS = new Set(["text", "src"]); +const STRING_ONLY_KEYS = new Set(["text", "src", "prompt"]); function resolveMergeFieldsInClip(clip: InternalClip, mergeFields: MergeFieldService): InternalClip { function processValue(value: unknown, key?: string): unknown { diff --git a/src/core/shared/ai-asset-utils.ts b/src/core/shared/ai-asset-utils.ts index 72615d7d..d0a18562 100644 --- a/src/core/shared/ai-asset-utils.ts +++ b/src/core/shared/ai-asset-utils.ts @@ -50,6 +50,18 @@ export function isAiAsset(asset: unknown): asset is AiAsset { return PROMPTABLE_MEDIA_TYPES.has(type) && hasText((asset as AiAsset).prompt); } +/** + * Whether an asset can carry a prompt, whether or not it does yet. A prompt is the only + * thing that makes a clip generate, so this is equally the set of clips a prompt can be + * added to or removed from: adding one turns a plain asset generative, clearing it turns + * a generated asset back into a fixed `src`. + */ +export function canCarryPrompt(asset: unknown): asset is AiAsset { + if (typeof asset !== "object" || asset === null || !("type" in asset)) return false; + const { type } = asset as { type: unknown }; + return typeof type === "string" && (AI_ASSET_TYPES.has(type) || PROMPTABLE_MEDIA_TYPES.has(type)); +} + /** * Whether an AI asset is still awaiting generation. Legacy generative types * are always pending (realisation replaces them with a media type); a diff --git a/src/core/ui/clip-toolbar.ts b/src/core/ui/clip-toolbar.ts index c17e6eab..9a7758b0 100644 --- a/src/core/ui/clip-toolbar.ts +++ b/src/core/ui/clip-toolbar.ts @@ -48,6 +48,11 @@ export class ClipToolbar extends BaseToolbar { +
diff --git a/src/core/ui/generate-toolbar.ts b/src/core/ui/generate-toolbar.ts new file mode 100644 index 00000000..70c22c7a --- /dev/null +++ b/src/core/ui/generate-toolbar.ts @@ -0,0 +1,234 @@ +import { EditEvent, InternalEvent } from "@core/events/edit-events"; +import { MERGE_FIELD_TEST_PATTERN } from "@core/merge/merge-field-service"; +import { canCarryPrompt } from "@core/shared/ai-asset-utils"; +import { injectShotstackStyles } from "@styles/inject"; + +import { BaseToolbar } from "./base-toolbar"; + +const PROMPT_DEBOUNCE_MS = 300; + +const promptProperty = (asset: { type: string }): "prompt" | "text" => (asset.type === "text-to-speech" ? "text" : "prompt"); + +export class GenerateToolbar extends BaseToolbar { + private promptInput: HTMLInputElement | null = null; + private generateBtn: HTMLButtonElement | null = null; + private generateError: HTMLElement | null = null; + private generateNote: HTMLElement | null = null; + private promptDebounceTimer: ReturnType | null = null; + private generationUnsubscribers: (() => void)[] = []; + private abortController: AbortController | null = null; + + override mount(parent: HTMLElement): void { + injectShotstackStyles(); + + this.container = document.createElement("div"); + this.container.className = "ss-generate-toolbar"; + + this.container.innerHTML = ` +
+ + + + +
+
+ + + + + + `; + + parent.insertBefore(this.container, parent.firstChild); + + this.promptInput = this.container.querySelector("[data-prompt-input]"); + this.generateBtn = this.container.querySelector("[data-action='generate']"); + this.generateError = this.container.querySelector("[data-generate-error]"); + this.generateNote = this.container.querySelector("[data-generate-note]"); + + this.setupEventListeners(); + this.subscribeToEditState(); + this.enableDrag(); + this.appendDeleteButton(); + } + + private setupEventListeners(): void { + this.abortController?.abort(); + this.abortController = new AbortController(); + const { signal } = this.abortController; + + this.promptInput?.addEventListener( + "input", + () => { + this.schedulePromptCommit(); + this.syncState(); + }, + { signal } + ); + + // Enter is the only way to generate without leaving the keyboard, so the pending + // prompt has to land on the clip first or the run would use the previous value. + this.promptInput?.addEventListener( + "keydown", + e => { + if (e.key !== "Enter") return; + e.preventDefault(); + this.commitPrompt(); + this.requestGeneration(); + }, + { signal } + ); + + this.generateBtn?.addEventListener( + "click", + e => { + e.stopPropagation(); + this.commitPrompt(); + this.requestGeneration(); + }, + { signal } + ); + } + + private requestGeneration(): void { + const clipId = this.getSelectedClipId(); + if (!clipId) return; + if ((this.promptInput?.value ?? "").trim() === "") return; + if (this.edit.getClipGenerationState(clipId)?.status === "generating") return; + this.edit.generateClipAsset(clipId).catch(() => { + // Failures surface as clip state. + }); + } + + private subscribeToEditState(): void { + // 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; + for (const name of names) { + const handler = (payload: { clipId: string }): void => { + if (payload.clipId === this.getSelectedClipId()) this.syncState(); + }; + events.on(name, handler); + this.generationUnsubscribers.push(() => events.off(name, handler)); + } + + const onEditChanged = (): void => this.syncState(); + this.edit.events.on(EditEvent.EditChanged, onEditChanged); + this.generationUnsubscribers.push(() => this.edit.events.off(EditEvent.EditChanged, onEditChanged)); + } + + private schedulePromptCommit(): void { + if (this.promptDebounceTimer) clearTimeout(this.promptDebounceTimer); + this.promptDebounceTimer = setTimeout(() => this.commitPrompt(), PROMPT_DEBOUNCE_MS); + } + + private commitPrompt(): void { + if (this.promptDebounceTimer) clearTimeout(this.promptDebounceTimer); + this.promptDebounceTimer = null; + + const clip = this.edit.getResolvedClip(this.selectedTrackIdx, this.selectedClipIdx); + if (!clip) return; + const property = promptProperty(clip.asset); + const path = `asset.${property}`; + const rawText = this.promptInput?.value ?? ""; + const cleared = rawText.trim() === ""; + const resolvedText = this.edit.resolveMergeFields(rawText); + const document = this.edit.getDocument(); + const clipId = this.getSelectedClipId(); + if (clipId && document) { + if (!cleared && MERGE_FIELD_TEST_PATTERN.test(rawText)) { + document.setClipBinding(clipId, path, { placeholder: rawText, resolvedValue: resolvedText }); + } else { + document.removeClipBinding(clipId, path); + } + } + this.edit.updateClip(this.selectedTrackIdx, this.selectedClipIdx, { + asset: { ...clip.asset, [property]: cleared ? undefined : resolvedText } + } as never); + } + + protected override syncState(): void { + const clip = this.edit.getResolvedClip(this.selectedTrackIdx, this.selectedClipIdx); + const asset = clip?.asset; + if (!canCarryPrompt(asset) || !this.generateBtn) return; + // While a commit is pending the field holds newer text than the asset, so the field + // wins. The enabled state below reads the field, so it must not be overwritten here. + if (this.promptInput && this.promptDebounceTimer === null) { + const property = promptProperty(asset); + const document = this.edit.getDocument(); + const clipId = this.getSelectedClipId(); + const binding = clipId ? document?.getClipBinding(clipId, `asset.${property}`) : undefined; + const value = (asset as unknown as Record)[property]; + this.promptInput.value = binding?.placeholder ?? (typeof value === "string" ? value : ""); + } + + const hasGenerator = this.edit.hasAssetGenerator(); + this.generateBtn.hidden = !hasGenerator; + if (this.generateNote) this.generateNote.hidden = hasGenerator; + if (!hasGenerator) { + if (this.generateError) this.generateError.hidden = true; + return; + } + + const clipId = this.getSelectedClipId(); + const state = clipId ? this.edit.getClipGenerationState(clipId) : undefined; + const generating = state?.status === "generating"; + const hasPrompt = (this.promptInput?.value ?? "").trim() !== ""; + const label = this.generateBtn.querySelector("[data-generate-label]"); + this.generateBtn.disabled = generating || !hasPrompt; + this.generateBtn.classList.toggle("is-generating", generating); + if (label) { + if (generating) label.textContent = "Generating…"; + else if (state?.status === "failed") label.textContent = "Retry"; + else label.textContent = "Generate"; + } + if (this.generateError) { + const message = state?.status === "failed" ? (state.error ?? "Generation failed") : ""; + this.generateError.textContent = message; + this.generateError.hidden = message === ""; + } + } + + protected override getPopupList(): (HTMLElement | null)[] { + return []; + } + + override dispose(): void { + this.abortController?.abort(); + this.abortController = null; + + if (this.promptDebounceTimer) clearTimeout(this.promptDebounceTimer); + this.promptDebounceTimer = null; + + for (const off of this.generationUnsubscribers) off(); + this.generationUnsubscribers = []; + + super.dispose(); + + this.promptInput = null; + this.generateBtn = null; + this.generateError = null; + this.generateNote = null; + } +} diff --git a/src/core/ui/media-toolbar.ts b/src/core/ui/media-toolbar.ts index 09cc1bb1..a70c077c 100644 --- a/src/core/ui/media-toolbar.ts +++ b/src/core/ui/media-toolbar.ts @@ -205,6 +205,11 @@ export class MediaToolbar extends BaseToolbar { +
diff --git a/src/core/ui/ui-controller.ts b/src/core/ui/ui-controller.ts index 404fd33e..95da9025 100644 --- a/src/core/ui/ui-controller.ts +++ b/src/core/ui/ui-controller.ts @@ -1,13 +1,15 @@ import { Canvas } from "@canvas/shotstack-canvas"; import type { Edit } from "@core/edit-session"; -import { EditEvent } from "@core/events/edit-events"; +import { EditEvent, InternalEvent } from "@core/events/edit-events"; import { EventEmitter } from "@core/events/event-emitter"; +import { canCarryPrompt, isAiAsset, isPendingAiAsset } from "@core/shared/ai-asset-utils"; import { ShotstackEdit } from "@core/shotstack-edit"; import type * as pixi from "pixi.js"; import { AssetToolbar } from "./asset-toolbar"; import { CanvasToolbar } from "./canvas-toolbar"; import { ClipToolbar } from "./clip-toolbar"; +import { GenerateToolbar } from "./generate-toolbar"; import { MediaToolbar } from "./media-toolbar"; import { RichCaptionToolbar } from "./rich-caption-toolbar"; import { RichTextToolbar } from "./rich-text-toolbar"; @@ -23,6 +25,9 @@ const TOOLBAR_WIDTH = 48; const TOOLBAR_PADDING = 12; const TOOLBAR_MIN_Y = 80; // Minimum Y to avoid overlapping with top navigation +/** Which pane the top toolbar shows. */ +type ToolbarMode = "asset" | "clip" | "generate"; + /** * Configuration for a toolbar button. */ @@ -127,7 +132,9 @@ export class UIController { // Toolbar mode switching private clipToolbar: ClipToolbar | null = null; - private toolbarMode: "asset" | "clip" = "asset"; + private generateToolbar: GenerateToolbar | null = null; + private toolbarMode: ToolbarMode = "asset"; + private generationListeners: Array<() => void> = []; private currentAssetType: string | null = null; private currentTrackIndex = -1; private currentClipIndex = -1; @@ -265,8 +272,8 @@ export class UIController { this.assetToolbar = new AssetToolbar(this); this.registerUtility(this.assetToolbar); - // ClipToolbar - managed separately for mode toggle this.clipToolbar = new ClipToolbar(this.edit); + this.generateToolbar = new GenerateToolbar(this.edit); } // ─── Public API ───────────────────────────────────────────────────────────── @@ -337,6 +344,7 @@ export class UIController { // Mount ClipToolbar to canvas container (managed separately for mode toggle) this.clipToolbar?.mount(canvasContainer); + this.generateToolbar?.mount(canvasContainer); // Mount utilities, passing drag reset to sidebar toolbars const resetPositions = () => this.updateToolbarPositions(); @@ -362,7 +370,7 @@ export class UIController { requestAnimationFrame(() => { document.querySelectorAll(".ss-toolbar-mode-btn").forEach(btn => { const handler = (): void => { - const mode = (btn as HTMLElement).dataset["mode"] as "asset" | "clip"; + const mode = (btn as HTMLElement).dataset["mode"] as ToolbarMode; if (mode) { this.setToolbarMode(mode); } @@ -375,6 +383,14 @@ export class UIController { // Backtick key shortcut for mode toggle document.addEventListener("keydown", this.onKeyDownBound); + // 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) { + const handler = (): void => this.syncGenerateSegments(); + internalEvents.on(name, handler); + this.generationListeners.push(() => internalEvents.off(name, handler)); + } + // Position toolbars after DOM is ready // Using nested rAF to ensure layout is complete before measuring requestAnimationFrame(() => { @@ -479,8 +495,11 @@ export class UIController { } } - // Dispose ClipToolbar (managed separately) this.clipToolbar?.dispose(); + this.generateToolbar?.dispose(); + + for (const off of this.generationListeners) off(); + this.generationListeners = []; // Dispose utilities for (const utility of this.utilities) { @@ -625,7 +644,7 @@ export class UIController { * Set the toolbar mode and update visibility accordingly. * @param mode - "asset" shows asset-specific toolbar, "clip" shows ClipToolbar */ - private setToolbarMode(mode: "asset" | "clip"): void { + private setToolbarMode(mode: ToolbarMode): void { this.toolbarMode = mode; // Update all toggle UIs @@ -636,6 +655,7 @@ export class UIController { }); }); + this.syncGenerateSegments(); this.updateToolbarVisibility(); } @@ -651,6 +671,7 @@ export class UIController { } } this.clipToolbar?.hide?.(); + this.generateToolbar?.hide?.(); } /** @@ -662,7 +683,10 @@ export class UIController { // No selection = nothing to show if (this.currentTrackIndex < 0 || this.currentClipIndex < 0) return; - if (this.toolbarMode === "clip") { + const mode = this.effectiveMode(); + if (mode === "generate") { + this.generateToolbar?.show?.(this.currentTrackIndex, this.currentClipIndex); + } else if (mode === "clip") { this.clipToolbar?.show?.(this.currentTrackIndex, this.currentClipIndex); } else if (this.currentAssetType) { const toolbar = this.toolbars.get(this.currentAssetType); @@ -670,6 +694,29 @@ export class UIController { } } + private isGenerateModeAvailable(): boolean { + const clip = this.edit.getResolvedClip(this.currentTrackIndex, this.currentClipIndex); + return isAiAsset(clip?.asset) || (this.edit.hasAssetGenerator() && canCarryPrompt(clip?.asset)); + } + + /** + * The mode actually shown. `generate` is intent rather than state: selecting a clip + * that cannot be generated falls back to `asset` without discarding the choice, so + * returning to a generative clip lands back on generate. + */ + private effectiveMode(): ToolbarMode { + return this.toolbarMode === "generate" && !this.isGenerateModeAvailable() ? "asset" : this.toolbarMode; + } + + /** Queries the document because toolbar mode toggles mount outside this.container. */ + private syncGenerateSegments(): void { + const generative = this.isGenerateModeAvailable(); + const clipId = this.edit.getClipId(this.currentTrackIndex, this.currentClipIndex); + const generating = clipId ? this.edit.getClipGenerationState(clipId)?.status === "generating" : false; + document.querySelectorAll(".ss-toolbar-mode-toggle").forEach(toggle => toggle.toggleAttribute("data-generative", generative)); + document.querySelectorAll('.ss-toolbar-mode-btn[data-mode="generate"]').forEach(btn => btn.classList.toggle("is-generating", generating)); + } + /** * Check if any toolbar is currently visible (clip is selected). */ @@ -693,7 +740,9 @@ export class UIController { const isBacktick = e.key === "`" || e.code === "Backquote"; if (isBacktick && this.hasVisibleToolbar() && !this.isInputFocused()) { e.preventDefault(); - this.setToolbarMode(this.toolbarMode === "asset" ? "clip" : "asset"); + const modes: ToolbarMode[] = this.isGenerateModeAvailable() ? ["asset", "clip", "generate"] : ["asset", "clip"]; + const next = modes[(modes.indexOf(this.effectiveMode()) + 1) % modes.length]; + this.setToolbarMode(next ?? "asset"); } } @@ -708,7 +757,12 @@ export class UIController { this.currentTrackIndex = trackIndex; this.currentClipIndex = clipIndex; + // A prompt-bearing clip with no output yet opens on generate: until it has been + // generated there is nothing for the other modes to act on. + if (isPendingAiAsset(clip?.asset)) this.toolbarMode = "generate"; + // Update visibility based on mode + this.syncGenerateSegments(); this.updateToolbarVisibility(); }; diff --git a/src/index.ts b/src/index.ts index 95edc633..12f74134 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,6 +9,7 @@ export { UIController } from "@core/ui/ui-controller"; export { WebGLUnsupportedError } from "@core/webgl-support"; export type { UIControllerOptions, ToolbarButtonConfig } from "@core/ui/ui-controller"; +export type { AssetGenerationRequest, AssetGeneratorHandler } from "@core/generation/asset-generator"; export type { EditConfig } from "@core/schemas"; export type { CommandResult } from "@core/commands/types"; diff --git a/src/smoketest.ts b/src/smoketest.ts new file mode 100644 index 00000000..f6e104d6 --- /dev/null +++ b/src/smoketest.ts @@ -0,0 +1,102 @@ +import { type Edit as EditSchema } from "@schemas"; +import { Timeline } from "@timeline/index"; + +import template from "./templates/generation-smoketest.json"; + +import { Edit, Canvas, Controls, UIController } from "./index"; + +/** + * Manual smoketest for the asset generation lifecycle. Run `npm run dev` and open + * /smoketest.html. The generator is fake: it returns placeholder media so every + * state can be exercised without spending credits. + */ + +type Outcome = "success" | "failure" | "slow"; + +const PLACEHOLDER = { + image: "https://shotstack-assets.s3.amazonaws.com/images/waterfall.jpeg", + video: "https://shotstack-assets.s3.amazonaws.com/footage/city-timelapse.mp4", + audio: "https://shotstack-assets.s3.amazonaws.com/music/unminus/lit.mp3" +} as const; + +let outcome: Outcome = "success"; +let delayMs = 1500; + +const wait = (ms: number, signal: AbortSignal): Promise => + new Promise((resolve, reject) => { + const timer = setTimeout(resolve, ms); + signal.addEventListener("abort", () => { + clearTimeout(timer); + reject(new Error("aborted")); + }); + }); + +function buildControls(): void { + const panel = document.createElement("div"); + panel.className = "smoketest-panel"; + panel.innerHTML = ` + Fake generator + + + + + + `; + document.body.appendChild(panel); + + panel.querySelectorAll("input[name=outcome]").forEach(input => { + input.addEventListener("change", () => { + if (input.checked) outcome = input.value as Outcome; + }); + }); + panel.querySelector("#delay")?.addEventListener("change", event => { + delayMs = Number((event.target as HTMLInputElement).value); + }); + + // Registering is one-way in the API, so this only demonstrates the + // no-generator state on a fresh load. + panel.querySelector("#toggle-generator")?.addEventListener("click", () => { + // eslint-disable-next-line no-alert -- dev harness only + alert("Reload with ?nogen to see the editor without a registered generator."); + }); +} + +async function main(): Promise { + const edit = new Edit(template as EditSchema); + const canvas = new Canvas(edit); + const ui = UIController.create(edit, canvas); + + await canvas.load(); + await edit.load(); + + const timeline = new Timeline(edit, document.querySelector("[data-shotstack-timeline]") as HTMLElement); + await timeline.load(); + + const controls = new Controls(edit); + await controls.load(); + + // Registered after load() on purpose: overlays must pick the generator up late. + if (!new URLSearchParams(window.location.search).has("nogen")) { + edit.registerAssetGenerator(async ({ clipId, asset, signal }) => { + const kind = (asset as { type?: string }).type ?? "image"; + // eslint-disable-next-line no-console -- dev harness only + console.log("[smoketest] generate", clipId, asset); + + await wait(outcome === "slow" ? 10_000 : delayMs, signal); + + if (outcome === "failure") throw new Error("Not enough credits"); + + const url = PLACEHOLDER[kind as keyof typeof PLACEHOLDER] ?? PLACEHOLDER.image; + return { url: `${url}?generated=${Date.now()}` }; + }); + } + + buildControls(); + if (!ui) throw new Error("UI controller failed to initialise"); + (window as unknown as { edit: Edit }).edit = edit; +} + +main().catch(error => { + // eslint-disable-next-line no-console -- dev harness only + console.error("Smoketest failed to start:", error); +}); diff --git a/src/styles/index.css b/src/styles/index.css index 5c0468aa..8bb59087 100644 --- a/src/styles/index.css +++ b/src/styles/index.css @@ -12,6 +12,7 @@ @import "./ui/rich-text-toolbar.css"; @import "./ui/svg-toolbar.css"; @import "./ui/media-toolbar.css"; +@import "./ui/generate-toolbar.css"; @import "./ui/text-to-image-toolbar.css"; @import "./ui/text-to-speech-toolbar.css"; @import "./ui/clip-toolbar.css"; 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/src/styles/ui/clip-toolbar.css b/src/styles/ui/clip-toolbar.css index b3ff2983..21df9ff5 100644 --- a/src/styles/ui/clip-toolbar.css +++ b/src/styles/ui/clip-toolbar.css @@ -348,6 +348,29 @@ transform: translateX(calc(100% + 2px)); } +.ss-toolbar-mode-toggle[data-mode="generate"] .ss-toolbar-mode-indicator { + transform: translateX(calc(200% + 4px)); +} + +.ss-toolbar-mode-toggle:not([data-generative]) .ss-toolbar-mode-btn[data-mode="generate"] { + display: none; +} + +/* Progress stays legible from the other modes, so switching is never required to see it. */ +@keyframes ss-generate-pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.45; + } +} + +.ss-toolbar-mode-btn[data-mode="generate"].is-generating { + animation: ss-generate-pulse 1.4s ease-in-out infinite; +} + /* Divider between toggle and content */ .ss-toolbar-mode-divider { width: 1px; @@ -361,4 +384,3 @@ .ss-toolbar-mode-container { display: none !important; } - diff --git a/src/styles/ui/generate-toolbar.css b/src/styles/ui/generate-toolbar.css new file mode 100644 index 00000000..83af2e61 --- /dev/null +++ b/src/styles/ui/generate-toolbar.css @@ -0,0 +1,103 @@ +.ss-generate-toolbar { + display: none; + position: absolute; + top: 12px; + left: 50%; + transform: translateX(-50%); + width: fit-content; + background: rgba(24, 24, 27, 0.95); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 10px; + padding: 6px 8px; + gap: 6px; + z-index: 10; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; + align-items: center; + box-shadow: + 0 4px 24px rgba(0, 0, 0, 0.4), + 0 0 0 1px rgba(0, 0, 0, 0.2), + inset 0 1px 0 rgba(255, 255, 255, 0.05); +} + +.ss-generate-toolbar.visible { + display: flex; +} + +/* The field receives content, so it sits inset — darker than the surface around it. */ +.ss-ai-prompt-input { + width: 280px; + height: 28px; + box-sizing: border-box; + padding: 0 10px; + background: rgba(0, 0, 0, 0.3); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 6px; + color: #fafafa; + font-family: inherit; + font-size: 12px; +} + +.ss-ai-prompt-input::placeholder { + color: rgba(255, 255, 255, 0.45); +} + +.ss-ai-prompt-input:hover { + border-color: rgba(255, 255, 255, 0.18); +} + +.ss-ai-prompt-input:focus { + outline: none; + border-color: rgba(255, 255, 255, 0.28); + background: rgba(0, 0, 0, 0.45); +} + +/* The only action on this surface, so it carries the weight the toolbar reserves for one. */ +.ss-ai-generate-btn { + padding: 0 16px; + font-weight: 600; + letter-spacing: -0.01em; + color: rgba(255, 255, 255, 0.95); + background: rgba(255, 255, 255, 0.08); +} + +.ss-ai-generate-btn:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.16); + color: #fff; +} + +.ss-ai-generate-btn[hidden] { + display: none; +} + +.ss-ai-generate-btn.is-generating { + opacity: 0.65; + cursor: default; +} + +/* Not an error: the prompt is functional, it simply has no local preview. */ +.ss-ai-note { + padding: 0 6px; + font-size: 11px; + color: rgba(255, 255, 255, 0.45); + white-space: nowrap; +} + +.ss-ai-note[hidden] { + display: none; +} + +.ss-ai-error { + max-width: 180px; + padding: 0 2px; + font-size: 11px; + color: #fca5a5; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ss-ai-error[hidden] { + display: none; +} diff --git a/src/templates/generation-smoketest.json b/src/templates/generation-smoketest.json new file mode 100644 index 00000000..7aa1a92b --- /dev/null +++ b/src/templates/generation-smoketest.json @@ -0,0 +1,158 @@ +{ + "timeline": { + "background": "#111111", + "tracks": [ + { + "clips": [ + { + "asset": { + "type": "image", + "prompt": "A lone lighthouse on jagged coastal cliffs at dusk, stormy waves crashing, dramatic oil painting style" + }, + "start": 0, + "length": 3 + }, + { + "asset": { + "type": "image", + "prompt": "A cinematic product photograph of a red apple on polished black stone, soft studio lighting", + "model": "nano-banana-2", + "options": { "resolution": "2K", "aspectRatio": "16:9" } + }, + "start": 3, + "length": 3 + }, + { + "asset": { + "type": "image", + "prompt": "A minimalist editorial illustration of {{ SUBJECT }} on a warm cream background" + }, + "start": 6, + "length": 3 + }, + { + "asset": { + "type": "image", + "prompt": "A turquoise ocean wave breaking inside a glass sphere on a dark studio background", + "src": "https://shotstack-assets.s3.amazonaws.com/images/wave-barrel.jpg" + }, + "start": 9, + "length": 3, + "fit": "crop" + }, + { + "asset": { + "type": "image", + "src": "https://shotstack-assets.s3.amazonaws.com/images/earth.jpg" + }, + "start": 12, + "length": 3, + "fit": "crop" + }, + { + "asset": { + "type": "video", + "prompt": "A slow aerial push over a misty pine forest at sunrise, cinematic natural light" + }, + "start": 15, + "length": 3 + }, + { + "asset": { + "type": "video", + "prompt": "The dog turns towards the camera as its fur moves gently in the breeze", + "model": "seedance-2.0", + "options": { "inputSrc": "https://shotstack-assets.s3.amazonaws.com/images/dog1.jpg" } + }, + "start": 18, + "length": 3 + }, + { + "asset": { + "type": "video", + "prompt": "A skateboarder lands a kickflip in slow motion during golden hour", + "src": "https://shotstack-assets.s3.amazonaws.com/footage/skateboarder.mp4" + }, + "start": 21, + "length": 3, + "fit": "crop" + }, + { + "asset": { + "type": "video", + "src": "https://shotstack-assets.s3.amazonaws.com/footage/beach.mp4" + }, + "start": 24, + "length": 3, + "fit": "crop" + }, + { + "asset": { + "type": "text-to-image", + "prompt": "An astronaut tending colourful flowers inside a lunar greenhouse" + }, + "start": 27, + "length": 3, + "width": 512, + "height": 512 + }, + { + "asset": { + "type": "image-to-video", + "src": "https://shotstack-assets.s3.amazonaws.com/images/waterfall.jpeg", + "prompt": "The waterfall surges as mist drifts slowly through the forest" + }, + "start": 30, + "length": 3 + } + ] + }, + { + "clips": [ + { + "asset": { + "type": "audio", + "prompt": "Welcome to Shotstack. Create videos with code, data, and a little imagination." + }, + "start": 0, + "length": 6 + }, + { + "asset": { + "type": "audio", + "prompt": "Every story begins with a single frame, then motion brings it to life.", + "model": "polly-neural", + "options": { "voice": "Matthew" } + }, + "start": 6, + "length": 6 + }, + { + "asset": { + "type": "audio", + "prompt": "The ocean is calm tonight, with soft waves rolling beneath the moonlight.", + "src": "https://shotstack-assets.s3.amazonaws.com/music/unminus/lit.mp3" + }, + "start": 12, + "length": 6 + }, + { + "asset": { + "type": "text-to-speech", + "text": "This legacy text-to-speech clip is ready for its next chapter.", + "voice": "Matthew" + }, + "start": 18, + "length": 6 + } + ] + } + ] + }, + "merge": [{ "find": "SUBJECT", "replace": "a red apple" }], + "output": { + "format": "mp4", + "fps": 25, + "size": { "width": 1280, "height": 720 } + } +} diff --git a/src/templates/prompt-assets.json b/src/templates/prompt-assets.json index 30ad5350..fb83e26b 100644 --- a/src/templates/prompt-assets.json +++ b/src/templates/prompt-assets.json @@ -18,7 +18,10 @@ "asset": { "type": "video", "prompt": "Slowly zoom out and orbit left around the trees", - "seed": "https://shotstack-assets.s3.amazonaws.com/images/woods1.jpg" + "model": "seedance-2.0", + "options": { + "inputSrc": "https://shotstack-assets.s3.amazonaws.com/images/woods1.jpg" + } }, "start": 5, "length": 5, @@ -81,7 +84,9 @@ "asset": { "type": "audio", "prompt": "Welcome to the unified prompt asset demo.", - "voice": "Matthew" + "options": { + "voice": "Matthew" + } }, "start": 10, "length": 10 diff --git a/tests/ai-asset-utils.test.ts b/tests/ai-asset-utils.test.ts index b25d689a..bc18352d 100644 --- a/tests/ai-asset-utils.test.ts +++ b/tests/ai-asset-utils.test.ts @@ -1,4 +1,4 @@ -import { isAiAsset, isPendingAiAsset, aiAssetKind, computeAiAssetNumber, getAiAssetTypeLabel } from "@core/shared/ai-asset-utils"; +import { canCarryPrompt, isAiAsset, isPendingAiAsset, aiAssetKind, computeAiAssetNumber, getAiAssetTypeLabel } from "@core/shared/ai-asset-utils"; import type { ResolvedClip } from "@schemas"; describe("ai-asset-utils", () => { @@ -11,7 +11,7 @@ describe("ai-asset-utils", () => { it("accepts prompt-bearing media assets", () => { expect(isAiAsset({ type: "image", prompt: "a cat" })).toBe(true); - expect(isAiAsset({ type: "video", prompt: "waves", seed: "https://cdn/seed.png" })).toBe(true); + expect(isAiAsset({ type: "video", prompt: "waves", options: { inputSrc: "https://cdn/start.png" } })).toBe(true); expect(isAiAsset({ type: "audio", prompt: "calm piano" })).toBe(true); }); @@ -31,10 +31,30 @@ describe("ai-asset-utils", () => { }); }); + describe("canCarryPrompt", () => { + it("accepts media types that have no prompt yet, so one can be added", () => { + expect(canCarryPrompt({ type: "image", src: "https://cdn/uploaded.png" })).toBe(true); + expect(canCarryPrompt({ type: "video" })).toBe(true); + expect(canCarryPrompt({ type: "audio" })).toBe(true); + }); + + it("accepts legacy generative types", () => { + expect(canCarryPrompt({ type: "text-to-image" })).toBe(true); + expect(canCarryPrompt({ type: "text-to-speech" })).toBe(true); + }); + + it("rejects types a prompt means nothing for", () => { + expect(canCarryPrompt({ type: "rich-text", text: "Title" })).toBe(false); + expect(canCarryPrompt({ type: "luma" })).toBe(false); + expect(canCarryPrompt(null)).toBe(false); + expect(canCarryPrompt({})).toBe(false); + }); + }); + describe("isPendingAiAsset", () => { it("is pending while a prompt-bearing media asset has no src", () => { expect(isPendingAiAsset({ type: "image", prompt: "a cat" })).toBe(true); - expect(isPendingAiAsset({ type: "video", prompt: "waves", seed: "https://cdn/seed.png" })).toBe(true); + expect(isPendingAiAsset({ type: "video", prompt: "waves", options: { inputSrc: "https://cdn/start.png" } })).toBe(true); expect(isPendingAiAsset({ type: "audio", prompt: "calm piano" })).toBe(true); }); diff --git a/tests/ai-pending-overlay.test.ts b/tests/ai-pending-overlay.test.ts new file mode 100644 index 00000000..82cb5269 --- /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/ai-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/asset-generator.test.ts b/tests/asset-generator.test.ts new file mode 100644 index 00000000..2cdf6a9d --- /dev/null +++ b/tests/asset-generator.test.ts @@ -0,0 +1,168 @@ +import { AssetGenerator, type AssetGeneratorDeps } from "@core/generation/asset-generator"; + +const PROMPT_ASSET = { type: "image", prompt: "a red apple" }; + +function makeDeps(overrides: Partial = {}) { + const started: string[] = []; + const completed: string[] = []; + const failed: { clipId: string; error: string }[] = []; + const applied: { clipId: string; url: string }[] = []; + + const deps: AssetGeneratorDeps = { + getClipAsset: () => ({ ...PROMPT_ASSET }), + applyGeneratedSrc: async (clipId, url) => { + applied.push({ clipId, url }); + }, + emitStarted: clipId => started.push(clipId), + emitCompleted: clipId => completed.push(clipId), + emitFailed: (clipId, error) => failed.push({ clipId, error }), + ...overrides + }; + + return { deps, started, completed, failed, applied }; +} + +describe("AssetGenerator", () => { + it("writes the generated src back and reports completion", async () => { + const { deps, started, completed, applied } = makeDeps(); + const generator = new AssetGenerator(deps); + generator.register(async () => ({ url: "https://cdn/out.png" })); + + await generator.generate("clip-1"); + + expect(applied).toEqual([{ clipId: "clip-1", url: "https://cdn/out.png" }]); + expect(started).toEqual(["clip-1"]); + expect(completed).toEqual(["clip-1"]); + expect(generator.getState("clip-1")).toBeUndefined(); + }); + + it("keeps the host's message on failure and writes nothing", async () => { + const { deps, failed, applied } = makeDeps(); + const generator = new AssetGenerator(deps); + generator.register(async () => { + throw new Error("Not enough credits"); + }); + + await expect(generator.generate("clip-1")).rejects.toThrow("Not enough credits"); + + expect(applied).toEqual([]); + expect(generator.getState("clip-1")).toEqual({ status: "failed", error: "Not enough credits" }); + expect(failed).toEqual([{ clipId: "clip-1", error: "Not enough credits" }]); + }); + + it("clears the failed state when retried", async () => { + const { deps } = makeDeps(); + const generator = new AssetGenerator(deps); + let attempt = 0; + generator.register(async () => { + attempt += 1; + if (attempt === 1) throw new Error("boom"); + return { url: "https://cdn/out.png" }; + }); + + await expect(generator.generate("clip-1")).rejects.toThrow("boom"); + expect(generator.getState("clip-1")?.status).toBe("failed"); + + await generator.generate("clip-1"); + expect(generator.getState("clip-1")).toBeUndefined(); + expect(attempt).toBe(2); + }); + + it("ignores a second request while one is in flight for the same clip", async () => { + const { deps } = makeDeps(); + const generator = new AssetGenerator(deps); + let calls = 0; + let release: (() => void) | undefined; + generator.register(async () => { + calls += 1; + await new Promise(resolve => { + release = resolve; + }); + return { url: "https://cdn/out.png" }; + }); + + const first = generator.generate("clip-1"); + await generator.generate("clip-1"); + expect(calls).toBe(1); + + release?.(); + await first; + }); + + it("generates different clips concurrently", async () => { + const { deps, completed } = makeDeps(); + const generator = new AssetGenerator(deps); + generator.register(async ({ clipId }) => ({ url: `https://cdn/${clipId}.png` })); + + await Promise.all([generator.generate("clip-1"), generator.generate("clip-2")]); + + expect(completed.sort()).toEqual(["clip-1", "clip-2"]); + }); + + it("rejects without a registered handler", async () => { + const { deps, started } = makeDeps(); + const generator = new AssetGenerator(deps); + + await expect(generator.generate("clip-1")).rejects.toThrow("No asset generator registered"); + + expect(started).toEqual([]); + expect(generator.getState("clip-1")).toBeUndefined(); + }); + + it("rejects a clip whose asset is not generatable", async () => { + const { deps, started } = makeDeps({ getClipAsset: () => ({ type: "image", src: "https://cdn/a.png" }) }); + const generator = new AssetGenerator(deps); + generator.register(async () => ({ url: "https://cdn/out.png" })); + + await expect(generator.generate("clip-1")).rejects.toThrow("Clip clip-1 has no generatable asset"); + + expect(started).toEqual([]); + }); + + it("accepts a realised asset so it can be regenerated", async () => { + const { deps, applied } = makeDeps({ + getClipAsset: () => ({ type: "image", prompt: "a red apple", src: "https://cdn/old.png" }) + }); + const generator = new AssetGenerator(deps); + generator.register(async () => ({ url: "https://cdn/new.png" })); + + await generator.generate("clip-1"); + + expect(applied).toEqual([{ clipId: "clip-1", url: "https://cdn/new.png" }]); + }); + + it("leaves no failed state when aborted mid-flight", async () => { + const { deps, failed, applied } = makeDeps(); + const generator = new AssetGenerator(deps); + generator.register( + ({ signal }) => + new Promise((_resolve, reject) => { + signal.addEventListener("abort", () => reject(new Error("aborted"))); + }) + ); + + const pending = generator.generate("clip-1"); + generator.abort("clip-1"); + await pending; + + expect(generator.getState("clip-1")).toBeUndefined(); + expect(failed).toEqual([]); + expect(applied).toEqual([]); + }); + + it("passes a snapshot rather than the live asset", async () => { + const live = { type: "image", prompt: "a red apple" }; + const { deps } = makeDeps({ getClipAsset: () => live }); + const generator = new AssetGenerator(deps); + let received: Record | undefined; + generator.register(async ({ asset }) => { + received = asset; + return { url: "https://cdn/out.png" }; + }); + + await generator.generate("clip-1"); + + expect(received).toEqual(live); + expect(received).not.toBe(live); + }); +}); diff --git a/tests/edit-clip-operations.test.ts b/tests/edit-clip-operations.test.ts index f93ae50e..7d4b81c5 100644 --- a/tests/edit-clip-operations.test.ts +++ b/tests/edit-clip-operations.test.ts @@ -571,6 +571,74 @@ describe("Edit Clip Operations", () => { expect((await edit.deleteClipById(id as string)).status).toBe("success"); }); + it("moves a legacy text-to-speech merge binding through generation, undo and redo", async () => { + const mergedEdit = new Edit({ + timeline: { + tracks: [ + { + clips: [ + { + asset: { type: "text-to-speech", text: "Hello {{ NAME }}", voice: "Matthew" }, + start: 0, + length: 1 + } + ] + } + ] + }, + merge: [{ find: "NAME", replace: "Derk" }], + output: { size: { width: 1920, height: 1080 }, format: "mp4" } + }); + await mergedEdit.load(); + 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); + expect(mergedEdit.getEdit().timeline.tracks[0]?.clips[0]?.asset).toMatchObject({ + type: "audio", + prompt: "Hello {{ NAME }}" + }); + expect(mergedEdit.getResolvedClip(0, 0)?.asset).toMatchObject({ prompt: "Hello Derk" }); + + await mergedEdit.undo(); + expect(mergedEdit.getEdit().timeline.tracks[0]?.clips[0]?.asset).toMatchObject({ + type: "text-to-speech", + text: "Hello {{ NAME }}" + }); + + await mergedEdit.redo(); + expect(mergedEdit.getEdit().timeline.tracks[0]?.clips[0]?.asset).toMatchObject({ + type: "audio", + prompt: "Hello {{ NAME }}" + }); + mergedEdit.dispose(); + }); + + it("aborts in-flight generation when the clip is deleted by position", async () => { + await edit.addClip(0, { asset: { type: "image", prompt: "a red apple" }, start: 0, length: 5 } as never); + let aborted = false; + edit.registerAssetGenerator( + ({ signal }) => + new Promise((_resolve, reject) => { + signal.addEventListener("abort", () => { + aborted = true; + reject(new Error("aborted")); + }); + }) + ); + 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); + expect(edit.getClipGenerationState(id)?.status).toBe("generating"); + + await edit.deleteClip(0, 1); + await pending; + + expect(aborted).toBe(true); + expect(edit.getClipGenerationState(id)).toBeUndefined(); + }); + it("updateClip resolves success and noop by position", async () => { expect((await edit.updateClip(0, 0, { fit: "contain" })).status).toBe("success"); expect(await edit.updateClip(0, 99, {})).toMatchObject({ status: "noop" }); diff --git a/tests/generate-toolbar.test.ts b/tests/generate-toolbar.test.ts new file mode 100644 index 00000000..a4457a9d --- /dev/null +++ b/tests/generate-toolbar.test.ts @@ -0,0 +1,327 @@ +/** + * @jest-environment jsdom + */ +/* eslint-disable import/first */ +/* eslint-disable max-classes-per-file -- the two stub classes are jest.mock factories, not real types */ + +import type { Edit } from "@core/edit-session"; + +if (typeof structuredClone === "undefined") { + global.structuredClone = (obj: unknown) => JSON.parse(JSON.stringify(obj)); +} + +jest.mock("pixi.js", () => ({})); +jest.mock("../src/components/canvas/players/player", () => ({ + Player: class MockPlayer {}, + PlayerType: {} +})); +jest.mock("../src/core/edit-session", () => ({})); +jest.mock("@styles/inject", () => ({ + injectShotstackStyles: jest.fn() +})); + +import { InternalEvent } from "@core/events/edit-events"; +import { GenerateToolbar } from "@core/ui/generate-toolbar"; + +type MockEdit = ReturnType; + +function createMockEdit(asset: Record = { type: "image", prompt: "a cat" }) { + const internalEvents = { on: jest.fn(), off: jest.fn() }; + return { + getClipId: jest.fn().mockReturnValue("clip-1"), + getResolvedClip: jest.fn().mockReturnValue({ asset }), + getDocument: jest.fn(), + hasAssetGenerator: jest.fn().mockReturnValue(true), + getClipGenerationState: jest.fn(), + generateClipAsset: jest.fn().mockResolvedValue(undefined), + resolveMergeFields: jest.fn((value: string) => value), + updateClip: jest.fn(), + deleteClip: jest.fn(), + canDeleteClip: jest.fn(() => true), + getInternalEvents: jest.fn(() => internalEvents), + events: { on: jest.fn(), off: jest.fn() } + }; +} + +function mountToolbar(edit: MockEdit): { toolbar: GenerateToolbar; container: HTMLDivElement } { + const container = document.createElement("div"); + document.body.appendChild(container); + const toolbar = new GenerateToolbar(edit as unknown as Edit); + toolbar.mount(container); + toolbar.show(0, 0); + return { toolbar, container }; +} + +describe("GenerateToolbar", () => { + afterEach(() => { + document.body.innerHTML = ""; + }); + + it("offers all three modes so the other panes stay reachable", () => { + const { toolbar, container } = mountToolbar(createMockEdit()); + + const modes = [...container.querySelectorAll(".ss-toolbar-mode-btn")].map(b => (b as HTMLElement).dataset["mode"]); + expect(modes).toEqual(["asset", "clip", "generate"]); + expect(container.querySelector(".ss-toolbar-mode-btn.active")?.getAttribute("data-mode")).toBe("generate"); + + toolbar.dispose(); + }); + + it("marks its own toggle generative, since it only ever shows for such a clip", () => { + const { toolbar, container } = mountToolbar(createMockEdit()); + + expect(container.querySelector(".ss-toolbar-mode-toggle")?.hasAttribute("data-generative")).toBe(true); + + toolbar.dispose(); + }); + + it("calls the action Generate whether or not the clip already has output", () => { + const assets: Array> = [ + { type: "image", prompt: "a cat" }, + { type: "image", prompt: "a cat", src: "https://cdn/out.png" } + ]; + + assets.forEach(asset => { + const { toolbar, container } = mountToolbar(createMockEdit(asset)); + expect(container.querySelector("[data-generate-label]")?.textContent).toBe("Generate"); + toolbar.dispose(); + document.body.innerHTML = ""; + }); + }); + + it("disables the action while a generation is in flight", () => { + const edit = createMockEdit(); + edit.getClipGenerationState.mockReturnValue({ status: "generating" }); + const { toolbar, container } = mountToolbar(edit); + + const btn = container.querySelector("[data-action='generate']"); + expect(btn?.disabled).toBe(true); + expect(container.querySelector("[data-generate-label]")?.textContent).toBe("Generating…"); + + toolbar.dispose(); + }); + + it("offers a retry and the reason when generation failed", () => { + const edit = createMockEdit(); + edit.getClipGenerationState.mockReturnValue({ status: "failed", error: "model unavailable" }); + const { toolbar, container } = mountToolbar(edit); + + expect(container.querySelector("[data-generate-label]")?.textContent).toBe("Retry"); + const error = container.querySelector("[data-generate-error]"); + expect(error?.hidden).toBe(false); + expect(error?.textContent).toBe("model unavailable"); + + toolbar.dispose(); + }); + + it("says the prompt still generates on render when no generator is registered", () => { + const edit = createMockEdit(); + edit.hasAssetGenerator.mockReturnValue(false); + const { toolbar, container } = mountToolbar(edit); + + expect(container.querySelector("[data-action='generate']")?.hidden).toBe(true); + const note = container.querySelector("[data-generate-note]"); + expect(note?.hidden).toBe(false); + expect(note?.textContent?.trim()).toBe("Generates on render"); + expect(container.querySelector("[data-prompt-input]")?.disabled).toBe(false); + + toolbar.dispose(); + }); + + it("drops the note once a generator can preview it here", () => { + const { toolbar, container } = mountToolbar(createMockEdit()); + + expect(container.querySelector("[data-generate-note]")?.hidden).toBe(true); + expect(container.querySelector("[data-action='generate']")?.hidden).toBe(false); + + toolbar.dispose(); + }); + + it("generates the selected clip when the action is pressed", () => { + const edit = createMockEdit(); + const { toolbar, container } = mountToolbar(edit); + + container.querySelector("[data-action='generate']")?.click(); + + expect(edit.generateClipAsset).toHaveBeenCalledWith("clip-1"); + + toolbar.dispose(); + }); + + it("puts the whole prompt in an editable field, not a truncated label", () => { + const prompt = "a very long prompt that would not have fitted in a button label"; + const { toolbar, container } = mountToolbar(createMockEdit({ type: "image", prompt })); + + const input = container.querySelector("[data-prompt-input]"); + expect(input?.value).toBe(prompt); + + toolbar.dispose(); + }); + + it("asks for a prompt when the clip has none", () => { + const { toolbar, container } = mountToolbar(createMockEdit({ type: "text-to-image" })); + + const input = container.querySelector("[data-prompt-input]"); + expect(input?.value).toBe(""); + expect(input?.placeholder).toBe("Describe what to generate…"); + + toolbar.dispose(); + }); + + it("commits an edited prompt before generating, so Enter never runs the stale one", () => { + const edit = createMockEdit(); + const { toolbar, container } = mountToolbar(edit); + + const input = container.querySelector("[data-prompt-input]"); + input!.value = "a dog instead"; + input?.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + + expect(edit.updateClip).toHaveBeenCalledWith(0, 0, expect.objectContaining({ asset: expect.objectContaining({ prompt: "a dog instead" }) })); + expect(edit.generateClipAsset).toHaveBeenCalledWith("clip-1"); + + toolbar.dispose(); + }); + + it("edits the legacy text-to-speech text property", () => { + const document = { + setClipBinding: jest.fn(), + removeClipBinding: jest.fn(), + getClipBinding: jest.fn() + }; + const edit = createMockEdit({ type: "text-to-speech", text: "Welcome" }); + edit.getDocument.mockReturnValue(document); + const { toolbar, container } = mountToolbar(edit); + + const input = container.querySelector("[data-prompt-input]"); + expect(input?.value).toBe("Welcome"); + input!.value = "Updated welcome"; + input?.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + + expect(document.removeClipBinding).toHaveBeenCalledWith("clip-1", "asset.text"); + expect(edit.updateClip).toHaveBeenCalledWith(0, 0, expect.objectContaining({ asset: expect.objectContaining({ text: "Updated welcome" }) })); + + toolbar.dispose(); + }); + + it("keeps a merge-field prompt editable when using the base Edit class", () => { + const rawPrompt = "a calm image of {{ SUBJECT }}"; + const resolvedPrompt = "a calm image of a red apple"; + let binding: { placeholder: string; resolvedValue: string } | undefined; + const document = { + setClipBinding: jest.fn((_clipId: string, _path: string, value: { placeholder: string; resolvedValue: string }) => { + binding = value; + }), + removeClipBinding: jest.fn(), + getClipBinding: jest.fn(() => binding) + }; + const edit = createMockEdit(); + edit.getDocument.mockReturnValue(document); + edit.resolveMergeFields.mockReturnValue(resolvedPrompt); + const { toolbar, container } = mountToolbar(edit); + + const input = container.querySelector("[data-prompt-input]"); + input!.value = rawPrompt; + input?.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + + expect(document.setClipBinding).toHaveBeenCalledWith("clip-1", "asset.prompt", { + placeholder: rawPrompt, + resolvedValue: resolvedPrompt + }); + expect(edit.updateClip).toHaveBeenCalledWith(0, 0, expect.objectContaining({ asset: expect.objectContaining({ prompt: resolvedPrompt }) })); + + input?.blur(); + toolbar.show(0, 0); + expect(input?.value).toBe(rawPrompt); + + toolbar.dispose(); + }); + + it("ignores a second request while one is already running", () => { + const edit = createMockEdit(); + edit.getClipGenerationState.mockReturnValue({ status: "generating" }); + const { toolbar, container } = mountToolbar(edit); + + container.querySelector("[data-action='generate']")?.click(); + + expect(edit.generateClipAsset).not.toHaveBeenCalled(); + + toolbar.dispose(); + }); + + it("offers the pane on a plain asset, with nothing to run yet", () => { + const { toolbar, container } = mountToolbar(createMockEdit({ type: "image", src: "https://cdn/uploaded.png" })); + + const btn = container.querySelector("[data-action='generate']"); + expect(btn?.hidden).toBe(false); + expect(btn?.disabled).toBe(true); + expect(container.querySelector("[data-prompt-input]")?.value).toBe(""); + + toolbar.dispose(); + }); + + it("enables the action as soon as a prompt is typed, and disables it again when cleared", () => { + const edit = createMockEdit({ type: "image", src: "https://cdn/uploaded.png" }); + const { toolbar, container } = mountToolbar(edit); + + const btn = container.querySelector("[data-action='generate']"); + const input = container.querySelector("[data-prompt-input]"); + expect(btn?.disabled).toBe(true); + + input!.value = "a golden sunset"; + input?.dispatchEvent(new Event("input", { bubbles: true })); + expect(btn?.disabled).toBe(false); + + btn?.click(); + expect(edit.generateClipAsset).toHaveBeenCalledWith("clip-1"); + + input!.value = ""; + input?.dispatchEvent(new Event("input", { bubbles: true })); + expect(btn?.disabled).toBe(true); + + toolbar.dispose(); + }); + + it("turns a plain asset generative when a prompt is typed", () => { + const edit = createMockEdit({ type: "image", src: "https://cdn/uploaded.png" }); + const { toolbar, container } = mountToolbar(edit); + + const input = container.querySelector("[data-prompt-input]"); + input!.value = "a golden sunset"; + input?.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + + expect(edit.updateClip).toHaveBeenCalledWith(0, 0, expect.objectContaining({ asset: expect.objectContaining({ prompt: "a golden sunset" }) })); + expect(edit.generateClipAsset).toHaveBeenCalledWith("clip-1"); + + toolbar.dispose(); + }); + + it("removes the prompt when the field is cleared, so the asset stops regenerating", () => { + const edit = createMockEdit({ type: "image", prompt: "a cat", src: "https://cdn/out.png" }); + const document = { setClipBinding: jest.fn(), removeClipBinding: jest.fn(), getClipBinding: jest.fn() }; + edit.getDocument.mockReturnValue(document); + const { toolbar, container } = mountToolbar(edit); + + const input = container.querySelector("[data-prompt-input]"); + input!.value = " "; + input?.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + + expect(edit.updateClip).toHaveBeenCalledWith(0, 0, { asset: expect.objectContaining({ prompt: undefined }) }); + expect(document.removeClipBinding).toHaveBeenCalledWith("clip-1", "asset.prompt"); + expect(edit.generateClipAsset).not.toHaveBeenCalled(); + + toolbar.dispose(); + }); + + it("does not stack generation listeners when mounted twice", () => { + const edit = createMockEdit(); + const { toolbar, container } = mountToolbar(edit); + + toolbar.mount(container); + + [InternalEvent.ClipGenerationStarted, InternalEvent.ClipGenerationCompleted, InternalEvent.ClipGenerationFailed].forEach(event => { + expect(edit.getInternalEvents().on.mock.calls.filter(([name]) => name === event)).toHaveLength(1); + }); + + toolbar.dispose(); + }); +}); diff --git a/tests/legacy-asset-migration.test.ts b/tests/legacy-asset-migration.test.ts new file mode 100644 index 00000000..fb7894fc --- /dev/null +++ b/tests/legacy-asset-migration.test.ts @@ -0,0 +1,69 @@ +import { migrateLegacyGeneratedAsset } from "@core/generation/legacy-asset-migration"; +import type { Clip } from "@schemas"; + +const generatedSrc = "https://cdn.example.com/generated"; + +describe("legacy generated asset migration", () => { + it.each([ + { + name: "text-to-image", + asset: { type: "text-to-image", prompt: "a painted fox", width: 512, height: 768 }, + expected: { + asset: { + type: "image", + src: generatedSrc, + prompt: "a painted fox", + model: "flux-schnell" + } + } + }, + { + name: "image-to-video", + asset: { + type: "image-to-video", + src: "https://cdn.example.com/start.jpg", + prompt: "orbit the subject", + aspectRatio: "16:9", + speed: 1.5 + }, + expected: { + asset: { + type: "video", + src: generatedSrc, + prompt: "orbit the subject", + model: "shotstack-itv-mini", + options: { inputSrc: "https://cdn.example.com/start.jpg" }, + speed: 1.5 + } + } + }, + { + name: "text-to-speech", + asset: { + type: "text-to-speech", + text: "Welcome to the show", + voice: "Matthew", + language: "en-US", + newscaster: true, + volume: 0.8 + }, + expected: { + asset: { + type: "audio", + src: generatedSrc, + prompt: "Welcome to the show", + model: "polly-neural", + options: { voice: "Matthew", language: "en-US", newscaster: true }, + volume: 0.8 + }, + bindingPathMoves: { "asset.text": "asset.prompt" } + } + } + ])("migrates a $name asset", ({ asset, expected }) => { + expect(migrateLegacyGeneratedAsset(asset as Clip["asset"], generatedSrc)).toEqual(expected); + }); + + it("ignores media assets", () => { + expect(migrateLegacyGeneratedAsset({ type: "image", src: "https://cdn.example.com/source.jpg" }, generatedSrc)).toBeNull(); + }); +}); 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)); }); diff --git a/tests/toolbar.test.ts b/tests/toolbar.test.ts index 3fe96f68..79014776 100644 --- a/tests/toolbar.test.ts +++ b/tests/toolbar.test.ts @@ -40,6 +40,10 @@ jest.mock("../src/components/canvas/players/player", () => { }; }); +jest.mock("../src/components/canvas/shotstack-canvas", () => ({ + Canvas: class MockCanvas {} +})); + // Mock ShotstackEdit to prevent circular dependency issues jest.mock("../src/core/shotstack-edit", () => ({ ShotstackEdit: class MockShotstackEdit {} @@ -65,7 +69,8 @@ global.ResizeObserver = jest.fn().mockImplementation(() => ({ import { AssetToolbar } from "../src/core/ui/asset-toolbar"; import { CanvasToolbar } from "../src/core/ui/canvas-toolbar"; import { BUILT_IN_FONTS, FONT_SIZES } from "../src/core/ui/base-toolbar"; -import type { ToolbarButtonConfig } from "../src/core/ui/ui-controller"; +import { EditEvent } from "../src/core/events/edit-events"; +import { UIController, type ToolbarButtonConfig } from "../src/core/ui/ui-controller"; type MockPlayer = { clipConfiguration: Record; @@ -142,6 +147,7 @@ function createMockEdit(overrides: Record = {}) { getPlayerClip: jest.fn((): MockPlayer | null => null), getClip: jest.fn(() => null), getClipId: jest.fn(() => "mock-clip-id"), + getClipGenerationState: jest.fn(() => undefined), getResolvedClip: jest.fn(() => null), getResolvedClipById: jest.fn(() => null), getDocumentClip: jest.fn(() => ({ start: 0, length: 1 })), @@ -163,6 +169,7 @@ function createMockEdit(overrides: Record = {}) { canDeleteClip: jest.fn(() => true), getToolbarButtons: jest.fn((): ToolbarButtonConfig[] => []), getSelectedClipInfo: jest.fn((): { trackIndex: number; clipIndex: number } | null => null), + hasAssetGenerator: jest.fn(() => false), mergeFields: { getAll: jest.fn(() => []), register: jest.fn(), @@ -1920,6 +1927,27 @@ describe("Mode Toggle (Regression)", () => { * Fix: Changed query to use document.querySelectorAll instead of this.container */ describe("button discoverability for click handling", () => { + it.each([ + ["plain media without a generator", { type: "image", src: "https://example.com/image.jpg" }, false, false], + ["configured AI media without a generator", { type: "image", prompt: "a cat" }, false, true], + ["plain media with a generator", { type: "image", src: "https://example.com/image.jpg" }, true, true] + ])("marks %s as generative only when AI is available", (_name, asset, hasGenerator, expected) => { + const mockEdit = createMockEdit({ + getResolvedClip: jest.fn(() => ({ asset })), + hasAssetGenerator: jest.fn(() => hasGenerator) + }); + const ui = UIController.minimal(mockEdit as never); + const container = createTestContainer(); + container.innerHTML = '
'; + + mockEdit.events.trigger(EditEvent.ClipSelected, { trackIndex: 0, clipIndex: 0 }); + + expect(container.querySelector(".ss-toolbar-mode-toggle")?.hasAttribute("data-generative")).toBe(expected); + + ui.dispose(); + cleanupTestContainer(container); + }); + it("mode toggle buttons are discoverable via document.querySelectorAll after mount", async () => { const mockEdit = createMockEdit(); const { MediaToolbar } = await import("../src/core/ui/media-toolbar"); @@ -1936,6 +1964,22 @@ describe("Mode Toggle (Regression)", () => { cleanupTestContainer(container); }); + it("carries a generate segment, hidden until the controller marks the clip generative", async () => { + const mockEdit = createMockEdit(); + const { MediaToolbar } = await import("../src/core/ui/media-toolbar"); + const toolbar = new MediaToolbar(mockEdit as never); + const container = createTestContainer(); + + toolbar.mount(container); + + const toggle = container.querySelector(".ss-toolbar-mode-toggle"); + expect(toggle?.querySelector('.ss-toolbar-mode-btn[data-mode="generate"]')).not.toBeNull(); + expect(toggle?.hasAttribute("data-generative")).toBe(false); + + toolbar.dispose(); + cleanupTestContainer(container); + }); + it("mode toggle buttons have data-mode attribute for click handling", async () => { const mockEdit = createMockEdit(); const { MediaToolbar } = await import("../src/core/ui/media-toolbar"); @@ -1947,7 +1991,7 @@ describe("Mode Toggle (Regression)", () => { const buttons = container.querySelectorAll(".ss-toolbar-mode-btn"); buttons.forEach(btn => { const { mode } = (btn as HTMLElement).dataset; - expect(mode === "asset" || mode === "clip").toBe(true); + expect(mode === "asset" || mode === "clip" || mode === "generate").toBe(true); }); toolbar.dispose();