From 3c12536a2e3962582485b453ef7351d84959f95e Mon Sep 17 00:00:00 2001 From: wiiiii123 Date: Fri, 10 Jul 2026 15:53:18 +0700 Subject: [PATCH] fix(export): keep large local media on the streaming path --- src/lib/exporter/forwardFrameSource.ts | 9 +-- src/lib/exporter/localMediaSource.test.ts | 68 +++++++++++++------ src/lib/exporter/localMediaSource.ts | 35 ++++------ .../modernVideoExporter.fallback.test.ts | 6 +- src/lib/exporter/modernVideoExporter.ts | 34 +++++----- src/lib/exporter/streamingDecoder.test.ts | 50 ++++++++------ src/lib/exporter/streamingDecoder.ts | 12 ++-- 7 files changed, 119 insertions(+), 95 deletions(-) diff --git a/src/lib/exporter/forwardFrameSource.ts b/src/lib/exporter/forwardFrameSource.ts index 34fbc1f2..bd1afde7 100644 --- a/src/lib/exporter/forwardFrameSource.ts +++ b/src/lib/exporter/forwardFrameSource.ts @@ -1,10 +1,7 @@ import { WebDemuxer } from "web-demuxer"; import { getEffectiveVideoStreamDurationSeconds } from "@/lib/mediaTiming"; +import { createFallbackDemuxerSource, resolveMediaResourceUrl } from "./localMediaSource"; import { getDecodedFrameTimelineOffsetUs } from "./streamingDecoder"; -import { - createReadableMediaResourceFile, - resolveMediaResourceUrl, -} from "./localMediaSource"; const DEFAULT_MAX_DECODE_QUEUE = 12; const DEFAULT_MAX_PENDING_FRAMES = 32; @@ -59,7 +56,7 @@ export class ForwardFrameSource { mediaInfo = await loadMediaInfo(resourceUrl); } catch (error) { console.warn( - "[ForwardFrameSource] Direct source load failed, retrying with file fallback:", + "[ForwardFrameSource] Direct source load failed, retrying with a fresh media source:", error, ); const currentDemuxer = this.demuxer; @@ -70,7 +67,7 @@ export class ForwardFrameSource { // Ignore cleanup errors before fallback re-init. } } - mediaInfo = await loadMediaInfo(await createReadableMediaResourceFile(videoUrl)); + mediaInfo = await loadMediaInfo(await createFallbackDemuxerSource(videoUrl)); } const videoStream = mediaInfo.streams.find( diff --git a/src/lib/exporter/localMediaSource.test.ts b/src/lib/exporter/localMediaSource.test.ts index 883370ed..73cbc9b8 100644 --- a/src/lib/exporter/localMediaSource.test.ts +++ b/src/lib/exporter/localMediaSource.test.ts @@ -1,18 +1,27 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { resolveMediaElementSource } from "./localMediaSource"; +import { createFallbackDemuxerSource, resolveMediaElementSource } from "./localMediaSource"; + +const readLocalFile = vi.fn(); +const getLocalMediaUrl = vi.fn(async (filePath: string) => ({ + success: true, + url: `http://127.0.0.1:4321/video?path=${encodeURIComponent(filePath)}`, +})); describe("resolveMediaElementSource", () => { beforeEach(() => { vi.restoreAllMocks(); + readLocalFile.mockReset(); + getLocalMediaUrl.mockReset(); + getLocalMediaUrl.mockImplementation(async (filePath: string) => ({ + success: true, + url: `http://127.0.0.1:4321/video?path=${encodeURIComponent(filePath)}`, + })); Object.assign(globalThis, { window: { electronAPI: { - readLocalFile: vi.fn(), - getLocalMediaUrl: vi.fn(async (filePath: string) => ({ - success: true, - url: `http://127.0.0.1:4321/video?path=${encodeURIComponent(filePath)}`, - })), + readLocalFile, + getLocalMediaUrl, }, }, }); @@ -21,20 +30,16 @@ describe("resolveMediaElementSource", () => { it("resolves file URLs through the local media server for media elements", async () => { const result = await resolveMediaElementSource("file:///tmp/example.mp4"); - expect((window as any).electronAPI.readLocalFile).not.toHaveBeenCalled(); - expect((window as any).electronAPI.getLocalMediaUrl).toHaveBeenCalledWith( - "/tmp/example.mp4", - ); + expect(readLocalFile).not.toHaveBeenCalled(); + expect(getLocalMediaUrl).toHaveBeenCalledWith("/tmp/example.mp4"); expect(result.src).toBe("http://127.0.0.1:4321/video?path=%2Ftmp%2Fexample.mp4"); }); it("resolves absolute local paths through the local media server without copying them into blobs", async () => { const result = await resolveMediaElementSource("/tmp/example.wav"); - expect((window as any).electronAPI.readLocalFile).not.toHaveBeenCalled(); - expect((window as any).electronAPI.getLocalMediaUrl).toHaveBeenCalledWith( - "/tmp/example.wav", - ); + expect(readLocalFile).not.toHaveBeenCalled(); + expect(getLocalMediaUrl).toHaveBeenCalledWith("/tmp/example.wav"); expect(result.src).toBe("http://127.0.0.1:4321/video?path=%2Ftmp%2Fexample.wav"); }); @@ -43,20 +48,39 @@ describe("resolveMediaElementSource", () => { "http://127.0.0.1:43123/video?path=%2Ftmp%2Fexample%20clip.mp4", ); - expect((window as any).electronAPI.readLocalFile).not.toHaveBeenCalled(); - expect((window as any).electronAPI.getLocalMediaUrl).not.toHaveBeenCalled(); - expect(result.src).toBe( - "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"); }); it("leaves remote URLs untouched", async () => { - const readLocalFile = vi.fn(); - (window as any).electronAPI.readLocalFile = readLocalFile; - const result = await resolveMediaElementSource("https://example.com/video.mp4"); expect(result.src).toBe("https://example.com/video.mp4"); expect(readLocalFile).not.toHaveBeenCalled(); }); + + it("keeps local demuxer fallback on the range-streamed media URL", async () => { + const source = await createFallbackDemuxerSource("/tmp/large-recording.mp4"); + + expect(source).toBe("http://127.0.0.1:4321/video?path=%2Ftmp%2Flarge-recording.mp4"); + expect(readLocalFile).not.toHaveBeenCalled(); + }); + + it("retains the readable File fallback for remote media", async () => { + const fetchMock = vi.fn(async () => ({ + ok: true, + blob: async () => new Blob([new Uint8Array([1, 2, 3])], { type: "video/mp4" }), + })); + vi.stubGlobal("fetch", fetchMock); + + try { + const source = await createFallbackDemuxerSource("https://example.com/video.mp4"); + + expect(source).toBeInstanceOf(File); + expect(fetchMock).toHaveBeenCalledWith("https://example.com/video.mp4"); + } finally { + vi.unstubAllGlobals(); + } + }); }); diff --git a/src/lib/exporter/localMediaSource.ts b/src/lib/exporter/localMediaSource.ts index 4f5a1b57..984d959c 100644 --- a/src/lib/exporter/localMediaSource.ts +++ b/src/lib/exporter/localMediaSource.ts @@ -116,36 +116,29 @@ export async function resolveMediaResourceUrl(resource: string): Promise return /^file:\/\//i.test(resource) ? resource : toFileUrl(localFilePath); } -export async function createReadableMediaResourceFile(resource: string): Promise { - const localFilePath = getLocalFilePath(resource); - const filename = (localFilePath ?? resource).split(/[\\/]/).pop()?.split("?")[0] || "media"; - - if (localFilePath && typeof window !== "undefined" && window.electronAPI?.readLocalFile) { - const result = await window.electronAPI.readLocalFile(localFilePath); - if (!result.success || !result.data) { - throw new Error(result.error || "Failed to read local media file"); - } - - const bytes = result.data instanceof Uint8Array ? result.data : new Uint8Array(result.data); - const arrayBuffer = bytes.buffer.slice( - bytes.byteOffset, - bytes.byteOffset + bytes.byteLength, - ) as ArrayBuffer; - return new File([arrayBuffer], filename, { type: inferMimeType(filename) }); - } - +async function createReadableMediaResourceFile(resource: string): Promise { + const filename = resource.split(/[\\/]/).pop()?.split("?")[0] || "media"; const resourceUrl = await resolveMediaResourceUrl(resource); const response = await fetch(resourceUrl); if (!response.ok) { - throw new Error( - `Failed to load media resource: ${response.status} ${response.statusText}`, - ); + throw new Error(`Failed to load media resource: ${response.status} ${response.statusText}`); } const blob = await response.blob(); return new File([blob], filename, { type: blob.type || inferMimeType(filename) }); } +export async function createFallbackDemuxerSource(resource: string): Promise { + // Local media already has a random-access transport: WebDemuxer issues bounded + // byte-range requests against this URL. Converting it to a File would copy the + // complete recording through Electron IPC and make memory use scale with file size. + if (getLocalFilePath(resource)) { + return resolveMediaResourceUrl(resource); + } + + return createReadableMediaResourceFile(resource); +} + export async function resolveMediaElementSource(resource: string): Promise<{ src: string; revoke: () => void; diff --git a/src/lib/exporter/modernVideoExporter.fallback.test.ts b/src/lib/exporter/modernVideoExporter.fallback.test.ts index 5431f7d7..e1afc68c 100644 --- a/src/lib/exporter/modernVideoExporter.fallback.test.ts +++ b/src/lib/exporter/modernVideoExporter.fallback.test.ts @@ -238,7 +238,7 @@ describe("ModernVideoExporter native fallback routing", () => { expect(mocks.streamingDecoderLoadMetadata).not.toHaveBeenCalled(); }, 15_000); - it("retries the main decode path once with a readable file-backed source", async () => { + it("retries the main decode path once with a fresh media source", async () => { mocks.streamingDecoderGetEffectiveDuration.mockReturnValue(1); mocks.streamingDecoderDecodeAll .mockRejectedValueOnce( @@ -277,13 +277,13 @@ describe("ModernVideoExporter native fallback routing", () => { expect(mocks.streamingDecoderLoadMetadata.mock.calls[0]).toEqual([ "file:///recording.mp4", { - forceReadableFileSource: false, + useFallbackMediaSource: false, }, ]); expect(mocks.streamingDecoderLoadMetadata.mock.calls[1]).toEqual([ "file:///recording.mp4", { - forceReadableFileSource: true, + useFallbackMediaSource: true, }, ]); expect(mocks.streamingDecoderDecodeAll).toHaveBeenCalledTimes(2); diff --git a/src/lib/exporter/modernVideoExporter.ts b/src/lib/exporter/modernVideoExporter.ts index a1983599..c77ba964 100644 --- a/src/lib/exporter/modernVideoExporter.ts +++ b/src/lib/exporter/modernVideoExporter.ts @@ -288,7 +288,7 @@ type NativeStaticLayoutZoomSample = { }; const NATIVE_EXPORT_ENGINE_NAME = "Breeze"; -const READABLE_SOURCE_RETRY_ERROR_TOKENS = [ +const MEDIA_SOURCE_RETRY_ERROR_TOKENS = [ "readavpacket", "get_media_info", "avfoundation", @@ -371,11 +371,11 @@ export class ModernVideoExporter { } async export(): Promise { - let preferReadableFileSource = false; - let retriedWithReadableFileSource = false; + let useFallbackMediaSource = false; + let retriedWithFallbackMediaSource = false; while (true) { - let shouldRetryWithReadableFileSource = false; + let shouldRetryWithFallbackMediaSource = false; try { this.cleanup(); this.cancelled = false; @@ -522,7 +522,7 @@ export class ModernVideoExporter { }); stageStartedAt = this.getNowMs(); const videoInfo = await this.streamingDecoder.loadMetadata(this.config.videoUrl, { - forceReadableFileSource: preferReadableFileSource, + useFallbackMediaSource, }); this.metadataLoadTimeMs = this.getNowMs() - stageStartedAt; const nativeAudioPlan = this.buildNativeAudioPlan(videoInfo); @@ -892,15 +892,15 @@ export class ModernVideoExporter { }; } catch (error) { if ( - !preferReadableFileSource && - !retriedWithReadableFileSource && - this.shouldRetryWithReadableFileSource(error) + !useFallbackMediaSource && + !retriedWithFallbackMediaSource && + this.shouldRetryWithFallbackMediaSource(error) ) { - retriedWithReadableFileSource = true; - preferReadableFileSource = true; - shouldRetryWithReadableFileSource = true; + retriedWithFallbackMediaSource = true; + useFallbackMediaSource = true; + shouldRetryWithFallbackMediaSource = true; console.warn( - "[VideoExporter] Primary decode path failed; retrying export once with a readable file-backed media source.", + "[VideoExporter] Primary decode path failed; retrying export once with a fresh media source.", error, ); } else { @@ -921,7 +921,7 @@ export class ModernVideoExporter { }; } } finally { - if (!shouldRetryWithReadableFileSource && this.totalExportStartTimeMs > 0) { + if (!shouldRetryWithFallbackMediaSource && this.totalExportStartTimeMs > 0) { console.log( `[VideoExporter] Final metrics ${JSON.stringify(this.buildExportMetrics())}`, ); @@ -929,20 +929,18 @@ export class ModernVideoExporter { this.cleanup(); } - if (shouldRetryWithReadableFileSource) { + if (shouldRetryWithFallbackMediaSource) { continue; } } } - private shouldRetryWithReadableFileSource(error: unknown): boolean { + private shouldRetryWithFallbackMediaSource(error: unknown): boolean { const resolvedError = this.encoderError ?? error; const message = resolvedError instanceof Error ? resolvedError.message : String(resolvedError); const normalizedMessage = message.toLowerCase(); - return READABLE_SOURCE_RETRY_ERROR_TOKENS.some((token) => - normalizedMessage.includes(token), - ); + return MEDIA_SOURCE_RETRY_ERROR_TOKENS.some((token) => normalizedMessage.includes(token)); } private getPlatformLabel(): string { diff --git a/src/lib/exporter/streamingDecoder.test.ts b/src/lib/exporter/streamingDecoder.test.ts index de831671..dea43e96 100644 --- a/src/lib/exporter/streamingDecoder.test.ts +++ b/src/lib/exporter/streamingDecoder.test.ts @@ -40,6 +40,12 @@ vi.mock("web-demuxer", () => ({ }, })); +const mockReadLocalFile = vi.fn(); +const mockGetLocalMediaUrl = vi.fn(async (filePath: string) => ({ + success: true, + url: `http://127.0.0.1:4321/video?path=${encodeURIComponent(filePath)}`, +})); + describe("StreamingVideoDecoder local media loading", () => { beforeEach(() => { vi.restoreAllMocks(); @@ -47,17 +53,20 @@ describe("StreamingVideoDecoder local media loading", () => { mockDemuxerGetMediaInfo.mockClear(); mockDemuxerDestroy.mockClear(); mockDemuxerGetDecoderConfig.mockClear(); + mockReadLocalFile.mockReset(); + mockGetLocalMediaUrl.mockReset(); + mockGetLocalMediaUrl.mockImplementation(async (filePath: string) => ({ + success: true, + url: `http://127.0.0.1:4321/video?path=${encodeURIComponent(filePath)}`, + })); Object.assign(globalThis, { window: { location: { href: "http://localhost:5173/", }, electronAPI: { - readLocalFile: vi.fn(), - getLocalMediaUrl: vi.fn(async (filePath: string) => ({ - success: true, - url: `http://127.0.0.1:4321/video?path=${encodeURIComponent(filePath)}`, - })), + readLocalFile: mockReadLocalFile, + getLocalMediaUrl: mockGetLocalMediaUrl, }, }, }); @@ -67,45 +76,48 @@ describe("StreamingVideoDecoder local media loading", () => { const decoder = new StreamingVideoDecoder(); await decoder.loadMetadata("http://127.0.0.1:43123/video?path=%2Ftmp%2Fcapture.mp4"); - expect((window as any).electronAPI.readLocalFile).not.toHaveBeenCalled(); + expect(mockReadLocalFile).not.toHaveBeenCalled(); expect(mockDemuxerLoad).toHaveBeenCalledWith( "http://127.0.0.1:43123/video?path=%2Ftmp%2Fcapture.mp4", ); }); - it("normalizes absolute local paths to file URLs before loading them", async () => { + it("resolves absolute local paths to range-streamed media URLs", async () => { const decoder = new StreamingVideoDecoder(); await decoder.loadMetadata("/tmp/capture.mp4"); - expect((window as any).electronAPI.getLocalMediaUrl).toHaveBeenCalledWith( - "/tmp/capture.mp4", - ); + expect(mockGetLocalMediaUrl).toHaveBeenCalledWith("/tmp/capture.mp4"); expect(mockDemuxerLoad).toHaveBeenCalledWith( "http://127.0.0.1:4321/video?path=%2Ftmp%2Fcapture.mp4", ); }); - it("falls back to a readable File when direct loading fails", async () => { + it("retries the range-streamed URL when direct local loading fails", async () => { mockDemuxerLoad.mockReset(); mockDemuxerLoad .mockRejectedValueOnce(new Error("get_media_info failed: Failed after 3 attempts")) .mockResolvedValueOnce(undefined); - (window as any).electronAPI.readLocalFile = vi.fn(async () => ({ - success: true, - data: new Uint8Array([1, 2, 3]), - })); const decoder = new StreamingVideoDecoder(); await decoder.loadMetadata("/tmp/fallback.mp4"); expect(mockDemuxerLoad).toHaveBeenNthCalledWith( - 1, + 2, "http://127.0.0.1:4321/video?path=%2Ftmp%2Ffallback.mp4", ); - expect(mockDemuxerLoad.mock.calls[1]?.[0]).toBeInstanceOf(File); - expect((window as any).electronAPI.readLocalFile).toHaveBeenCalledWith( - "/tmp/fallback.mp4", + expect(mockReadLocalFile).not.toHaveBeenCalled(); + }); + + it("keeps an explicit local retry on the range-streamed URL", async () => { + const decoder = new StreamingVideoDecoder(); + await decoder.loadMetadata("/tmp/retry.mp4", { + useFallbackMediaSource: true, + }); + + expect(mockDemuxerLoad).toHaveBeenCalledWith( + "http://127.0.0.1:4321/video?path=%2Ftmp%2Fretry.mp4", ); + expect(mockReadLocalFile).not.toHaveBeenCalled(); }); }); diff --git a/src/lib/exporter/streamingDecoder.ts b/src/lib/exporter/streamingDecoder.ts index 861d6720..004f1fec 100644 --- a/src/lib/exporter/streamingDecoder.ts +++ b/src/lib/exporter/streamingDecoder.ts @@ -1,7 +1,7 @@ import { WebDemuxer } from "web-demuxer"; import type { SpeedRegion, TrimRegion } from "@/components/video-editor/types"; import { getEffectiveVideoStreamDurationSeconds } from "@/lib/mediaTiming"; -import { createReadableMediaResourceFile, resolveMediaResourceUrl } from "./localMediaSource"; +import { createFallbackDemuxerSource, resolveMediaResourceUrl } from "./localMediaSource"; const DEFAULT_MAX_DECODE_QUEUE = 12; const DEFAULT_MAX_PENDING_FRAMES = 32; @@ -24,7 +24,7 @@ export interface DecodedVideoInfo { } interface StreamingVideoDecoderLoadOptions { - forceReadableFileSource?: boolean; + useFallbackMediaSource?: boolean; } /** Decoder retains ownership of the VideoFrame and closes it after use. */ @@ -126,14 +126,14 @@ export class StreamingVideoDecoder { }; let mediaInfo; - if (options.forceReadableFileSource) { - mediaInfo = await loadMediaInfo(await createReadableMediaResourceFile(videoUrl)); + if (options.useFallbackMediaSource) { + mediaInfo = await loadMediaInfo(await createFallbackDemuxerSource(videoUrl)); } else { try { mediaInfo = await loadMediaInfo(resourceUrl); } catch (error) { console.warn( - "[StreamingVideoDecoder] Direct source load failed, retrying with file fallback:", + "[StreamingVideoDecoder] Direct source load failed, retrying with a fresh media source:", error, ); const currentDemuxer = this.demuxer; @@ -144,7 +144,7 @@ export class StreamingVideoDecoder { // Ignore cleanup errors before fallback re-init. } } - mediaInfo = await loadMediaInfo(await createReadableMediaResourceFile(videoUrl)); + mediaInfo = await loadMediaInfo(await createFallbackDemuxerSource(videoUrl)); } }