Skip to content
Merged
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
7 changes: 5 additions & 2 deletions src/components/canvas/players/image-player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ export class ImagePlayer extends Player {
try {
await this.loadTexture();
this.configureKeyframes();
} catch {
} catch (error) {
this.recordLoadError(error);
this.createFallbackGraphic();
}
}
Expand Down Expand Up @@ -76,10 +77,12 @@ export class ImagePlayer extends Player {
public override async reloadAsset(): Promise<void> {
this.disposeTexture();
this.clearPlaceholder();
this.loadError = null;

try {
await this.loadTexture();
} catch {
} catch (error) {
this.recordLoadError(error);
this.createFallbackGraphic();
}
}
Expand Down
11 changes: 11 additions & 0 deletions src/components/canvas/players/player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,12 @@ export abstract class Player extends Entity {
/** True when the player's asset needs external resolution (e.g. alias caption awaiting transcription). */
public needsResolution = false;

/**
* Why the asset couldn't be shown, or null. A failing player draws a placeholder and still
* resolves load() so the rest of the edit keeps loading; this is how the failure is reported.
*/
public loadError: string | null = null;

protected edit: Edit;
public clipConfiguration: ResolvedClip;

Expand Down Expand Up @@ -231,7 +237,12 @@ export abstract class Player extends Entity {
}
}

protected recordLoadError(error: unknown): void {
this.loadError = error instanceof Error ? error.message : String(error);
}

public override async load(): Promise<void> {
this.loadError = null;
if (this.lumaWrapper?.destroyed) {
this.lumaWrapper = new pixi.Container();
this.getContainer().addChild(this.lumaWrapper);
Expand Down
37 changes: 30 additions & 7 deletions src/components/canvas/players/svg-player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export class SvgPlayer extends Player {
private renderedWidth: number = 0;
private renderedHeight: number = 0;
private pendingRender: Promise<void> | null = null;
private placeholder: pixi.Graphics | null = null;

constructor(edit: Edit, clipConfiguration: ResolvedClip) {
super(edit, clipConfiguration, PlayerType.Svg);
Expand Down Expand Up @@ -53,6 +54,7 @@ export class SvgPlayer extends Player {
try {
const validationResult = SvgAssetSchema.safeParse(svgAsset);
if (!validationResult.success) {
this.recordLoadError(`Invalid svg asset: ${validationResult.error.issues.map(i => i.message).join("; ")}`);
this.createFallbackGraphic();
return;
}
Expand All @@ -62,26 +64,42 @@ export class SvgPlayer extends Player {
this.configureKeyframes();
} catch (error) {
console.error("Failed to render SVG asset:", error);
this.recordLoadError(error);
this.createFallbackGraphic();
}
}

public override async reloadAsset(): Promise<void> {
await this.rerenderAtCurrentDimensions();
this.loadError = null;
try {
await this.rerenderAtCurrentDimensions();
} catch (error) {
console.error("Failed to render SVG asset:", error);
this.recordLoadError(error);
this.createFallbackGraphic();
}
}

private createFallbackGraphic(): void {
const width = this.clipConfiguration.width || this.edit.size.width;
const height = this.clipConfiguration.height || this.edit.size.height;

const graphics = createPlaceholderGraphic(width, height);
this.clearPlaceholder();
this.placeholder = createPlaceholderGraphic(width, height);

this.renderedWidth = width;
this.renderedHeight = height;
this.contentContainer.addChild(graphics);
this.contentContainer.addChild(this.placeholder);
this.configureKeyframes();
}

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

public override update(deltaTime: number, elapsed: number): void {
super.update(deltaTime, elapsed);
}
Expand All @@ -90,6 +108,7 @@ export class SvgPlayer extends Player {
super.dispose();

this.pendingRender = null;
this.placeholder = null;

if (this.sprite) {
this.sprite.destroy();
Expand Down Expand Up @@ -136,9 +155,9 @@ export class SvgPlayer extends Player {
}

private async rerenderAtCurrentDimensions(): Promise<void> {
// Wait for any pending render to complete
// Wait for any pending render to complete; its failure is reported by whoever started it.
if (this.pendingRender) {
await this.pendingRender;
await this.pendingRender.catch(() => undefined);
}

// Clean up old sprite/texture
Expand All @@ -154,8 +173,12 @@ export class SvgPlayer extends Player {

// Start new render
this.pendingRender = this.doRender();
await this.pendingRender;
this.pendingRender = null;
try {
await this.pendingRender;
} finally {
this.pendingRender = null;
}
this.clearPlaceholder();
}

private async doRender(): Promise<void> {
Expand Down
3 changes: 3 additions & 0 deletions src/components/canvas/players/video-player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export class VideoPlayer extends Player {
this.configureKeyframes();
} catch (error) {
console.warn(`[VideoPlayer.load] FAILED clipId=${this.clipId}:`, error);
this.recordLoadError(error);
this.createFallbackGraphic();
} finally {
this.skipVideoUpdate = false;
Expand Down Expand Up @@ -157,12 +158,14 @@ export class VideoPlayer extends Player {
this.syncTimer = 0;
this.activeSyncTimer = 0;

this.loadError = null;
try {
this.disposeVideo();
this.clearPlaceholder();
await this.loadVideo();
} catch (error) {
console.warn(`[VideoPlayer.reloadAsset] FAILED clipId=${this.clipId}:`, error);
this.recordLoadError(error);
this.createFallbackGraphic();
} finally {
this.skipVideoUpdate = false;
Expand Down
35 changes: 24 additions & 11 deletions src/core/edit-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -887,6 +887,10 @@ export class Edit {
* @internal
*/
public getClipError(trackIdx: number, clipIdx: number): { error: string; assetType: string } | null {
const player = this.getPlayerClip(trackIdx, clipIdx);
if (player?.loadError) {
return { error: player.loadError, assetType: (player.clipConfiguration.asset as { type?: string })?.type ?? "unknown" };
}
const recorded = this.clipErrors.get(`${trackIdx}-${clipIdx}`);
if (recorded) return recorded;
const asset = this.getResolvedClip(trackIdx, clipIdx)?.asset;
Expand Down Expand Up @@ -1741,18 +1745,27 @@ export class Edit {

this.addPlayerToContainer(trackIdx, clip);

clip.load().catch(error => {
// Capture load errors for restored clips (same pattern as initial load)
const assetType = (clip.clipConfiguration?.asset as { type?: string })?.type ?? "unknown";
const errorMessage = error instanceof Error ? error.message : String(error);
this.clipErrors.set(`${trackIdx}-${insertIdx}`, { error: errorMessage, assetType });
this.internalEvents.emit(EditEvent.ClipLoadFailed, {
trackIndex: trackIdx,
clipIndex: insertIdx,
error: errorMessage,
assetType
clip
.load()
.then(() => {
if (!clip.loadError) return;
const indices = this.findClipIndices(clip);
if (!indices) return;
const assetType = (clip.clipConfiguration?.asset as { type?: string })?.type ?? "unknown";
this.internalEvents.emit(EditEvent.ClipLoadFailed, { ...indices, error: clip.loadError, assetType });
})
.catch(error => {
// Capture load errors for restored clips (same pattern as initial load)
const assetType = (clip.clipConfiguration?.asset as { type?: string })?.type ?? "unknown";
const errorMessage = error instanceof Error ? error.message : String(error);
this.clipErrors.set(`${trackIdx}-${insertIdx}`, { error: errorMessage, assetType });
this.internalEvents.emit(EditEvent.ClipLoadFailed, {
trackIndex: trackIdx,
clipIndex: insertIdx,
error: errorMessage,
assetType
});
});
});

this.updateTotalDuration();
},
Expand Down
15 changes: 15 additions & 0 deletions src/core/player-reconciler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,8 @@ export class PlayerReconciler {
const loadPromise = player
.load()
.then(() => {
if (player.loadError) this.reportLoadFailure(player, player.loadError);

// Emit PlayerLoaded for all players
this.edit.getInternalEvents().emit(InternalEvent.PlayerLoaded, {
player,
Expand Down Expand Up @@ -345,6 +347,17 @@ export class PlayerReconciler {
player.reconfigureAfterRestore();
}

/**
* Announce a player that couldn't show its asset. Its position is looked up now, not when the
* load started, because clips can move while an asset is still loading.
*/
private reportLoadFailure(player: Player, error: string): void {
const indices = this.edit.findClipIndices(player);
if (!indices) return;
const assetType = (player.clipConfiguration.asset as { type?: string })?.type ?? "unknown";
this.edit.getInternalEvents().emit(EditEvent.ClipLoadFailed, { ...indices, error, assetType });
}

/**
* Check if asset properties changed (excluding type, which is handled separately).
*/
Expand Down Expand Up @@ -376,9 +389,11 @@ export class PlayerReconciler {
.reloadAsset()
.then(() => {
player.reconfigureAfterRestore();
if (player.loadError) this.reportLoadFailure(player, player.loadError);
})
.catch(error => {
console.error("Failed to reload asset:", error);
this.reportLoadFailure(player, error instanceof Error ? error.message : String(error));
});
} else {
player.reconfigureAfterRestore();
Expand Down
31 changes: 31 additions & 0 deletions tests/edit-load.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1271,6 +1271,37 @@ describe("Edit loadEdit()", () => {
const { tracks } = getEditState(edit);
expect(tracks[0].length).toBe(2);
});

it("reports a clip whose player fell back to a placeholder", async () => {
const loadFailedHandler = jest.fn();
events.on("clip:loadFailed", loadFailedHandler);

// Real players draw a placeholder and resolve load() rather than rejecting.
const { ImagePlayer: ImagePlayerMock } = jest.requireMock("@canvas/players/image-player");
ImagePlayerMock.mockImplementationOnce((editInstance: Edit, config: ResolvedClip) => {
const player = createMockPlayer(editInstance, config, PlayerType.Image);
player["load"] = jest.fn(async () => {
player["loadError"] = "Invalid image source 'bad.jpg'.";
});
return player;
});

await edit.loadEdit(
createMinimalEdit([
{
clips: [
{ asset: { type: "image", src: "https://example.com/bad.jpg" }, start: 0, length: 3, fit: "crop" },
{ asset: { type: "image", src: "https://example.com/good.jpg" }, start: 3, length: 3, fit: "crop" }
]
}
])
);

expect(loadFailedHandler).toHaveBeenCalledWith(
expect.objectContaining({ trackIndex: 0, clipIndex: 0, error: "Invalid image source 'bad.jpg'.", assetType: "image" })
);
expect(edit.getClipError(0, 0)).toEqual({ error: "Invalid image source 'bad.jpg'.", assetType: "image" });
});
});
});

Expand Down
40 changes: 40 additions & 0 deletions tests/svg-player.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -468,6 +468,46 @@ describe("SvgPlayer", () => {
consoleSpy.mockRestore();
});

it("records why it fell back when rendering fails", async () => {
mockRenderSvgAssetToPng.mockRejectedValueOnce(new Error("Render failed"));
const player = new SvgPlayer(createMockEdit(), createSvgClipConfig());
const consoleSpy = jest.spyOn(console, "error").mockImplementation();

await player.load();

expect(player.loadError).toBe("Render failed");
consoleSpy.mockRestore();
});

it("records why it fell back when validation fails", async () => {
const player = new SvgPlayer(createMockEdit(), createInvalidSvgClipConfig());

await player.load();

expect(player.loadError).toEqual(expect.any(String));
});

it("has no load error after a successful render", async () => {
const player = new SvgPlayer(createMockEdit(), createSvgClipConfig());

await player.load();

expect(player.loadError).toBeNull();
});

it("falls back and records the error when a re-render fails", async () => {
const player = new SvgPlayer(createMockEdit(), createSvgClipConfig());
await player.load();
mockRenderSvgAssetToPng.mockRejectedValueOnce(new Error("Render failed"));
const consoleSpy = jest.spyOn(console, "error").mockImplementation();

await expect(player.reloadAsset()).resolves.toBeUndefined();

expect(createPlaceholderGraphic).toHaveBeenCalled();
expect(player.loadError).toBe("Render failed");
consoleSpy.mockRestore();
});

it("creates fallback graphic when WASM init fails", async () => {
// Canvas's initResvg handles the fetch internally; we simulate a
// failure by making the SDK-side initResvg call reject.
Expand Down
Loading