diff --git a/src/lib/assetPath.test.ts b/src/lib/assetPath.test.ts index 826006bb..7cb51728 100644 --- a/src/lib/assetPath.test.ts +++ b/src/lib/assetPath.test.ts @@ -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", + ); + }); +}); diff --git a/src/lib/assetPath.ts b/src/lib/assetPath.ts index 152ff629..6ff5ddeb 100644 --- a/src/lib/assetPath.ts +++ b/src/lib/assetPath.ts @@ -177,6 +177,10 @@ async function resolveLocalMediaUrl(filePath: string): Promise { return toFileUrl(filePath); } +function isBundledAssetPath(asset: string): boolean { + return asset.startsWith("/wallpapers/") || asset.startsWith("/app-icons/"); +} + export async function getRenderableVideoUrl(asset: string): Promise { if ( !asset || @@ -188,10 +192,7 @@ export async function getRenderableVideoUrl(asset: string): Promise { 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 { return asset; } +export async function getExportableVideoUrl(asset: string): Promise { + 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. diff --git a/src/lib/exporter/frameRenderer.test.ts b/src/lib/exporter/frameRenderer.test.ts index caf807d7..bae16422 100644 --- a/src/lib/exporter/frameRenderer.test.ts +++ b/src/lib/exporter/frameRenderer.test.ts @@ -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), })); diff --git a/src/lib/exporter/frameRenderer.ts b/src/lib/exporter/frameRenderer.ts index fc342ff6..6c6da5b5 100644 --- a/src/lib/exporter/frameRenderer.ts +++ b/src/lib/exporter/frameRenderer.ts @@ -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((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 ( diff --git a/src/lib/exporter/modernFrameRenderer.test.ts b/src/lib/exporter/modernFrameRenderer.test.ts index 2ccdec77..b87b3b19 100644 --- a/src/lib/exporter/modernFrameRenderer.test.ts +++ b/src/lib/exporter/modernFrameRenderer.test.ts @@ -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), })); diff --git a/src/lib/exporter/modernFrameRenderer.ts b/src/lib/exporter/modernFrameRenderer.ts index 1b3f2190..c300a454 100644 --- a/src/lib/exporter/modernFrameRenderer.ts +++ b/src/lib/exporter/modernFrameRenderer.ts @@ -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((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 (