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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 0 additions & 6 deletions src/components/canvas/players/ai-icons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,3 @@ export const AI_ICON_LINE_PATHS: Record<AiIconType, string> = {
"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<string, AiIconType> = {
"text-to-image": "image",
"image-to-video": "video",
"text-to-speech": "mic"
};
7 changes: 5 additions & 2 deletions src/components/canvas/players/audio-player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<howler.Howl>(identifier, loadOptions);

Expand Down Expand Up @@ -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<howler.Howl>(audioAsset.src, loadOptions);
const { src } = audioAsset;
if (!src) return;
const loadOptions: pixi.UnresolvedAsset = { src, parser: AudioLoadParser.Name };
const audioResource = await this.edit.assetLoader.load<howler.Howl>(src, loadOptions);

if (!(audioResource instanceof howler.Howl)) {
throw new Error(`Invalid audio source '${audioAsset.src}'.`);
Expand Down
1 change: 1 addition & 0 deletions src/components/canvas/players/image-player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ export class ImagePlayer extends Player {
private async loadTexture(): Promise<void> {
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: {} };
Expand Down
80 changes: 39 additions & 41 deletions src/components/canvas/players/image-to-video-player.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -116,28 +112,30 @@ export class ImageToVideoPlayer extends Player {
super.dispose();
}

private async loadTexture(): Promise<void> {
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<pixi.Texture<pixi.ImageSource>>(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<boolean> {
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<pixi.Texture<pixi.ImageSource>>(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;
}
}
}
12 changes: 9 additions & 3 deletions src/components/canvas/players/player-factory.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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);
Expand All @@ -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":
Expand Down
1 change: 1 addition & 0 deletions src/components/canvas/players/video-player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ export class VideoPlayer extends Player {
private async loadVideo(): Promise<void> {
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.`);
Expand Down
24 changes: 14 additions & 10 deletions src/components/timeline/components/clip/clip-component.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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 = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="${AI_ICON_LINE_PATHS[aiIconType]}"/></svg>`;
} 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 = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="${AI_ICON_LINE_PATHS[aiIconType]}"/></svg>`;
} else {
this.iconEl.textContent = this.getAssetIcon(assetType);
}
}
}

Expand Down
14 changes: 14 additions & 0 deletions src/components/timeline/media-thumbnail-renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

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

Expand Down
11 changes: 7 additions & 4 deletions src/core/player-reconciler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;

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

Expand Down
Loading