Fix black video wallpaper exports

This commit is contained in:
webadderall
2026-04-27 16:22:57 +10:00
parent b4b009fafa
commit f1449073e6
6 changed files with 141 additions and 20 deletions
+25 -1
View File
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { getRenderableVideoUrl } from "./assetPath";
import { getExportableVideoUrl, getRenderableVideoUrl } from "./assetPath";
describe("getRenderableVideoUrl", () => {
beforeEach(() => {
@@ -39,3 +39,27 @@ describe("getRenderableVideoUrl", () => {
);
});
});
describe("getExportableVideoUrl", () => {
beforeEach(() => {
vi.unstubAllGlobals();
});
it("uses a direct file URL for absolute local video paths", async () => {
await expect(getExportableVideoUrl("/Users/egg/Desktop/bg.mp4")).resolves.toBe(
"file:///Users/egg/Desktop/bg.mp4",
);
});
it("keeps bundled wallpaper paths routed through the app asset directory", async () => {
vi.stubGlobal("window", {
location: {
protocol: "http:",
},
});
await expect(getExportableVideoUrl("/wallpapers/wispysky.mp4")).resolves.toBe(
"/wallpapers/wispysky.mp4",
);
});
});
+27 -4
View File
@@ -177,6 +177,10 @@ async function resolveLocalMediaUrl(filePath: string): Promise<string> {
return toFileUrl(filePath);
}
function isBundledAssetPath(asset: string): boolean {
return asset.startsWith("/wallpapers/") || asset.startsWith("/app-icons/");
}
export async function getRenderableVideoUrl(asset: string): Promise<string> {
if (
!asset ||
@@ -188,10 +192,7 @@ export async function getRenderableVideoUrl(asset: string): Promise<string> {
return asset;
}
const isBundledAssetPath =
asset.startsWith("/wallpapers/") || asset.startsWith("/app-icons/");
if (isAbsoluteLocalAssetPath(asset) && !isBundledAssetPath) {
if (isAbsoluteLocalAssetPath(asset) && !isBundledAssetPath(asset)) {
return resolveLocalMediaUrl(asset);
}
@@ -202,6 +203,28 @@ export async function getRenderableVideoUrl(asset: string): Promise<string> {
return asset;
}
export async function getExportableVideoUrl(asset: string): Promise<string> {
if (
!asset ||
asset.startsWith("blob:") ||
asset.startsWith("data:") ||
asset.startsWith("file://") ||
asset.startsWith("http")
) {
return asset;
}
if (isAbsoluteLocalAssetPath(asset) && !isBundledAssetPath(asset)) {
return toFileUrl(asset);
}
if (asset.startsWith("/") && !asset.startsWith("//")) {
return getAssetPath(asset.replace(/^\/+/, ""));
}
return asset;
}
// ---------------------------------------------------------------------------
// Wallpaper thumbnail helper — generates a tiny JPEG thumbnail via the main
// process (nativeImage resize) and returns a data URL for fast grid rendering.
+1
View File
@@ -18,6 +18,7 @@ vi.mock("pixi-filters/motion-blur", () => ({
vi.mock("@/lib/assetPath", () => ({
getAssetPath: vi.fn(async (value: string) => value),
getExportableVideoUrl: vi.fn(async (value: string) => value),
getRenderableAssetUrl: vi.fn((value: string) => value),
}));
+31 -5
View File
@@ -51,7 +51,11 @@ import {
getWebcamOverlaySizePx,
} from "@/components/video-editor/webcamOverlay";
import { getWebcamMediaTargetTimeSeconds } from "@/components/video-editor/videoPlayback/webcamSync";
import { getAssetPath, getRenderableAssetUrl, getRenderableVideoUrl } from "@/lib/assetPath";
import {
getAssetPath,
getExportableVideoUrl,
getRenderableAssetUrl,
} from "@/lib/assetPath";
import { extensionHost } from "@/lib/extensions";
import {
mapCursorToCanvasNormalized,
@@ -374,17 +378,39 @@ export class FrameRenderer {
videoSrc = await getAssetPath(wallpaper.replace(/^\//, ""));
}
const backgroundSource = await resolveMediaElementSource(videoSrc);
this.cleanupBackgroundSource = backgroundSource.revoke;
const video = document.createElement("video");
video.muted = true;
video.loop = true;
video.playsInline = true;
video.preload = "auto";
video.src = videoSrc;
video.src = backgroundSource.src;
video.load();
await new Promise<void>((resolve, reject) => {
video.onloadeddata = () => resolve();
video.onerror = () =>
const onReady = () => {
if (video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) {
return;
}
cleanup();
resolve();
};
const onError = () => {
cleanup();
reject(new Error(`Failed to load video wallpaper: ${wallpaper}`));
};
const cleanup = () => {
video.removeEventListener("loadeddata", onReady);
video.removeEventListener("canplay", onReady);
video.removeEventListener("error", onError);
};
video.addEventListener("loadeddata", onReady);
video.addEventListener("canplay", onReady);
video.addEventListener("error", onError);
onReady();
});
this.backgroundVideoElement = video;
@@ -581,7 +607,7 @@ export class FrameRenderer {
}
if (isVideoWallpaperSource(wallpaper)) {
return getRenderableVideoUrl(wallpaper);
return getExportableVideoUrl(wallpaper);
}
if (
@@ -36,6 +36,7 @@ vi.mock("pixi-filters/motion-blur", () => ({
vi.mock("@/lib/assetPath", () => ({
getAssetPath: vi.fn(async (value: string) => value),
getExportableVideoUrl: vi.fn(async (value: string) => value),
getRenderableAssetUrl: vi.fn((value: string) => value),
}));
+56 -10
View File
@@ -57,7 +57,11 @@ import {
getWebcamOverlaySizePx,
} from "@/components/video-editor/webcamOverlay";
import { getWebcamMediaTargetTimeSeconds } from "@/components/video-editor/videoPlayback/webcamSync";
import { getAssetPath, getRenderableAssetUrl, getRenderableVideoUrl } from "@/lib/assetPath";
import {
getAssetPath,
getExportableVideoUrl,
getRenderableAssetUrl,
} from "@/lib/assetPath";
import { extensionHost } from "@/lib/extensions";
import {
mapCursorToCanvasNormalized,
@@ -332,6 +336,7 @@ export class FrameRenderer {
private backgroundForwardFrameSource: ForwardFrameSource | null = null;
private backgroundDecodedFrame: VideoFrame | null = null;
private backgroundVideoElement: HTMLVideoElement | null = null;
private cleanupBackgroundSource: (() => void) | null = null;
private videoShadowLayers: ShadowLayer[] = [];
private webcamShadowLayers: ShadowLayer[] = [];
private webcamSprite: Sprite | null = null;
@@ -344,6 +349,8 @@ export class FrameRenderer {
private webcamFrameCacheCtx: CanvasRenderingContext2D | null = null;
private sceneVideoFrameStagingCanvas: HTMLCanvasElement | null = null;
private sceneVideoFrameStagingCtx: CanvasRenderingContext2D | null = null;
private backgroundVideoFrameStagingCanvas: HTMLCanvasElement | null = null;
private backgroundVideoFrameStagingCtx: CanvasRenderingContext2D | null = null;
private webcamVideoFrameStagingCanvas: HTMLCanvasElement | null = null;
private webcamVideoFrameStagingCtx: CanvasRenderingContext2D | null = null;
private captionMeasureCanvas: HTMLCanvasElement | null = null;
@@ -708,7 +715,7 @@ export class FrameRenderer {
}
private ensureVideoFrameStagingCanvas(
kind: "scene" | "webcam",
kind: "scene" | "background" | "webcam",
width: number,
height: number,
): { canvas: HTMLCanvasElement; context: CanvasRenderingContext2D } | null {
@@ -717,9 +724,15 @@ export class FrameRenderer {
const currentCanvas =
kind === "scene"
? this.sceneVideoFrameStagingCanvas
: this.webcamVideoFrameStagingCanvas;
: kind === "background"
? this.backgroundVideoFrameStagingCanvas
: this.webcamVideoFrameStagingCanvas;
const currentContext =
kind === "scene" ? this.sceneVideoFrameStagingCtx : this.webcamVideoFrameStagingCtx;
kind === "scene"
? this.sceneVideoFrameStagingCtx
: kind === "background"
? this.backgroundVideoFrameStagingCtx
: this.webcamVideoFrameStagingCtx;
if (
currentCanvas &&
@@ -741,6 +754,9 @@ export class FrameRenderer {
if (kind === "scene") {
this.sceneVideoFrameStagingCanvas = canvas;
this.sceneVideoFrameStagingCtx = context;
} else if (kind === "background") {
this.backgroundVideoFrameStagingCanvas = canvas;
this.backgroundVideoFrameStagingCtx = context;
} else {
this.webcamVideoFrameStagingCanvas = canvas;
this.webcamVideoFrameStagingCtx = context;
@@ -751,7 +767,7 @@ export class FrameRenderer {
private stageVideoFrameForTexture(
frame: VideoFrame,
kind: "scene" | "webcam",
kind: "scene" | "background" | "webcam",
fallbackWidth: number,
fallbackHeight: number,
): CanvasImageSource | VideoFrame {
@@ -814,6 +830,8 @@ export class FrameRenderer {
void this.backgroundForwardFrameSource?.destroy();
this.backgroundForwardFrameSource = null;
this.closeBackgroundDecodedFrame();
this.cleanupBackgroundSource?.();
this.cleanupBackgroundSource = null;
if (this.backgroundVideoElement) {
this.backgroundVideoElement.pause();
@@ -856,17 +874,39 @@ export class FrameRenderer {
);
}
const backgroundSource = await resolveMediaElementSource(videoSrc);
this.cleanupBackgroundSource = backgroundSource.revoke;
const video = document.createElement("video");
video.muted = true;
video.loop = true;
video.playsInline = true;
video.preload = "auto";
video.src = videoSrc;
video.src = backgroundSource.src;
video.load();
await new Promise<void>((resolve, reject) => {
video.onloadeddata = () => resolve();
video.onerror = () =>
const onReady = () => {
if (video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) {
return;
}
cleanup();
resolve();
};
const onError = () => {
cleanup();
reject(new Error(`Failed to load video wallpaper: ${wallpaper}`));
};
const cleanup = () => {
video.removeEventListener("loadeddata", onReady);
video.removeEventListener("canplay", onReady);
video.removeEventListener("error", onError);
};
video.addEventListener("loadeddata", onReady);
video.addEventListener("canplay", onReady);
video.addEventListener("error", onError);
onReady();
});
this.backgroundVideoElement = video;
@@ -1434,8 +1474,14 @@ export class FrameRenderer {
this.closeBackgroundDecodedFrame();
this.backgroundDecodedFrame = decodedFrame;
if (decodedFrame) {
this.ensureBackgroundSprite(
const resolvedBackgroundSource = this.stageVideoFrameForTexture(
decodedFrame,
"background",
decodedFrame.displayWidth,
decodedFrame.displayHeight,
);
this.ensureBackgroundSprite(
resolvedBackgroundSource,
decodedFrame.displayWidth,
decodedFrame.displayHeight,
);
@@ -1488,7 +1534,7 @@ export class FrameRenderer {
}
if (isVideoWallpaperSource(wallpaper)) {
return getRenderableVideoUrl(wallpaper);
return getExportableVideoUrl(wallpaper);
}
if (