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;
+ }
+}