diff --git a/package.json b/package.json index ed2994e..88cf1d0 100644 --- a/package.json +++ b/package.json @@ -99,8 +99,8 @@ "vite-plugin-dts": "^4.5.4" }, "dependencies": { - "@shotstack/schemas": "1.11.0", - "@shotstack/shotstack-canvas": "^2.10.0", + "@shotstack/schemas": "1.13.1", + "@shotstack/shotstack-canvas": "^2.10.2", "howler": "^2.2.4", "mediabunny": "^1.11.2", "opentype.js": "^1.3.4", diff --git a/src/components/canvas/players/ai-icons.ts b/src/components/canvas/players/ai-icons.ts index 881aaec..89d48ab 100644 --- a/src/components/canvas/players/ai-icons.ts +++ b/src/components/canvas/players/ai-icons.ts @@ -17,9 +17,3 @@ export const AI_ICON_LINE_PATHS: Record = { "M19.7134 8.12811L19.4668 8.69379C19.2864 9.10792 18.7136 9.10792 18.5331 8.69379L18.2866 8.12811C17.8471 7.11947 17.0555 6.31641 16.0677 5.87708L15.308 5.53922C14.8973 5.35653 14.8973 4.75881 15.308 4.57612L16.0252 4.25714C17.0384 3.80651 17.8442 2.97373 18.2761 1.93083L18.5293 1.31953C18.7058 0.893489 19.2942 0.893489 19.4706 1.31953L19.7238 1.93083C20.1558 2.97373 20.9616 3.80651 21.9748 4.25714L22.6919 4.57612C23.1027 4.75881 23.1027 5.35653 22.6919 5.53922L21.9323 5.87708C20.9445 6.31641 20.1529 7.11947 19.7134 8.12811ZM3.9934 3H13V5H5V19H19V11H21V20.0066C21 20.5552 20.5551 21 20.0066 21H3.9934C3.44476 21 3 20.5551 3 20.0066V3.9934C3 3.44476 3.44495 3 3.9934 3ZM10.6219 8.41459L15.5008 11.6672C15.6846 11.7897 15.7343 12.0381 15.6117 12.2219C15.5824 12.2658 15.5447 12.3035 15.5008 12.3328L10.6219 15.5854C10.4381 15.708 10.1897 15.6583 10.0672 15.4745C10.0234 15.4088 10 15.3316 10 15.2526V8.74741C10 8.52649 10.1791 8.34741 10.4 8.34741C10.479 8.34741 10.5562 8.37078 10.6219 8.41459Z", mic: "M20.4668 7.69379L20.7134 7.12811C21.1529 6.11947 21.9445 5.31641 22.9323 4.87708L23.6919 4.53922C24.1027 4.35653 24.1027 3.75881 23.6919 3.57612L22.9748 3.25714C21.9616 2.80651 21.1558 1.97373 20.7238 0.930828L20.4706 0.319534C20.2942 -0.106511 19.7058 -0.106511 19.5293 0.319534L19.2761 0.930828C18.8442 1.97373 18.0384 2.80651 17.0252 3.25714L16.308 3.57612C15.8973 3.75881 15.8973 4.35653 16.308 4.53922L17.0677 4.87708C18.0555 5.31641 18.8471 6.11947 19.2866 7.12811L19.5331 7.69379C19.7136 8.10792 20.2864 8.10792 20.4668 7.69379ZM3.05469 11H5.07065C5.55588 14.3923 8.47329 17 11.9998 17C15.5262 17 18.4436 14.3923 18.9289 11H20.9448C20.4837 15.1716 17.1714 18.4839 12.9998 18.9451V23H10.9998V18.9451C6.82814 18.4839 3.51584 15.1716 3.05469 11ZM12 1C9.23858 1 7 3.23858 7 6V10C7 12.7614 9.23858 15 12 15C14.7614 15 17 12.7614 17 10V7H15V10C15 11.6569 13.6569 13 12 13C10.3431 13 9 11.6569 9 10V6C9 4.34315 10.3431 3 12 3C12.5972 3 13.1509 3.17349 13.617 3.47248L14.6969 1.7891C13.9182 1.28957 12.9914 1 12 1Z" }; - -export const AI_ASSET_ICON_MAP: Record = { - "text-to-image": "image", - "image-to-video": "video", - "text-to-speech": "mic" -}; diff --git a/src/components/canvas/players/audio-player.ts b/src/components/canvas/players/audio-player.ts index 1ff7672..d710caf 100644 --- a/src/components/canvas/players/audio-player.ts +++ b/src/components/canvas/players/audio-player.ts @@ -30,6 +30,7 @@ export class AudioPlayer extends Player { const audioClipConfiguration = this.clipConfiguration.asset as AudioAsset; const identifier = audioClipConfiguration.src; + if (!identifier) return; const loadOptions: pixi.UnresolvedAsset = { src: identifier, parser: AudioLoadParser.Name }; const audioResource = await this.edit.assetLoader.load(identifier, loadOptions); @@ -123,8 +124,10 @@ export class AudioPlayer extends Player { this.syncTimer = 0; const audioAsset = this.clipConfiguration.asset as AudioAsset; - const loadOptions: pixi.UnresolvedAsset = { src: audioAsset.src, parser: AudioLoadParser.Name }; - const audioResource = await this.edit.assetLoader.load(audioAsset.src, loadOptions); + const { src } = audioAsset; + if (!src) return; + const loadOptions: pixi.UnresolvedAsset = { src, parser: AudioLoadParser.Name }; + const audioResource = await this.edit.assetLoader.load(src, loadOptions); if (!(audioResource instanceof howler.Howl)) { throw new Error(`Invalid audio source '${audioAsset.src}'.`); diff --git a/src/components/canvas/players/image-player.ts b/src/components/canvas/players/image-player.ts index c937ee5..f472ee5 100644 --- a/src/components/canvas/players/image-player.ts +++ b/src/components/canvas/players/image-player.ts @@ -86,6 +86,7 @@ export class ImagePlayer extends Player { private async loadTexture(): Promise { const imageAsset = this.clipConfiguration.asset as ImageAsset; const { src } = imageAsset; + if (!src) return; const corsUrl = `${src}${src.includes("?") ? "&" : "?"}x-cors=1`; const loadOptions: pixi.UnresolvedAsset = { src: corsUrl, crossorigin: "anonymous", data: {} }; diff --git a/src/components/canvas/players/image-to-video-player.ts b/src/components/canvas/players/image-to-video-player.ts index f4a1402..d377f13 100644 --- a/src/components/canvas/players/image-to-video-player.ts +++ b/src/components/canvas/players/image-to-video-player.ts @@ -1,7 +1,7 @@ import type { Edit } from "@core/edit-session"; import { computeAiAssetNumber, isAiAsset } from "@core/shared/ai-asset-utils"; import { type Size } from "@layouts/geometry"; -import { type ResolvedClip, type ImageToVideoAsset } from "@schemas"; +import { type ResolvedClip } from "@schemas"; import * as pixi from "pixi.js"; import { AiPendingOverlay } from "./ai-pending-overlay"; @@ -32,31 +32,27 @@ export class ImageToVideoPlayer extends Player { const prompt = isAiAsset(asset) ? asset.prompt || "" : ""; const assetType = isAiAsset(asset) ? asset.type : "image-to-video"; - try { - await this.loadTexture(); - this.aiOverlay = new AiPendingOverlay({ - mode: "badge", - icon: "video", - width: displaySize.width, - height: displaySize.height, - assetNumber: assetNumber ?? undefined, - prompt, - assetType - }); - } catch { + // 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; + const loaded = inputImage ? await this.tryLoadTexture(inputImage) : false; + + if (!loaded) { this.placeholder = createPlaceholderGraphic(displaySize.width, displaySize.height); this.contentContainer.addChild(this.placeholder); - this.aiOverlay = new AiPendingOverlay({ - mode: "panel", - icon: "video", - width: displaySize.width, - height: displaySize.height, - assetNumber: assetNumber ?? undefined, - prompt, - assetType - }); } + this.aiOverlay = new AiPendingOverlay({ + mode: loaded ? "badge" : "panel", + icon: "video", + width: displaySize.width, + height: displaySize.height, + assetNumber: assetNumber ?? undefined, + prompt, + assetType + }); + this.contentContainer.addChild(this.aiOverlay.getContainer()); this.configureKeyframes(); } @@ -116,28 +112,30 @@ export class ImageToVideoPlayer extends Player { super.dispose(); } - private async loadTexture(): Promise { - const asset = this.clipConfiguration.asset as ImageToVideoAsset; - const { src } = asset; - - const corsUrl = `${src}${src.includes("?") ? "&" : "?"}x-cors=1`; - const loadOptions: pixi.UnresolvedAsset = { src: corsUrl, crossorigin: "anonymous", data: {} }; - const texture = await this.edit.assetLoader.load>(corsUrl, loadOptions); - - if (!(texture?.source instanceof pixi.ImageSource)) { - if (texture) { - texture.destroy(true); - await this.edit.assetLoader.rejectAsset(corsUrl); + private async tryLoadTexture(src: string): Promise { + try { + const corsUrl = `${src}${src.includes("?") ? "&" : "?"}x-cors=1`; + const loadOptions: pixi.UnresolvedAsset = { src: corsUrl, crossorigin: "anonymous", data: {} }; + const texture = await this.edit.assetLoader.load>(corsUrl, loadOptions); + + if (!(texture?.source instanceof pixi.ImageSource)) { + if (texture) { + texture.destroy(true); + await this.edit.assetLoader.rejectAsset(corsUrl); + } + return false; } - throw new Error(`Invalid image source '${src}'.`); - } - this.texture = texture; - this.sprite = new pixi.Sprite(this.texture); - this.contentContainer.addChild(this.sprite); + this.texture = texture; + this.sprite = new pixi.Sprite(this.texture); + this.contentContainer.addChild(this.sprite); - if (this.clipConfiguration.width && this.clipConfiguration.height) { - this.applyFixedDimensions(); + if (this.clipConfiguration.width && this.clipConfiguration.height) { + this.applyFixedDimensions(); + } + return true; + } catch { + return false; } } } diff --git a/src/components/canvas/players/player-factory.ts b/src/components/canvas/players/player-factory.ts index 2b3bfb1..233bb58 100644 --- a/src/components/canvas/players/player-factory.ts +++ b/src/components/canvas/players/player-factory.ts @@ -1,4 +1,5 @@ import type { Edit } from "@core/edit-session"; +import { isPendingAiAsset } from "@core/shared/ai-asset-utils"; import type { ResolvedClip } from "@schemas"; import { AudioPlayer } from "./audio-player"; @@ -27,6 +28,11 @@ export class PlayerFactory { throw new Error("Invalid clip configuration: missing asset type"); } + // Prompt-bearing media assets awaiting generation (no src yet) render as + // pending placeholders; once realisation fills src the reconciler + // recreates them as regular media players. + const pending = isPendingAiAsset(clipConfiguration.asset); + switch (clipConfiguration.asset.type) { case "text": return new TextPlayer(edit, clipConfiguration); @@ -39,11 +45,11 @@ export class PlayerFactory { case "html5": return new Html5Player(edit, clipConfiguration); case "image": - return new ImagePlayer(edit, clipConfiguration); + return pending ? new TextToImagePlayer(edit, clipConfiguration) : new ImagePlayer(edit, clipConfiguration); case "video": - return new VideoPlayer(edit, clipConfiguration); + return pending ? new ImageToVideoPlayer(edit, clipConfiguration) : new VideoPlayer(edit, clipConfiguration); case "audio": - return new AudioPlayer(edit, clipConfiguration); + return pending ? new TextToSpeechPlayer(edit, clipConfiguration) : new AudioPlayer(edit, clipConfiguration); case "luma": return new LumaPlayer(edit, clipConfiguration); case "caption": diff --git a/src/components/canvas/players/video-player.ts b/src/components/canvas/players/video-player.ts index 6c64f8a..f8406b3 100644 --- a/src/components/canvas/players/video-player.ts +++ b/src/components/canvas/players/video-player.ts @@ -168,6 +168,7 @@ export class VideoPlayer extends Player { private async loadVideo(): Promise { const videoAsset = this.clipConfiguration.asset as VideoAsset; const { src } = videoAsset; + if (!src) return; if (src.endsWith(".mov")) { throw new Error(`Video source '${src}' is not supported. .mov files cannot be played in the browser. Please convert to .webm or .mp4 first.`); diff --git a/src/components/timeline/components/clip/clip-component.ts b/src/components/timeline/components/clip/clip-component.ts index 9b080b8..4ae197b 100644 --- a/src/components/timeline/components/clip/clip-component.ts +++ b/src/components/timeline/components/clip/clip-component.ts @@ -1,7 +1,7 @@ -import { getAiAssetTypeLabel, isAiAsset, type ResolvedClipWithId } from "@core/shared/ai-asset-utils"; +import { aiAssetKind, getAiAssetTypeLabel, isAiAsset, type ResolvedClipWithId } from "@core/shared/ai-asset-utils"; import type { ResolvedClip } from "@schemas"; -import { AI_ICON_LINE_PATHS, AI_ASSET_ICON_MAP } from "../../../canvas/players/ai-icons"; +import { AI_ICON_LINE_PATHS } from "../../../canvas/players/ai-icons"; import { formatClipErrorMessage } from "../../error-messages"; import type { ClipState, ClipRenderer } from "../../timeline.types"; @@ -161,14 +161,18 @@ export class ClipComponent { this.element.classList.toggle("resizing", clip.visualState === "resizing"); this.element.classList.toggle("focused", clip.isFocused); - // Update icon (using cached reference) - if (this.iconEl && this.iconEl.dataset["assetType"] !== assetType) { - this.iconEl.dataset["assetType"] = assetType; - const aiIconType = AI_ASSET_ICON_MAP[assetType]; - if (aiIconType) { - this.iconEl.innerHTML = ``; - } else { - this.iconEl.textContent = this.getAssetIcon(assetType); + // Update icon (using cached reference); AI styling is per-asset, not + // per-type — a prompt-bearing image is AI, a plain image is not + if (this.iconEl) { + const aiIconType = aiAssetKind(config.asset); + const iconKey = aiIconType ? `${assetType}:ai` : assetType; + if (this.iconEl.dataset["assetType"] !== iconKey) { + this.iconEl.dataset["assetType"] = iconKey; + if (aiIconType) { + this.iconEl.innerHTML = ``; + } else { + this.iconEl.textContent = this.getAssetIcon(assetType); + } } } diff --git a/src/components/timeline/media-thumbnail-renderer.ts b/src/components/timeline/media-thumbnail-renderer.ts index 3a8487d..ef3a43a 100644 --- a/src/components/timeline/media-thumbnail-renderer.ts +++ b/src/components/timeline/media-thumbnail-renderer.ts @@ -126,6 +126,13 @@ export class MediaThumbnailRenderer implements ClipRenderer { const state: ThumbnailState = { loading: true, thumbnails: [], thumbnailWidth: 0, failed: false }; this.clipStates.set(clipKey, state); + if (!asset.src) { + state.loading = false; + state.failed = true; + this.onRendered(); + return; + } + try { const result = await this.generator.generateThumbnail(asset.src, asset.trim ?? 0); @@ -155,6 +162,13 @@ export class MediaThumbnailRenderer implements ClipRenderer { const state: ThumbnailState = { loading: true, thumbnails: [], thumbnailWidth: 0, failed: false }; this.clipStates.set(clipKey, state); + if (!asset.src) { + state.loading = false; + state.failed = true; + this.onRendered(); + return; + } + try { const result = await this.loadImageThumbnail(asset.src); diff --git a/src/core/player-reconciler.ts b/src/core/player-reconciler.ts index dee4f6e..82c41de 100644 --- a/src/core/player-reconciler.ts +++ b/src/core/player-reconciler.ts @@ -13,6 +13,7 @@ import type { ResolvedClip, ResolvedEdit } from "@schemas"; import type { Edit } from "./edit-session"; import { EditEvent, InternalEvent } from "./events/edit-events"; +import { isPendingAiAsset } from "./shared/ai-asset-utils"; import type { Seconds } from "./timing/types"; export interface ReconcileResult { @@ -25,8 +26,6 @@ export interface ReconcileResult { /** Properties handled by dedicated checks in updatePlayer — skip in generic diff/patch */ const HANDLED_PROPS = new Set(["asset", "start", "length", "id"]); -const AI_ASSET_TYPES = new Set(["text-to-image", "image-to-video", "text-to-speech"]); - export class PlayerReconciler { private isReconciling = false; @@ -218,8 +217,8 @@ export class PlayerReconciler { clipIndex }); - // Also emit ClipUnresolved for AI assets - if (AI_ASSET_TYPES.has(assetType)) { + // Also emit ClipUnresolved for AI assets awaiting generation + if (isPendingAiAsset(clip.asset)) { this.edit.getInternalEvents().emit(EditEvent.ClipUnresolved, { trackIndex, clipIndex, @@ -265,6 +264,10 @@ export class PlayerReconciler { if (currentAssetType !== newAssetType) return "recreate"; + // A pending-generation flip (prompt-only asset realised with src, or src + // cleared back to prompt-only) changes the player class - recreate + if (isPendingAiAsset(player.clipConfiguration.asset) !== isPendingAiAsset(clip.asset)) return "recreate"; + let changed = false; const currentTrackIndex = player.layer - 1; diff --git a/src/core/shared/ai-asset-utils.ts b/src/core/shared/ai-asset-utils.ts index fa8dd21..72615d7 100644 --- a/src/core/shared/ai-asset-utils.ts +++ b/src/core/shared/ai-asset-utils.ts @@ -2,15 +2,33 @@ import type { ResolvedClip } from "@schemas"; export const AI_ASSET_TYPES = new Set(["text-to-image", "image-to-video", "text-to-speech"]); +const PROMPTABLE_MEDIA_TYPES = new Set(["image", "video", "audio"]); + /** * AI asset type definition */ export interface AiAsset { - type: "text-to-image" | "image-to-video" | "text-to-speech"; + type: string; prompt?: string; src?: string; } +/** Visual kind of an AI asset; doubles as the overlay/timeline icon key. */ +export type AiMediaKind = "image" | "video" | "mic"; + +const AI_KIND_BY_TYPE: Record = { + "text-to-image": "image", + "image-to-video": "video", + "text-to-speech": "mic", + image: "image", + video: "video", + audio: "mic" +}; + +function hasText(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0; +} + /** * Extended clip type with ID property */ @@ -19,16 +37,36 @@ export interface ResolvedClipWithId extends ResolvedClip { } /** - * Type guard to check if an asset is an AI asset + * Type guard to check if an asset is AI-generative: a legacy generative type + * (text-to-image, image-to-video, text-to-speech) or a media asset (image, + * video, audio) carrying a prompt. Remains true after realisation fills `src`. */ export function isAiAsset(asset: unknown): asset is AiAsset { - return ( - typeof asset === "object" && - asset !== null && - "type" in asset && - typeof (asset as { type: unknown }).type === "string" && - AI_ASSET_TYPES.has((asset as { type: string }).type) - ); + if (typeof asset !== "object" || asset === null || !("type" in asset)) return false; + const { type } = asset as { type: unknown }; + if (typeof type !== "string") return false; + + if (AI_ASSET_TYPES.has(type)) return true; + return PROMPTABLE_MEDIA_TYPES.has(type) && hasText((asset as AiAsset).prompt); +} + +/** + * Whether an AI asset is still awaiting generation. Legacy generative types + * are always pending (realisation replaces them with a media type); a + * prompt-bearing media asset is pending until generation fills `src`. + */ +export function isPendingAiAsset(asset: unknown): asset is AiAsset { + if (!isAiAsset(asset)) return false; + if (AI_ASSET_TYPES.has(asset.type)) return true; + return !hasText(asset.src); +} + +/** + * Visual kind of an AI asset (image, video, mic), or null for non-AI assets. + */ +export function aiAssetKind(asset: unknown): AiMediaKind | null { + if (!isAiAsset(asset)) return null; + return AI_KIND_BY_TYPE[asset.type] ?? null; } /** @@ -68,16 +106,18 @@ export function computeAiAssetNumber(allClips: ResolvedClip[], clipId: string): if (!clip.asset || !isAiAsset(clip.asset)) return null; - const assetType = clip.asset.type; + // Number by visual kind so legacy generative types and prompt-bearing + // media assets of the same kind share one sequence (no duplicate "Image 1") + const kind = aiAssetKind(clip.asset); // Get sorted clips const sortedClips = getSortedClips(allClips); - // Count clips of same type that appear before this one chronologically + // Count clips of same kind that appear before this one chronologically let count = 0; for (const c of sortedClips) { if (hasId(c) && c.id === clipId) break; - if (c.asset && isAiAsset(c.asset) && c.asset.type === assetType) { + if (c.asset && aiAssetKind(c.asset) === kind) { count += 1; } } @@ -143,7 +183,10 @@ export function getAiAssetTypeLabel(assetType: string): string { const labels: Record = { "text-to-image": "Image", "image-to-video": "Video", - "text-to-speech": "Audio" + "text-to-speech": "Audio", + image: "Image", + video: "Video", + audio: "Audio" }; return labels[assetType] || assetType; } diff --git a/src/main.ts b/src/main.ts index 5827087..6a8d4e8 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,7 +1,7 @@ import { type Edit as EditSchema } from "@schemas"; import { Timeline } from "@timeline/index"; -import template from "./templates/html5-bundle-demo.json"; +import template from "./templates/prompt-assets.json"; import { Edit, Canvas, Controls, UIController } from "./index"; diff --git a/src/templates/prompt-assets.json b/src/templates/prompt-assets.json new file mode 100644 index 0000000..30ad535 --- /dev/null +++ b/src/templates/prompt-assets.json @@ -0,0 +1,101 @@ +{ + "timeline": { + "background": "#111111", + "tracks": [ + { + "clips": [ + { + "asset": { + "type": "image", + "prompt": "A golden sunset over a calm ocean with silhouetted palm trees" + }, + "start": 0, + "length": 5, + "width": 720, + "height": 405 + }, + { + "asset": { + "type": "video", + "prompt": "Slowly zoom out and orbit left around the trees", + "seed": "https://shotstack-assets.s3.amazonaws.com/images/woods1.jpg" + }, + "start": 5, + "length": 5, + "fit": "crop" + }, + { + "asset": { + "type": "video", + "prompt": "Aerial drone shot flying over a misty mountain range at dawn" + }, + "start": 10, + "length": 4 + }, + { + "asset": { + "type": "image", + "prompt": "A romantic holiday scene, already generated", + "src": "https://templates.shotstack.io/holiday-love-story-template-romantic-memories-video/04c8d948-dd60-493f-b02b-f928f0c14590/source.png" + }, + "start": 14, + "length": 4 + }, + { + "asset": { + "type": "text-to-image", + "prompt": "Legacy text-to-image asset sharing the Image numbering sequence" + }, + "start": 18, + "length": 4, + "width": 512, + "height": 512 + } + ] + }, + { + "clips": [ + { + "asset": { + "type": "image", + "src": "https://templates.shotstack.io/holiday-love-story-template-romantic-memories-video/0901b167-9a7e-4031-a423-1f4fbb11ec2c/source.png" + }, + "start": 0, + "length": 22, + "fit": "cover", + "opacity": 0.35 + } + ] + }, + { + "clips": [ + { + "asset": { + "type": "audio", + "prompt": "Calm ambient piano with soft ocean waves" + }, + "start": 0, + "length": 10 + }, + { + "asset": { + "type": "audio", + "prompt": "Welcome to the unified prompt asset demo.", + "voice": "Matthew" + }, + "start": 10, + "length": 10 + } + ] + } + ] + }, + "output": { + "format": "mp4", + "fps": 25, + "size": { + "width": 1280, + "height": 720 + } + } +} diff --git a/tests/ai-asset-utils.test.ts b/tests/ai-asset-utils.test.ts new file mode 100644 index 0000000..b25d689 --- /dev/null +++ b/tests/ai-asset-utils.test.ts @@ -0,0 +1,106 @@ +import { isAiAsset, isPendingAiAsset, aiAssetKind, computeAiAssetNumber, getAiAssetTypeLabel } from "@core/shared/ai-asset-utils"; +import type { ResolvedClip } from "@schemas"; + +describe("ai-asset-utils", () => { + describe("isAiAsset", () => { + it("accepts legacy generative types", () => { + expect(isAiAsset({ type: "text-to-image", prompt: "a cat" })).toBe(true); + expect(isAiAsset({ type: "image-to-video", src: "https://cdn/input.png" })).toBe(true); + expect(isAiAsset({ type: "text-to-speech", prompt: "hello" })).toBe(true); + }); + + 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: "audio", prompt: "calm piano" })).toBe(true); + }); + + it("stays true after realisation fills src", () => { + expect(isAiAsset({ type: "image", prompt: "a cat", src: "https://cdn/cat.png" })).toBe(true); + }); + + it("rejects plain media and non-media assets", () => { + expect(isAiAsset({ type: "image", src: "https://cdn/cat.png" })).toBe(false); + expect(isAiAsset({ type: "video", src: "https://cdn/clip.mp4" })).toBe(false); + expect(isAiAsset({ type: "shape", shape: "rectangle" })).toBe(false); + expect(isAiAsset(null)).toBe(false); + }); + + it("treats a whitespace-only prompt as absent", () => { + expect(isAiAsset({ type: "image", prompt: " " })).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: "audio", prompt: "calm piano" })).toBe(true); + }); + + it("is not pending once src is filled in place", () => { + expect(isPendingAiAsset({ type: "image", prompt: "a cat", src: "https://cdn/cat.png" })).toBe(false); + expect(isPendingAiAsset({ type: "audio", prompt: "calm piano", src: "https://cdn/piano.mp3" })).toBe(false); + }); + + it("legacy generative types are always pending", () => { + expect(isPendingAiAsset({ type: "text-to-image", prompt: "a cat" })).toBe(true); + expect(isPendingAiAsset({ type: "image-to-video", src: "https://cdn/input.png" })).toBe(true); + }); + + it("is false for plain media assets", () => { + expect(isPendingAiAsset({ type: "image", src: "https://cdn/cat.png" })).toBe(false); + expect(isPendingAiAsset({ type: "image" })).toBe(false); + }); + }); + + describe("aiAssetKind", () => { + it("maps legacy and unified types to the same kinds", () => { + expect(aiAssetKind({ type: "text-to-image", prompt: "x" })).toBe("image"); + expect(aiAssetKind({ type: "image", prompt: "x" })).toBe("image"); + expect(aiAssetKind({ type: "image-to-video", src: "s" })).toBe("video"); + expect(aiAssetKind({ type: "video", prompt: "x" })).toBe("video"); + expect(aiAssetKind({ type: "text-to-speech", prompt: "x" })).toBe("mic"); + expect(aiAssetKind({ type: "audio", prompt: "x" })).toBe("mic"); + }); + + it("returns null for non-AI assets", () => { + expect(aiAssetKind({ type: "image", src: "https://cdn/cat.png" })).toBe(null); + expect(aiAssetKind({ type: "text", text: "hi" })).toBe(null); + }); + }); + + describe("computeAiAssetNumber", () => { + const clip = (id: string, start: number, asset: object): ResolvedClip => ({ id, start, length: 1, asset }) as unknown as ResolvedClip; + + it("numbers legacy and prompt-bearing assets of one kind in a single sequence", () => { + const clips = [ + clip("a", 0, { type: "text-to-image", prompt: "first" }), + clip("b", 1, { type: "image", prompt: "second" }), + clip("c", 2, { type: "image", prompt: "third", src: "https://cdn/done.png" }), + clip("d", 3, { type: "audio", prompt: "music" }) + ]; + + expect(computeAiAssetNumber(clips, "a")).toBe(1); + expect(computeAiAssetNumber(clips, "b")).toBe(2); + expect(computeAiAssetNumber(clips, "c")).toBe(3); + expect(computeAiAssetNumber(clips, "d")).toBe(1); + }); + + it("skips plain media assets and returns null for them", () => { + const clips = [clip("a", 0, { type: "image", src: "https://cdn/plain.png" }), clip("b", 1, { type: "image", prompt: "generated" })]; + + expect(computeAiAssetNumber(clips, "a")).toBe(null); + expect(computeAiAssetNumber(clips, "b")).toBe(1); + }); + }); + + describe("getAiAssetTypeLabel", () => { + it("labels unified media types like their legacy counterparts", () => { + expect(getAiAssetTypeLabel("image")).toBe("Image"); + expect(getAiAssetTypeLabel("video")).toBe("Video"); + expect(getAiAssetTypeLabel("audio")).toBe("Audio"); + expect(getAiAssetTypeLabel("text-to-image")).toBe("Image"); + }); + }); +});