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
52 changes: 46 additions & 6 deletions src/components/canvas/players/image-player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,38 @@ import { type Size } from "@layouts/geometry";
import { type ResolvedClip, type ImageAsset } from "@schemas";
import * as pixi from "pixi.js";

import { createPlaceholderGraphic } from "./placeholder-graphic";
import { Player, PlayerType } from "./player";

export class ImagePlayer extends Player {
private texture: pixi.Texture<pixi.ImageSource> | null;
private sprite: pixi.Sprite | null;
private placeholder: pixi.Graphics | null;

constructor(edit: Edit, clipConfiguration: ResolvedClip) {
super(edit, clipConfiguration, PlayerType.Image);

this.texture = null;
this.sprite = null;
this.placeholder = null;
}

public override async load(): Promise<void> {
await super.load();
await this.loadTexture();
try {
await this.loadTexture();
this.configureKeyframes();
} catch {
this.createFallbackGraphic();
}
}

private createFallbackGraphic(): void {
const displaySize = this.getDisplaySize();
this.clearPlaceholder();

this.placeholder = createPlaceholderGraphic(displaySize.width, displaySize.height);
this.contentContainer.addChild(this.placeholder);
this.configureKeyframes();
}

Expand All @@ -27,8 +43,9 @@ export class ImagePlayer extends Player {
}

public override dispose(): void {
super.dispose();
this.disposeTexture();
this.clearPlaceholder();
super.dispose();
}

public override getSize(): Size {
Expand All @@ -39,17 +56,31 @@ export class ImagePlayer extends Player {
};
}

return { width: this.sprite?.width ?? 0, height: this.sprite?.height ?? 0 };
if (this.sprite) {
return { width: this.sprite.width, height: this.sprite.height };
}

return this.placeholder ? this.getDisplaySize() : { width: 0, height: 0 };
}

public override getContentSize(): Size {
return { width: this.sprite?.width ?? 0, height: this.sprite?.height ?? 0 };
if (this.sprite) {
return { width: this.sprite.width, height: this.sprite.height };
}

return this.placeholder ? this.getDisplaySize() : { width: 0, height: 0 };
}

/** Reload the image asset when asset.src changes (e.g., merge field update) */
public override async reloadAsset(): Promise<void> {
this.disposeTexture();
await this.loadTexture();
this.clearPlaceholder();

try {
await this.loadTexture();
} catch {
this.createFallbackGraphic();
}
}

private async loadTexture(): Promise<void> {
Expand All @@ -63,11 +94,12 @@ export class ImagePlayer extends Player {
if (!(texture?.source instanceof pixi.ImageSource)) {
if (texture) {
texture.destroy(true);
// Asset unloading handled by ref counting in edit-session.unloadClipAssets()
await this.edit.assetLoader.rejectAsset(corsUrl);
}
throw new Error(`Invalid image source '${src}'.`);
}

this.clearPlaceholder();
this.texture = this.createCroppedTexture(texture);
this.sprite = new pixi.Sprite(this.texture);
this.contentContainer.addChild(this.sprite);
Expand All @@ -88,6 +120,14 @@ export class ImagePlayer extends Player {
this.texture = null;
}

private clearPlaceholder(): void {
if (this.placeholder) {
this.contentContainer.removeChild(this.placeholder);
this.placeholder.destroy();
this.placeholder = null;
}
}

public override supportsEdgeResize(): boolean {
return true;
}
Expand Down
14 changes: 5 additions & 9 deletions src/components/canvas/players/image-to-video-player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ export class ImageToVideoPlayer extends Player {
}

public override getSize(): Size {
const displaySize = this.getDisplaySize();

if (this.clipConfiguration.width && this.clipConfiguration.height) {
return {
width: this.clipConfiguration.width,
Expand All @@ -92,8 +94,8 @@ export class ImageToVideoPlayer extends Player {
}

return {
width: this.sprite?.width || this.edit.size.width,
height: this.sprite?.height || this.edit.size.height
width: this.sprite?.width || displaySize.width,
height: this.sprite?.height || displaySize.height
};
}

Expand All @@ -114,13 +116,6 @@ export class ImageToVideoPlayer extends Player {
super.dispose();
}

private getDisplaySize(): Size {
return {
width: this.clipConfiguration.width ?? this.edit.size.width,
height: this.clipConfiguration.height ?? this.edit.size.height
};
}

private async loadTexture(): Promise<void> {
const asset = this.clipConfiguration.asset as ImageToVideoAsset;
const { src } = asset;
Expand All @@ -132,6 +127,7 @@ export class ImageToVideoPlayer extends Player {
if (!(texture?.source instanceof pixi.ImageSource)) {
if (texture) {
texture.destroy(true);
await this.edit.assetLoader.rejectAsset(corsUrl);
}
throw new Error(`Invalid image source '${src}'.`);
}
Expand Down
5 changes: 2 additions & 3 deletions src/components/canvas/players/luma-player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,9 @@ export class LumaPlayer extends Player {

const isValidLumaSource = texture?.source instanceof pixi.ImageSource || texture?.source instanceof pixi.VideoSource;
if (!isValidLumaSource) {
// Clean up ref if texture loaded but has invalid source type
// (if texture was null, AssetLoader already decremented on failure)
if (texture) {
this.edit.assetLoader.decrementRef(identifier);
texture.destroy(true);
await this.edit.assetLoader.rejectAsset(identifier);
}
throw new Error(`Invalid luma source '${lumaAsset.src}'.`);
}
Expand Down
7 changes: 7 additions & 0 deletions src/components/canvas/players/player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,13 @@ export abstract class Player extends Entity {
return this.getSize();
}

protected getDisplaySize(): Size {
return {
width: this.clipConfiguration.width ?? this.edit.size.width,
height: this.clipConfiguration.height ?? this.edit.size.height
};
}

/** @internal */
public getContentContainer(): pixi.Container {
return this.contentContainer;
Expand Down
59 changes: 49 additions & 10 deletions src/components/canvas/players/video-player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@ import { type Size } from "@layouts/geometry";
import { type ResolvedClip, type VideoAsset } from "@schemas";
import * as pixi from "pixi.js";

import { createPlaceholderGraphic } from "./placeholder-graphic";
import { Player, PlayerType } from "./player";

export class VideoPlayer extends Player {
private texture: pixi.Texture<pixi.VideoSource> | null;
private sprite: pixi.Sprite | null;
private placeholder: pixi.Graphics | null;
private isPlaying: boolean;

private volumeKeyframeBuilder: KeyframeBuilder;
Expand All @@ -22,6 +24,7 @@ export class VideoPlayer extends Player {

this.texture = null;
this.sprite = null;
this.placeholder = null;
this.isPlaying = false;

const videoAsset = this.clipConfiguration.asset as VideoAsset;
Expand All @@ -34,7 +37,21 @@ export class VideoPlayer extends Player {

public override async load(): Promise<void> {
await super.load();
await this.loadVideo();
try {
await this.loadVideo();
this.configureKeyframes();
} catch (error) {
console.warn(`[VideoPlayer.load] FAILED clipId=${this.clipId}:`, error);
this.createFallbackGraphic();
}
}

private createFallbackGraphic(): void {
const { width, height } = this.getDisplaySize();
this.clearPlaceholder();

this.placeholder = createPlaceholderGraphic(width, height);
this.contentContainer.addChild(this.placeholder);
this.configureKeyframes();
}

Expand Down Expand Up @@ -98,11 +115,9 @@ export class VideoPlayer extends Player {
}

public override dispose(): void {
try {
super.dispose();
} finally {
this.disposeVideo();
}
this.disposeVideo();
this.clearPlaceholder();
super.dispose();
}

public override getSize(): Size {
Expand All @@ -113,7 +128,11 @@ export class VideoPlayer extends Player {
};
}

return { width: this.sprite?.width ?? 0, height: this.sprite?.height ?? 0 };
if (this.sprite) {
return { width: this.sprite.width, height: this.sprite.height };
}

return this.placeholder ? this.getDisplaySize() : { width: 0, height: 0 };
}

public override supportsEdgeResize(): boolean {
Expand All @@ -123,12 +142,20 @@ export class VideoPlayer extends Player {
/** Reload the video asset when asset.src changes (e.g., merge field update) */
public override async reloadAsset(): Promise<void> {
this.skipVideoUpdate = true;
this.disposeVideo();
await this.loadVideo();
this.isPlaying = false;
this.syncTimer = 0;
this.activeSyncTimer = 0;
this.skipVideoUpdate = false;

try {
this.disposeVideo();
this.clearPlaceholder();
await this.loadVideo();
} catch (error) {
console.warn(`[VideoPlayer.reloadAsset] FAILED clipId=${this.clipId}:`, error);
this.createFallbackGraphic();
} finally {
this.skipVideoUpdate = false;
}
}

public override reconfigureAfterRestore(): void {
Expand Down Expand Up @@ -157,6 +184,8 @@ export class VideoPlayer extends Player {
throw new Error(`Invalid video source '${src}'.`);
}

this.clearPlaceholder();

// Fix alpha channel rendering for WebM VP9 videos (PixiJS 8 auto-detection is buggy)
texture.source.alphaMode = "no-premultiply-alpha";

Expand Down Expand Up @@ -202,6 +231,16 @@ export class VideoPlayer extends Player {
}
}

private clearPlaceholder(): void {
if (!this.placeholder) {
return;
}

this.contentContainer.removeChild(this.placeholder);
this.placeholder.destroy();
this.placeholder = null;
}

public getVolume(): number {
return this.volumeKeyframeBuilder.getValue(this.getPlaybackTime());
}
Expand Down
4 changes: 4 additions & 0 deletions src/core/layout/fit-system.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ export interface SpriteTransform {
* - none: No scaling (returns 1)
*/
export function calculateFitScale(contentSize: Size, targetSize: Size, fit: FitMode): number {
if (contentSize.width === 0 || contentSize.height === 0) {
return 1;
}

const ratioX = targetSize.width / contentSize.width;
const ratioY = targetSize.height / contentSize.height;

Expand Down
40 changes: 32 additions & 8 deletions src/core/loaders/asset-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,27 +42,42 @@ export class AssetLoader {
pixi.Assets.setPreferences({ crossOrigin: "anonymous" });
}

/**
* Release an asset that was loaded successfully but rejected by the caller
* (e.g. returned a non-image texture). Decrements the ref count and removes
* the stale entry from the PixiJS Assets cache to prevent GL corruption.
*/
public async rejectAsset(identifier: string): Promise<void> {
console.warn(`[AssetLoader.rejectAsset] Rejected invalid asset "${identifier}".`);
await this.cleanupFailedLoad(identifier);
}

public async load<TResolvedAsset>(identifier: string, loadOptions: pixi.UnresolvedAsset): Promise<TResolvedAsset | null> {
this.updateAssetLoadMetadata(identifier, "pending", 0);
this.incrementRef(identifier);

try {
const useSafari = await this.shouldUseSafariVideoLoader(loadOptions);

if (useSafari) {
return await this.loadVideoForSafari<TResolvedAsset>(identifier, loadOptions);
const resolvedAsset = useSafari
? await this.loadVideoForSafari<TResolvedAsset>(identifier, loadOptions)
: await pixi.Assets.load<TResolvedAsset>(loadOptions, progress => {
this.updateAssetLoadMetadata(identifier, "loading", progress);
});

if (resolvedAsset == null) {
console.warn(`[AssetLoader.load] Empty asset returned for "${identifier}"`);
this.updateAssetLoadMetadata(identifier, "failed", 1);
await this.cleanupFailedLoad(identifier);
return null;
}

const resolvedAsset = await pixi.Assets.load<TResolvedAsset>(loadOptions, progress => {
this.updateAssetLoadMetadata(identifier, "loading", progress);
});

this.updateAssetLoadMetadata(identifier, "success", 1);
return resolvedAsset;
} catch (error) {
console.warn(`[AssetLoader] Failed to load asset "${identifier}":`, error);
console.warn(`[AssetLoader.load] Failed to load "${identifier}":`, error);
this.updateAssetLoadMetadata(identifier, "failed", 1);
this.decrementRef(identifier);
await this.cleanupFailedLoad(identifier);
return null;
}
}
Expand Down Expand Up @@ -130,6 +145,15 @@ export class AssetLoader {
return totalProgress / identifiers.length;
}

private async cleanupFailedLoad(identifier: string): Promise<void> {
this.decrementRef(identifier);
try {
await pixi.Assets.unload(identifier);
} catch {
// Ignore unload errors for already-failed assets
}
}

private extractUrl(opts: pixi.UnresolvedAsset): string | undefined {
if (typeof opts === "string") return opts;
const src = Array.isArray(opts.src) ? opts.src[0] : opts.src;
Expand Down
Loading
Loading