From 4005687de4b4dbd622a0a164d2e59f499f4d8103 Mon Sep 17 00:00:00 2001 From: webadderall <131426131+webadderall@users.noreply.github.com> Date: Tue, 21 Apr 2026 19:22:05 +1000 Subject: [PATCH] fix(export): handle loopback media URLs during export --- src/lib/exporter/forwardFrameSource.ts | 30 +++------------- src/lib/exporter/localMediaSource.test.ts | 21 +++++++++++- src/lib/exporter/localMediaSource.ts | 42 +++++++++++++++++++++-- src/lib/exporter/modernVideoExporter.ts | 32 ++--------------- src/lib/exporter/streamingDecoder.test.ts | 35 ++++++++++++++++++- src/lib/exporter/streamingDecoder.ts | 23 +++---------- src/lib/exporter/videoExporter.ts | 32 ++--------------- 7 files changed, 105 insertions(+), 110 deletions(-) diff --git a/src/lib/exporter/forwardFrameSource.ts b/src/lib/exporter/forwardFrameSource.ts index c719c62e..2c3086c7 100644 --- a/src/lib/exporter/forwardFrameSource.ts +++ b/src/lib/exporter/forwardFrameSource.ts @@ -1,6 +1,7 @@ import { WebDemuxer } from "web-demuxer"; import { getEffectiveVideoStreamDurationSeconds } from "@/lib/mediaTiming"; import { getDecodedFrameTimelineOffsetUs } from "./streamingDecoder"; +import { getLocalFilePath } from "./localMediaSource"; const DEFAULT_MAX_DECODE_QUEUE = 12; const DEFAULT_MAX_PENDING_FRAMES = 32; @@ -38,30 +39,6 @@ export class ForwardFrameSource { private firstFrameTimestampUs: number | null = null; private frameTimelineOffsetUs = 0; - private toLocalFilePath(resourceUrl: string): string | null { - if (!resourceUrl.startsWith("file:")) { - return null; - } - - try { - const url = new URL(resourceUrl); - let filePath = decodeURIComponent(url.pathname); - if (url.host && url.host !== "localhost") { - return `\\\\${url.host}${filePath.replace(/\//g, "\\")}`; - } - if (/^\/[A-Za-z]:/.test(filePath)) { - filePath = filePath.slice(1); - } - return filePath; - } catch { - const uncMatch = resourceUrl.match(/^file:\/\/([^/]+)(\/.*)$/i); - if (uncMatch && uncMatch[1].toLowerCase() !== "localhost") { - return `\\\\${uncMatch[1]}${decodeURIComponent(uncMatch[2]).replace(/\//g, "\\")}`; - } - return resourceUrl.replace(/^file:\/\//, ""); - } - } - private inferMimeType(fileName: string): string { const extension = fileName.split(".").pop()?.toLowerCase(); switch (extension) { @@ -80,8 +57,9 @@ export class ForwardFrameSource { } private async loadVideoFile(resourceUrl: string): Promise { - const filename = resourceUrl.split("/").pop() || "video"; - const localFilePath = this.toLocalFilePath(resourceUrl); + const localFilePath = getLocalFilePath(resourceUrl); + const filename = + (localFilePath ?? resourceUrl).split(/[\\/]/).pop()?.split("?")[0] || "video"; if (localFilePath) { const result = await window.electronAPI.readLocalFile(localFilePath); diff --git a/src/lib/exporter/localMediaSource.test.ts b/src/lib/exporter/localMediaSource.test.ts index 3a7f2a08..bf36a5c2 100644 --- a/src/lib/exporter/localMediaSource.test.ts +++ b/src/lib/exporter/localMediaSource.test.ts @@ -2,6 +2,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { resolveMediaElementSource } from "./localMediaSource"; +const NativeURL = URL; + describe("resolveMediaElementSource", () => { beforeEach(() => { vi.restoreAllMocks(); @@ -12,10 +14,12 @@ describe("resolveMediaElementSource", () => { }, }, }); - vi.stubGlobal("URL", { + class MockURL extends NativeURL {} + Object.assign(MockURL, { createObjectURL: vi.fn(() => "blob:mock-local-media"), revokeObjectURL: vi.fn(), }); + vi.stubGlobal("URL", MockURL); }); it("reads file URLs through Electron IPC and returns an object URL", async () => { @@ -48,6 +52,21 @@ describe("resolveMediaElementSource", () => { expect(result.src).toBe("blob:mock-local-media"); }); + it("reads loopback media-server URLs through Electron IPC", async () => { + const readLocalFile = vi.fn(async () => ({ + success: true, + data: new Uint8Array([7, 8, 9]), + })); + (window as any).electronAPI.readLocalFile = readLocalFile; + + const result = await resolveMediaElementSource( + "http://127.0.0.1:43123/video?path=%2Ftmp%2Fexample%20clip.mp4", + ); + + expect(readLocalFile).toHaveBeenCalledWith("/tmp/example clip.mp4"); + expect(result.src).toBe("blob:mock-local-media"); + }); + it("leaves remote URLs untouched", async () => { const readLocalFile = vi.fn(); (window as any).electronAPI.readLocalFile = readLocalFile; diff --git a/src/lib/exporter/localMediaSource.ts b/src/lib/exporter/localMediaSource.ts index b4a7dd58..5483fc16 100644 --- a/src/lib/exporter/localMediaSource.ts +++ b/src/lib/exporter/localMediaSource.ts @@ -2,8 +2,9 @@ import { fromFileUrl, toFileUrl } from "@/components/video-editor/projectPersist const NOOP = () => undefined; const REMOTE_MEDIA_URL_PATTERN = /^(https?:|blob:|data:)/i; +const LOOPBACK_MEDIA_HOSTS = new Set(["127.0.0.1", "localhost"]); -function isAbsoluteLocalPath(resource: string) { +export function isAbsoluteLocalPath(resource: string) { return ( resource.startsWith("/") || /^[A-Za-z]:[\\/]/.test(resource) || @@ -11,7 +12,34 @@ function isAbsoluteLocalPath(resource: string) { ); } -function getLocalFilePath(resource: string) { +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; +} + +export function getLocalFilePath(resource: string) { + const localMediaServerPath = getLocalMediaServerPath(resource); + if (localMediaServerPath) { + return localMediaServerPath; + } + if (/^file:\/\//i.test(resource)) { return fromFileUrl(resource); } @@ -19,12 +47,20 @@ function getLocalFilePath(resource: string) { return isAbsoluteLocalPath(resource) ? resource : null; } +function isRemoteMediaResource(resource: string) { + return REMOTE_MEDIA_URL_PATTERN.test(resource) && !isLocalMediaServerUrl(resource); +} + function getNormalizedResourceUrl(resource: string) { const localFilePath = getLocalFilePath(resource); if (!localFilePath) { return resource; } + if (isLocalMediaServerUrl(resource)) { + return resource; + } + return /^file:\/\//i.test(resource) ? resource : toFileUrl(localFilePath); } @@ -51,7 +87,7 @@ export async function resolveMediaElementSource(resource: string): Promise<{ src: string; revoke: () => void; }> { - if (!resource || REMOTE_MEDIA_URL_PATTERN.test(resource)) { + if (!resource || isRemoteMediaResource(resource)) { return { src: resource, revoke: NOOP }; } diff --git a/src/lib/exporter/modernVideoExporter.ts b/src/lib/exporter/modernVideoExporter.ts index fea3219a..843c56ef 100644 --- a/src/lib/exporter/modernVideoExporter.ts +++ b/src/lib/exporter/modernVideoExporter.ts @@ -29,6 +29,7 @@ import { INITIAL_FINALIZATION_PROGRESS_STATE, withFinalizationTimeout, } from "./finalizationTimeout"; +import { getLocalFilePath } from "./localMediaSource"; import { FrameRenderer as ModernFrameRenderer } from "./modernFrameRenderer"; import { getOrderedSupportedMp4EncoderCandidates, @@ -681,36 +682,7 @@ export class ModernVideoExporter { } private getNativeVideoSourcePath(): string | null { - const resource = this.config.videoUrl; - if (!resource) { - return null; - } - - if (/^file:\/\//i.test(resource)) { - try { - const url = new URL(resource); - const pathname = decodeURIComponent(url.pathname); - if (url.host && url.host !== "localhost") { - return `//${url.host}${pathname}`; - } - if (/^\/[A-Za-z]:/.test(pathname)) { - return pathname.slice(1); - } - return pathname; - } catch { - return resource.replace(/^file:\/\//i, ""); - } - } - - if ( - resource.startsWith("/") || - /^[A-Za-z]:[\\/]/.test(resource) || - /^\\\\[^\\]+\\[^\\]+/.test(resource) - ) { - return resource; - } - - return null; + return this.config.videoUrl ? getLocalFilePath(this.config.videoUrl) : null; } private buildNativeTrimSegments(durationMs: number): Array<{ startMs: number; endMs: number }> { diff --git a/src/lib/exporter/streamingDecoder.test.ts b/src/lib/exporter/streamingDecoder.test.ts index d9a7326e..38a2012c 100644 --- a/src/lib/exporter/streamingDecoder.test.ts +++ b/src/lib/exporter/streamingDecoder.test.ts @@ -1,9 +1,42 @@ -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { getDecodedFrameStartupOffsetUs, getDecodedFrameTimelineOffsetUs, + StreamingVideoDecoder, } from "./streamingDecoder"; +describe("StreamingVideoDecoder local media loading", () => { + beforeEach(() => { + vi.restoreAllMocks(); + Object.assign(globalThis, { + window: { + electronAPI: { + readLocalFile: vi.fn(), + }, + }, + }); + }); + + it("loads loopback media-server URLs through Electron IPC instead of fetch", async () => { + const readLocalFile = vi.fn(async () => ({ + success: true, + data: new Uint8Array([1, 2, 3]), + })); + (window as any).electronAPI.readLocalFile = readLocalFile; + const fetchSpy = vi.fn(); + vi.stubGlobal("fetch", fetchSpy); + + const decoder = new StreamingVideoDecoder(); + const file = await (decoder as any).loadVideoFile( + "http://127.0.0.1:43123/video?path=%2Ftmp%2Fcapture.mp4", + ); + + expect(readLocalFile).toHaveBeenCalledWith("/tmp/capture.mp4"); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(file.name).toBe("capture.mp4"); + }); +}); + describe("getDecodedFrameStartupOffsetUs", () => { it("ignores positive stream start metadata when the first decoded frame matches it", () => { expect( diff --git a/src/lib/exporter/streamingDecoder.ts b/src/lib/exporter/streamingDecoder.ts index cfc1a9d9..d13ff888 100644 --- a/src/lib/exporter/streamingDecoder.ts +++ b/src/lib/exporter/streamingDecoder.ts @@ -1,6 +1,7 @@ import { WebDemuxer } from "web-demuxer"; import type { SpeedRegion, TrimRegion } from "@/components/video-editor/types"; import { getEffectiveVideoStreamDurationSeconds } from "@/lib/mediaTiming"; +import { getLocalFilePath } from "./localMediaSource"; const DEFAULT_MAX_DECODE_QUEUE = 12; const DEFAULT_MAX_PENDING_FRAMES = 32; @@ -79,23 +80,6 @@ export class StreamingVideoDecoder { ); } - private toLocalFilePath(resourceUrl: string): string | null { - if (!resourceUrl.startsWith("file:")) { - return null; - } - - try { - const url = new URL(resourceUrl); - let filePath = decodeURIComponent(url.pathname); - if (/^\/[A-Za-z]:/.test(filePath)) { - filePath = filePath.slice(1); - } - return filePath; - } catch { - return resourceUrl.replace(/^file:\/\//, ""); - } - } - private inferMimeType(fileName: string): string { const extension = fileName.split(".").pop()?.toLowerCase(); switch (extension) { @@ -114,8 +98,9 @@ export class StreamingVideoDecoder { } private async loadVideoFile(resourceUrl: string): Promise { - const filename = resourceUrl.split("/").pop() || "video"; - const localFilePath = this.toLocalFilePath(resourceUrl); + const localFilePath = getLocalFilePath(resourceUrl); + const filename = + (localFilePath ?? resourceUrl).split(/[\\/]/).pop()?.split("?")[0] || "video"; if (localFilePath) { const result = await window.electronAPI.readLocalFile(localFilePath); diff --git a/src/lib/exporter/videoExporter.ts b/src/lib/exporter/videoExporter.ts index 87f7bcc3..42f4995a 100644 --- a/src/lib/exporter/videoExporter.ts +++ b/src/lib/exporter/videoExporter.ts @@ -21,6 +21,7 @@ import { withFinalizationTimeout, } from "./finalizationTimeout"; import { FrameRenderer } from "./frameRenderer"; +import { getLocalFilePath } from "./localMediaSource"; import type { SupportedMp4EncoderPath } from "./mp4Support"; import { VideoMuxer } from "./muxer"; import { type DecodedVideoInfo, StreamingVideoDecoder } from "./streamingDecoder"; @@ -448,36 +449,7 @@ export class VideoExporter { } private getNativeVideoSourcePath(): string | null { - const resource = this.config.videoUrl; - if (!resource) { - return null; - } - - if (/^file:\/\//i.test(resource)) { - try { - const url = new URL(resource); - const pathname = decodeURIComponent(url.pathname); - if (url.host && url.host !== "localhost") { - return `//${url.host}${pathname}`; - } - if (/^\/[A-Za-z]:/.test(pathname)) { - return pathname.slice(1); - } - return pathname; - } catch { - return resource.replace(/^file:\/\//i, ""); - } - } - - if ( - resource.startsWith("/") || - /^[A-Za-z]:[\\/]/.test(resource) || - /^\\\\[^\\]+\\[^\\]+/.test(resource) - ) { - return resource; - } - - return null; + return this.config.videoUrl ? getLocalFilePath(this.config.videoUrl) : null; } private buildNativeTrimSegments(durationMs: number): Array<{ startMs: number; endMs: number }> {