diff --git a/electron/ipc/register/assets.ts b/electron/ipc/register/assets.ts index a9e97278..11b244cb 100644 --- a/electron/ipc/register/assets.ts +++ b/electron/ipc/register/assets.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import { existsSync } from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; @@ -27,11 +28,21 @@ export function registerAssetHandlers() { ipcMain.handle("generate-wallpaper-thumbnail", async (_, filePath: string) => { try { - const resolved = await resolveReadableLocalFilePath(filePath); + const bundled = filePath.startsWith("/wallpapers/"); + const candidate = bundled + ? path.join( + getAssetRootPath(), + "wallpapers", + path.basename(decodeURIComponent(filePath)), + ) + : filePath; + const resolved = await resolveReadableLocalFilePath(candidate); // Deterministic cache key from file path + mtime const stat = await fs.stat(resolved); - const cacheKey = Buffer.from(`${resolved}:${stat.mtimeMs}`).toString("base64url"); + const cacheKey = createHash("sha256") + .update(`${resolved}:${stat.mtimeMs}`) + .digest("hex"); const thumbPath = path.join(thumbCacheDir, `${cacheKey}.jpg`); // Return cached thumbnail if it exists (no queue needed) diff --git a/electron/ipc/utils.ts b/electron/ipc/utils.ts index 23960f20..af6b3c2b 100644 --- a/electron/ipc/utils.ts +++ b/electron/ipc/utils.ts @@ -1,3 +1,4 @@ +import { getLocalMediaServerPath } from "../../src/lib/localMediaUrl"; import fs from "node:fs/promises"; import { createRequire } from "node:module"; import path from "node:path"; @@ -46,7 +47,7 @@ export function normalizeVideoSourcePath(videoPath?: string | null): string | nu } } - return trimmed; + return getLocalMediaServerPath(trimmed) ?? trimmed; } export function stripJsonByteOrderMark(content: string) { diff --git a/src/components/video-editor/WallpaperGrid.tsx b/src/components/video-editor/WallpaperGrid.tsx index 7c810e95..25ebd9e2 100644 --- a/src/components/video-editor/WallpaperGrid.tsx +++ b/src/components/video-editor/WallpaperGrid.tsx @@ -53,8 +53,10 @@ export function WallpaperGrid({ ) : ( diff --git a/src/components/video-editor/hooks/useVideoSourceRecovery.ts b/src/components/video-editor/hooks/useVideoSourceRecovery.ts new file mode 100644 index 00000000..bb8b782f --- /dev/null +++ b/src/components/video-editor/hooks/useVideoSourceRecovery.ts @@ -0,0 +1,38 @@ +import { useCallback, useEffect, useRef } from "react"; +import { resolveVideoUrl } from "../projectPersistence"; +import type { useProjectState } from "../state/useProjectState"; + +/** Retry once through the permission-checked media API after a stale port/access failure. */ +export function useVideoSourceRecovery( + project: ReturnType, + remount: () => void, +) { + const attempted = useRef(false); + const latest = useRef(project); + latest.current = project; + // biome-ignore lint/correctness/useExhaustiveDependencies: a different source gets its own recovery attempt. + useEffect(() => { + attempted.current = false; + }, [project.videoSourcePath]); + return useCallback( + (message: string | null) => { + const current = latest.current; + const source = current.videoSourcePath; + if (!source || attempted.current || !message?.startsWith("Failed to load video")) { + current.setError(message); + return; + } + attempted.current = true; + void resolveVideoUrl(source) + .then((url) => { + if (latest.current.videoSourcePath !== source) return; + latest.current.setVideoPath(url); + remount(); + }) + .catch(() => { + if (latest.current.videoSourcePath === source) latest.current.setError(message); + }); + }, + [remount], + ); +} diff --git a/src/lib/assetPath.test.ts b/src/lib/assetPath.test.ts index 8956f291..fb9ad8f5 100644 --- a/src/lib/assetPath.test.ts +++ b/src/lib/assetPath.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { getAssetPath, + getWallpaperThumbnailUrl, getExportableVideoUrl, getRenderableAssetUrl, getRenderableVideoUrl, @@ -124,3 +125,14 @@ describe("getExportableVideoUrl", () => { ); }); }); + + it("requests a cached thumbnail for a dev wallpaper instead of loading the original", async () => { + const generateWallpaperThumbnail = vi.fn(async () => ({ success: true, data: new Uint8Array([255, 216, 255]) })); + vi.stubGlobal("window", { location: { protocol: "http:" }, electronAPI: { getAssetBasePath: async () => null, generateWallpaperThumbnail } }); + const url = await getWallpaperThumbnailUrl("/wallpapers/thumbnail-regression.jpg"); + expect(url).toBe("data:image/jpeg;base64,/9j/"); + expect(generateWallpaperThumbnail).toHaveBeenCalledWith("/wallpapers/thumbnail-regression.jpg"); + await expect(getWallpaperThumbnailUrl("/wallpapers/thumbnail-regression.jpg")).resolves.toBe(url); + expect(generateWallpaperThumbnail).toHaveBeenCalledTimes(1); + vi.unstubAllGlobals(); + }); diff --git a/src/lib/assetPath.ts b/src/lib/assetPath.ts index f810c153..3e38962e 100644 --- a/src/lib/assetPath.ts +++ b/src/lib/assetPath.ts @@ -1,3 +1,4 @@ +import { getLocalMediaServerPath } from "./localMediaUrl"; import { resolveAvailableWallpaperPath } from "./wallpapers"; function encodeRelativeAssetPath(relativePath: string): string { @@ -186,6 +187,8 @@ function isBundledAssetPath(asset: string): boolean { } export async function getRenderableVideoUrl(asset: string): Promise { + const serverPath = getLocalMediaServerPath(asset); + if (serverPath) return resolveLocalMediaUrl(serverPath); if ( !asset || asset.startsWith("blob:") || @@ -273,11 +276,13 @@ export async function getWallpaperThumbnailUrl(asset: string): Promise { const cached = thumbnailCache.get(asset); if (cached) return cached; - const localFilePath = toLocalFilePath( - asset.startsWith("/") && !asset.startsWith("//") - ? await getAssetPath(asset.replace(/^\//, "")) - : asset, - ); + const localFilePath = + (asset.startsWith("/wallpapers/") ? asset : null) ?? + toLocalFilePath( + asset.startsWith("/") && !asset.startsWith("//") + ? await getAssetPath(asset.replace(/^\//, "")) + : asset, + ); if ( !localFilePath || typeof window === "undefined" || diff --git a/src/lib/exporter/localMediaSource.test.ts b/src/lib/exporter/localMediaSource.test.ts index 73cbc9b8..2d850c04 100644 --- a/src/lib/exporter/localMediaSource.test.ts +++ b/src/lib/exporter/localMediaSource.test.ts @@ -43,14 +43,14 @@ describe("resolveMediaElementSource", () => { expect(result.src).toBe("http://127.0.0.1:4321/video?path=%2Ftmp%2Fexample.wav"); }); - it("preserves loopback media-server URLs instead of materializing them through IPC", async () => { + it("refreshes loopback URLs through the current media server", async () => { const result = await resolveMediaElementSource( "http://127.0.0.1:43123/video?path=%2Ftmp%2Fexample%20clip.mp4", ); expect(readLocalFile).not.toHaveBeenCalled(); - expect(getLocalMediaUrl).not.toHaveBeenCalled(); - expect(result.src).toBe("http://127.0.0.1:43123/video?path=%2Ftmp%2Fexample%20clip.mp4"); + expect(getLocalMediaUrl).toHaveBeenCalledWith("/tmp/example clip.mp4"); + expect(result.src).toBe("http://127.0.0.1:4321/video?path=%2Ftmp%2Fexample%20clip.mp4"); }); it("leaves remote URLs untouched", async () => { diff --git a/src/lib/exporter/localMediaSource.ts b/src/lib/exporter/localMediaSource.ts index 984d959c..ead9fb24 100644 --- a/src/lib/exporter/localMediaSource.ts +++ b/src/lib/exporter/localMediaSource.ts @@ -1,8 +1,8 @@ +import { getLocalMediaServerPath } from "../localMediaUrl"; import { fromFileUrl, toFileUrl } from "@/components/video-editor/projectPersistence"; const NOOP = () => undefined; const REMOTE_MEDIA_URL_PATTERN = /^(https?:|blob:|data:)/i; -const LOOPBACK_MEDIA_HOSTS = new Set(["127.0.0.1", "localhost"]); const BUNDLED_ASSET_PATH_PREFIXES = ["/wallpapers/", "/app-icons/"]; export function isAbsoluteLocalPath(resource: string) { @@ -17,24 +17,6 @@ function isBundledAssetPath(resource: string) { return BUNDLED_ASSET_PATH_PREFIXES.some((prefix) => resource.startsWith(prefix)); } -function getLocalMediaServerPath(resource: string) { - if (!/^https?:\/\//i.test(resource)) { - return null; - } - - try { - const url = new URL(resource); - if (!LOOPBACK_MEDIA_HOSTS.has(url.hostname) || url.pathname !== "/video") { - return null; - } - - const mediaPath = url.searchParams.get("path"); - return mediaPath && mediaPath.trim().length > 0 ? mediaPath : null; - } catch { - return null; - } -} - export function isLocalMediaServerUrl(resource: string) { return getLocalMediaServerPath(resource) !== null; } @@ -98,10 +80,6 @@ export async function resolveMediaResourceUrl(resource: string): Promise return resource; } - if (isLocalMediaServerUrl(resource)) { - return resource; - } - if (typeof window !== "undefined" && window.electronAPI?.getLocalMediaUrl) { try { const result = await window.electronAPI.getLocalMediaUrl(localFilePath); diff --git a/src/lib/exporter/streamingDecoder.test.ts b/src/lib/exporter/streamingDecoder.test.ts index 90b43020..d8d2c9e5 100644 --- a/src/lib/exporter/streamingDecoder.test.ts +++ b/src/lib/exporter/streamingDecoder.test.ts @@ -312,7 +312,7 @@ describe("StreamingVideoDecoder local media loading", () => { expect(window.electronAPI.readLocalFile).not.toHaveBeenCalled(); expect(mockDemuxerLoad).toHaveBeenCalledWith( - "http://127.0.0.1:43123/video?path=%2Ftmp%2Fcapture.mp4", + "http://127.0.0.1:4321/video?path=%2Ftmp%2Fcapture.mp4", ); }); diff --git a/src/lib/localMediaUrl.ts b/src/lib/localMediaUrl.ts new file mode 100644 index 00000000..9ff39c8a --- /dev/null +++ b/src/lib/localMediaUrl.ts @@ -0,0 +1,21 @@ +/** The port belongs to one app run; persist the file path, never this URL. */ +export function getLocalMediaServerPath(resource: string): string | null { + try { + const url = new URL(resource); + if ( + !["http:", "https:"].includes(url.protocol) || + !["127.0.0.1", "localhost"].includes(url.hostname) || + url.pathname !== "/video" + ) + return null; + const filePath = url.searchParams.get("path"); + return filePath && + (/^\//.test(filePath) || + /^[A-Za-z]:[\\/]/.test(filePath) || + filePath.startsWith("\\\\")) + ? filePath + : null; + } catch { + return null; + } +}