mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-24 23:05:49 +00:00
fix(media): normalize local sources and recover preview loading
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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" ||
|
||||
|
||||
@@ -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,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);
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user