fix(media): normalize local sources and recover preview loading

This commit is contained in:
webadderall
2026-09-20 18:52:26 +10:00
parent 04cf058ad0
commit e82272fc6c
10 changed files with 104 additions and 36 deletions
+13 -2
View File
@@ -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)
+2 -1
View File
@@ -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) {
@@ -53,8 +53,10 @@ export function WallpaperGrid({
<WallpaperVideoPreview src={item.previewUrl} />
) : (
<img
src={item.previewUrl}
src={item.previewUrl || undefined}
alt=""
loading="lazy"
decoding="async"
draggable={false}
className="absolute inset-0 h-full w-full select-none object-cover"
/>
@@ -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<typeof useProjectState>,
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],
);
}
+12
View File
@@ -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();
});
+10 -5
View File
@@ -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<string> {
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<string> {
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" ||
+3 -3
View File
@@ -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 () => {
+1 -23
View File
@@ -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<string>
return resource;
}
if (isLocalMediaServerUrl(resource)) {
return resource;
}
if (typeof window !== "undefined" && window.electronAPI?.getLocalMediaUrl) {
try {
const result = await window.electronAPI.getLocalMediaUrl(localFilePath);
+1 -1
View File
@@ -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",
);
});
+21
View File
@@ -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;
}
}