Skip to content

Commit 3712481

Browse files
committed
fix: improve player asset loading and resolution
1 parent 5386daf commit 3712481

12 files changed

Lines changed: 1608 additions & 39 deletions

src/components/canvas/players/image-player.ts

Lines changed: 46 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,22 +3,38 @@ import { type Size } from "@layouts/geometry";
33
import { type ResolvedClip, type ImageAsset } from "@schemas";
44
import * as pixi from "pixi.js";
55

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

89
export class ImagePlayer extends Player {
910
private texture: pixi.Texture<pixi.ImageSource> | null;
1011
private sprite: pixi.Sprite | null;
12+
private placeholder: pixi.Graphics | null;
1113

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

1517
this.texture = null;
1618
this.sprite = null;
19+
this.placeholder = null;
1720
}
1821

1922
public override async load(): Promise<void> {
2023
await super.load();
21-
await this.loadTexture();
24+
try {
25+
await this.loadTexture();
26+
this.configureKeyframes();
27+
} catch {
28+
this.createFallbackGraphic();
29+
}
30+
}
31+
32+
private createFallbackGraphic(): void {
33+
const displaySize = this.getDisplaySize();
34+
this.clearPlaceholder();
35+
36+
this.placeholder = createPlaceholderGraphic(displaySize.width, displaySize.height);
37+
this.contentContainer.addChild(this.placeholder);
2238
this.configureKeyframes();
2339
}
2440

@@ -27,8 +43,9 @@ export class ImagePlayer extends Player {
2743
}
2844

2945
public override dispose(): void {
30-
super.dispose();
3146
this.disposeTexture();
47+
this.clearPlaceholder();
48+
super.dispose();
3249
}
3350

3451
public override getSize(): Size {
@@ -39,17 +56,31 @@ export class ImagePlayer extends Player {
3956
};
4057
}
4158

42-
return { width: this.sprite?.width ?? 0, height: this.sprite?.height ?? 0 };
59+
if (this.sprite) {
60+
return { width: this.sprite.width, height: this.sprite.height };
61+
}
62+
63+
return this.placeholder ? this.getDisplaySize() : { width: 0, height: 0 };
4364
}
4465

4566
public override getContentSize(): Size {
46-
return { width: this.sprite?.width ?? 0, height: this.sprite?.height ?? 0 };
67+
if (this.sprite) {
68+
return { width: this.sprite.width, height: this.sprite.height };
69+
}
70+
71+
return this.placeholder ? this.getDisplaySize() : { width: 0, height: 0 };
4772
}
4873

4974
/** Reload the image asset when asset.src changes (e.g., merge field update) */
5075
public override async reloadAsset(): Promise<void> {
5176
this.disposeTexture();
52-
await this.loadTexture();
77+
this.clearPlaceholder();
78+
79+
try {
80+
await this.loadTexture();
81+
} catch {
82+
this.createFallbackGraphic();
83+
}
5384
}
5485

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

102+
this.clearPlaceholder();
71103
this.texture = this.createCroppedTexture(texture);
72104
this.sprite = new pixi.Sprite(this.texture);
73105
this.contentContainer.addChild(this.sprite);
@@ -88,6 +120,14 @@ export class ImagePlayer extends Player {
88120
this.texture = null;
89121
}
90122

123+
private clearPlaceholder(): void {
124+
if (this.placeholder) {
125+
this.contentContainer.removeChild(this.placeholder);
126+
this.placeholder.destroy();
127+
this.placeholder = null;
128+
}
129+
}
130+
91131
public override supportsEdgeResize(): boolean {
92132
return true;
93133
}

src/components/canvas/players/image-to-video-player.ts

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,8 @@ export class ImageToVideoPlayer extends Player {
8484
}
8585

8686
public override getSize(): Size {
87+
const displaySize = this.getDisplaySize();
88+
8789
if (this.clipConfiguration.width && this.clipConfiguration.height) {
8890
return {
8991
width: this.clipConfiguration.width,
@@ -92,8 +94,8 @@ export class ImageToVideoPlayer extends Player {
9294
}
9395

9496
return {
95-
width: this.sprite?.width || this.edit.size.width,
96-
height: this.sprite?.height || this.edit.size.height
97+
width: this.sprite?.width || displaySize.width,
98+
height: this.sprite?.height || displaySize.height
9799
};
98100
}
99101

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

117-
private getDisplaySize(): Size {
118-
return {
119-
width: this.clipConfiguration.width ?? this.edit.size.width,
120-
height: this.clipConfiguration.height ?? this.edit.size.height
121-
};
122-
}
123-
124119
private async loadTexture(): Promise<void> {
125120
const asset = this.clipConfiguration.asset as ImageToVideoAsset;
126121
const { src } = asset;
@@ -132,6 +127,7 @@ export class ImageToVideoPlayer extends Player {
132127
if (!(texture?.source instanceof pixi.ImageSource)) {
133128
if (texture) {
134129
texture.destroy(true);
130+
await this.edit.assetLoader.rejectAsset(corsUrl);
135131
}
136132
throw new Error(`Invalid image source '${src}'.`);
137133
}

src/components/canvas/players/luma-player.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,9 @@ export class LumaPlayer extends Player {
3131

3232
const isValidLumaSource = texture?.source instanceof pixi.ImageSource || texture?.source instanceof pixi.VideoSource;
3333
if (!isValidLumaSource) {
34-
// Clean up ref if texture loaded but has invalid source type
35-
// (if texture was null, AssetLoader already decremented on failure)
3634
if (texture) {
37-
this.edit.assetLoader.decrementRef(identifier);
35+
texture.destroy(true);
36+
await this.edit.assetLoader.rejectAsset(identifier);
3837
}
3938
throw new Error(`Invalid luma source '${lumaAsset.src}'.`);
4039
}

src/components/canvas/players/player.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,13 @@ export abstract class Player extends Entity {
395395
return this.getSize();
396396
}
397397

398+
protected getDisplaySize(): Size {
399+
return {
400+
width: this.clipConfiguration.width ?? this.edit.size.width,
401+
height: this.clipConfiguration.height ?? this.edit.size.height
402+
};
403+
}
404+
398405
/** @internal */
399406
public getContentContainer(): pixi.Container {
400407
return this.contentContainer;

src/components/canvas/players/video-player.ts

Lines changed: 49 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,13 @@ import { type Size } from "@layouts/geometry";
44
import { type ResolvedClip, type VideoAsset } from "@schemas";
55
import * as pixi from "pixi.js";
66

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

910
export class VideoPlayer extends Player {
1011
private texture: pixi.Texture<pixi.VideoSource> | null;
1112
private sprite: pixi.Sprite | null;
13+
private placeholder: pixi.Graphics | null;
1214
private isPlaying: boolean;
1315

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

2325
this.texture = null;
2426
this.sprite = null;
27+
this.placeholder = null;
2528
this.isPlaying = false;
2629

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

3538
public override async load(): Promise<void> {
3639
await super.load();
37-
await this.loadVideo();
40+
try {
41+
await this.loadVideo();
42+
this.configureKeyframes();
43+
} catch (error) {
44+
console.warn(`[VideoPlayer.load] FAILED clipId=${this.clipId}:`, error);
45+
this.createFallbackGraphic();
46+
}
47+
}
48+
49+
private createFallbackGraphic(): void {
50+
const { width, height } = this.getDisplaySize();
51+
this.clearPlaceholder();
52+
53+
this.placeholder = createPlaceholderGraphic(width, height);
54+
this.contentContainer.addChild(this.placeholder);
3855
this.configureKeyframes();
3956
}
4057

@@ -98,11 +115,9 @@ export class VideoPlayer extends Player {
98115
}
99116

100117
public override dispose(): void {
101-
try {
102-
super.dispose();
103-
} finally {
104-
this.disposeVideo();
105-
}
118+
this.disposeVideo();
119+
this.clearPlaceholder();
120+
super.dispose();
106121
}
107122

108123
public override getSize(): Size {
@@ -113,7 +128,11 @@ export class VideoPlayer extends Player {
113128
};
114129
}
115130

116-
return { width: this.sprite?.width ?? 0, height: this.sprite?.height ?? 0 };
131+
if (this.sprite) {
132+
return { width: this.sprite.width, height: this.sprite.height };
133+
}
134+
135+
return this.placeholder ? this.getDisplaySize() : { width: 0, height: 0 };
117136
}
118137

119138
public override supportsEdgeResize(): boolean {
@@ -123,12 +142,20 @@ export class VideoPlayer extends Player {
123142
/** Reload the video asset when asset.src changes (e.g., merge field update) */
124143
public override async reloadAsset(): Promise<void> {
125144
this.skipVideoUpdate = true;
126-
this.disposeVideo();
127-
await this.loadVideo();
128145
this.isPlaying = false;
129146
this.syncTimer = 0;
130147
this.activeSyncTimer = 0;
131-
this.skipVideoUpdate = false;
148+
149+
try {
150+
this.disposeVideo();
151+
this.clearPlaceholder();
152+
await this.loadVideo();
153+
} catch (error) {
154+
console.warn(`[VideoPlayer.reloadAsset] FAILED clipId=${this.clipId}:`, error);
155+
this.createFallbackGraphic();
156+
} finally {
157+
this.skipVideoUpdate = false;
158+
}
132159
}
133160

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

187+
this.clearPlaceholder();
188+
160189
// Fix alpha channel rendering for WebM VP9 videos (PixiJS 8 auto-detection is buggy)
161190
texture.source.alphaMode = "no-premultiply-alpha";
162191

@@ -202,6 +231,16 @@ export class VideoPlayer extends Player {
202231
}
203232
}
204233

234+
private clearPlaceholder(): void {
235+
if (!this.placeholder) {
236+
return;
237+
}
238+
239+
this.contentContainer.removeChild(this.placeholder);
240+
this.placeholder.destroy();
241+
this.placeholder = null;
242+
}
243+
205244
public getVolume(): number {
206245
return this.volumeKeyframeBuilder.getValue(this.getPlaybackTime());
207246
}

src/core/layout/fit-system.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,10 @@ export interface SpriteTransform {
4242
* - none: No scaling (returns 1)
4343
*/
4444
export function calculateFitScale(contentSize: Size, targetSize: Size, fit: FitMode): number {
45+
if (contentSize.width === 0 || contentSize.height === 0) {
46+
return 1;
47+
}
48+
4549
const ratioX = targetSize.width / contentSize.width;
4650
const ratioY = targetSize.height / contentSize.height;
4751

src/core/loaders/asset-loader.ts

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -42,27 +42,42 @@ export class AssetLoader {
4242
pixi.Assets.setPreferences({ crossOrigin: "anonymous" });
4343
}
4444

45+
/**
46+
* Release an asset that was loaded successfully but rejected by the caller
47+
* (e.g. returned a non-image texture). Decrements the ref count and removes
48+
* the stale entry from the PixiJS Assets cache to prevent GL corruption.
49+
*/
50+
public async rejectAsset(identifier: string): Promise<void> {
51+
console.warn(`[AssetLoader.rejectAsset] Rejected invalid asset "${identifier}".`);
52+
await this.cleanupFailedLoad(identifier);
53+
}
54+
4555
public async load<TResolvedAsset>(identifier: string, loadOptions: pixi.UnresolvedAsset): Promise<TResolvedAsset | null> {
4656
this.updateAssetLoadMetadata(identifier, "pending", 0);
4757
this.incrementRef(identifier);
4858

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

52-
if (useSafari) {
53-
return await this.loadVideoForSafari<TResolvedAsset>(identifier, loadOptions);
62+
const resolvedAsset = useSafari
63+
? await this.loadVideoForSafari<TResolvedAsset>(identifier, loadOptions)
64+
: await pixi.Assets.load<TResolvedAsset>(loadOptions, progress => {
65+
this.updateAssetLoadMetadata(identifier, "loading", progress);
66+
});
67+
68+
if (resolvedAsset == null) {
69+
console.warn(`[AssetLoader.load] Empty asset returned for "${identifier}"`);
70+
this.updateAssetLoadMetadata(identifier, "failed", 1);
71+
await this.cleanupFailedLoad(identifier);
72+
return null;
5473
}
5574

56-
const resolvedAsset = await pixi.Assets.load<TResolvedAsset>(loadOptions, progress => {
57-
this.updateAssetLoadMetadata(identifier, "loading", progress);
58-
});
59-
6075
this.updateAssetLoadMetadata(identifier, "success", 1);
6176
return resolvedAsset;
6277
} catch (error) {
63-
console.warn(`[AssetLoader] Failed to load asset "${identifier}":`, error);
78+
console.warn(`[AssetLoader.load] Failed to load "${identifier}":`, error);
6479
this.updateAssetLoadMetadata(identifier, "failed", 1);
65-
this.decrementRef(identifier);
80+
await this.cleanupFailedLoad(identifier);
6681
return null;
6782
}
6883
}
@@ -130,6 +145,13 @@ export class AssetLoader {
130145
return totalProgress / identifiers.length;
131146
}
132147

148+
private async cleanupFailedLoad(identifier: string): Promise<void> {
149+
this.decrementRef(identifier);
150+
try {
151+
await pixi.Assets.unload(identifier);
152+
} catch {}
153+
}
154+
133155
private extractUrl(opts: pixi.UnresolvedAsset): string | undefined {
134156
if (typeof opts === "string") return opts;
135157
const src = Array.isArray(opts.src) ? opts.src[0] : opts.src;

src/core/ui/selection-handles.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -768,7 +768,15 @@ export class SelectionHandles implements CanvasOverlayRegistration {
768768

769769
private getContentCenter(): Vector {
770770
if (!this.selectedPlayer) return { x: 0, y: 0 };
771-
const bounds = this.selectedPlayer.getContentContainer().getBounds();
771+
772+
const contentContainer = this.selectedPlayer.getContentContainer();
773+
774+
if (contentContainer.destroyed) {
775+
const container = this.selectedPlayer.getContainer();
776+
return { x: container.x, y: container.y };
777+
}
778+
779+
const bounds = contentContainer.getBounds();
772780
return {
773781
x: bounds.x + bounds.width / 2,
774782
y: bounds.y + bounds.height / 2

0 commit comments

Comments
 (0)