diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 72e73d792..b7314ea7c 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -217,6 +217,25 @@ interface Window { message?: string; error?: string; }>; + getReadableFileInfo: (filePath: string) => Promise<{ + success: boolean; + size?: number; + mtimeMs?: number; + path?: string; + message?: string; + error?: string; + }>; + readFileChunk: ( + filePath: string, + offset: number, + length: number, + ) => Promise<{ + success: boolean; + data?: ArrayBuffer; + bytesRead?: number; + message?: string; + error?: string; + }>; preparePreviewAudioTrack: (filePath: string) => Promise<{ success: boolean; path?: string | null; diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index d8a1ee55a..ae8f5525d 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -2580,6 +2580,83 @@ export function registerIpcHandlers( } }); + // Stat an approved video file. Used to decide whether a recording is small + // enough to slurp via read-binary-file, or large enough that it must be + // streamed in chunks (Node's fs.readFile caps a single read at 2 GiB, so any + // recording above that can never be loaded whole — see read-file-chunk). + ipcMain.handle("get-readable-file-info", async (_, filePath: string) => { + try { + const normalizedPath = await approveReadableVideoPath(filePath); + if (!normalizedPath) { + return { + success: false, + message: "File path is not approved or is not a supported video file", + }; + } + + const stat = await fs.stat(normalizedPath); + return { + success: true, + size: stat.size, + mtimeMs: stat.mtimeMs, + path: normalizedPath, + }; + } catch (error) { + console.error("Failed to stat file:", error); + return { + success: false, + message: "Failed to stat file", + error: String(error), + }; + } + }); + + // Cap renderer-requested chunk sizes so a buggy or compromised renderer + // cannot make the main process allocate an arbitrarily large buffer. + const MAX_IPC_CHUNK_BYTES = 64 * 1024 * 1024; + + // Read a byte range [offset, offset+length) from an approved video file. + // Lets the renderer stream a >2 GiB recording into OPFS one chunk at a time + // instead of materialising the whole file in memory, which fs.readFile cannot + // do (2 GiB cap) and a 16 GB machine cannot hold for multi-GB recordings. + ipcMain.handle("read-file-chunk", async (_, filePath: string, offset: number, length: number) => { + try { + const normalizedPath = await approveReadableVideoPath(filePath); + if (!normalizedPath) { + return { + success: false, + message: "File path is not approved or is not a supported video file", + }; + } + if (!Number.isFinite(offset) || offset < 0 || !Number.isFinite(length) || length <= 0) { + return { success: false, message: "Invalid chunk range" }; + } + if (length > MAX_IPC_CHUNK_BYTES) { + return { success: false, message: "Requested chunk size exceeds limit" }; + } + + const handle = await fs.open(normalizedPath, "r"); + try { + const buffer = Buffer.allocUnsafe(length); + const { bytesRead } = await handle.read(buffer, 0, length, offset); + return { + success: true, + data: buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + bytesRead), + bytesRead, + }; + } finally { + await handle.close(); + } + } catch (error) { + console.error("Failed to read file chunk:", error); + return { + success: false, + message: "Failed to read file chunk", + error: String(error), + }; + } + }); + ipcMain.handle("prepare-preview-audio-track", async (_, filePath: string) => { try { return await prepareSupplementalPreviewAudioTrack(filePath); diff --git a/electron/preload.ts b/electron/preload.ts index e0d246bda..32e014953 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -177,6 +177,12 @@ contextBridge.exposeInMainWorld("electronAPI", { readBinaryFile: (filePath: string) => { return ipcRenderer.invoke("read-binary-file", filePath); }, + getReadableFileInfo: (filePath: string) => { + return ipcRenderer.invoke("get-readable-file-info", filePath); + }, + readFileChunk: (filePath: string, offset: number, length: number) => { + return ipcRenderer.invoke("read-file-chunk", filePath, offset, length); + }, preparePreviewAudioTrack: (filePath: string) => { return ipcRenderer.invoke("prepare-preview-audio-track", filePath); }, diff --git a/src/components/video-editor/ExportDialog.tsx b/src/components/video-editor/ExportDialog.tsx index 44eae5d2e..b012617c1 100644 --- a/src/components/video-editor/ExportDialog.tsx +++ b/src/components/video-editor/ExportDialog.tsx @@ -62,6 +62,8 @@ export function ExportDialog({ const isCompiling = isExporting && progress && progress.percentage >= 100 && exportFormat === "gif"; const isFinalizing = progress?.phase === "finalizing"; + // Streaming a large recording into OPFS before frames start rendering. + const isPreparing = progress?.phase === "preparing"; const renderProgress = progress?.renderProgress; const getStatusMessage = () => { @@ -172,7 +174,9 @@ export function ExportDialog({ {isCompiling || isFinalizing ? t("export.compiling") - : t("export.renderingFrames")} + : isPreparing + ? t("export.processing") + : t("export.renderingFrames")} {isCompiling || isFinalizing ? ( @@ -240,7 +244,14 @@ export function ExportDialog({ {t("export.frames")}
- {progress.currentFrame} / {progress.totalFrames} + {isPreparing ? ( + + + {t("export.processing")} + + ) : ( + `${progress.currentFrame} / ${progress.totalFrames}` + )}
diff --git a/src/hooks/streamingAudioPeaks.ts b/src/hooks/streamingAudioPeaks.ts new file mode 100644 index 000000000..8e906ad21 --- /dev/null +++ b/src/hooks/streamingAudioPeaks.ts @@ -0,0 +1,201 @@ +import { WebDemuxer } from "web-demuxer"; +import { audioDataFrameToMono } from "@/lib/captioning/extractMono16kWebDemuxer"; + +/** + * Streaming trim-waveform peaks for recordings too large to load into memory. + * + * The default waveform path reads the whole file and runs `decodeAudioData`, + * which needs the full bytes up front — impossible for multi-GB recordings. + * This module demuxes the audio track with web-demuxer (which reads the File + * on demand), decodes it chunk by chunk with WebCodecs `AudioDecoder`, and + * folds every decoded frame straight into min/max peak buckets, closing the + * frame immediately. Peak memory is the buckets array (≤ 24k blocks ≈ 192 kB) + * plus a handful of in-flight frames, regardless of recording length. + * + * Output matches `audioPeaksWorker.ts` exactly: Float32Array of length 2*N, + * `[min0, max0, min1, max1, ...]`, N = min(24000, ceil(duration * 200)), with + * min/max starting from 0 (silence baseline) and channels averaged per sample. + */ + +const DECODE_QUEUE_BACKPRESSURE = 20; +const LOAD_TIMEOUT_MS = 60_000; +const READ_END_PADDING_SEC = 0.5; +// Keep in sync with audioPeaksWorker.ts so both paths render identically. +const MAX_PEAK_BLOCKS = 24_000; +const PEAK_BLOCKS_PER_SEC = 200; +// Upper bound for the duration fallback scan when container metadata is +// unreliable (MediaRecorder WebM often reports 0/Infinity — see +// streamingDecoder's validateDuration). Same ceiling as the export scan. +const SCAN_UNBOUNDED_FALLBACK_SEC = 24 * 60 * 60; + +/** + * Ground-truth duration from audio packet timestamps, for containers whose + * metadata duration is missing or bogus. Demux-only (no decode), so it is a + * fast forward pass even for multi-GB files. + */ +async function scanAudioDurationSec(demuxer: WebDemuxer, signal?: AbortSignal): Promise { + const reader = demuxer.read("audio", 0, SCAN_UNBOUNDED_FALLBACK_SEC).getReader(); + let maxEndUs = 0; + try { + while (!signal?.aborted) { + const { done, value: chunk } = await reader.read(); + if (done || !chunk) break; + const endUs = chunk.timestamp + (chunk.duration ?? 0); + if (endUs > maxEndUs) maxEndUs = endUs; + } + } finally { + try { + await reader.cancel(); + } catch { + /* already closed */ + } + } + if (signal?.aborted) throw new DOMException("Aborted", "AbortError"); + return maxEndUs / 1e6; +} + +function withTimeout(promise: Promise, ms: number, message: string): Promise { + return new Promise((resolve, reject) => { + const id = window.setTimeout(() => reject(new Error(message)), ms); + promise + .then((v) => { + window.clearTimeout(id); + resolve(v); + }) + .catch((e) => { + window.clearTimeout(id); + reject(e instanceof Error ? e : new Error(String(e))); + }); + }); +} + +/** + * Computes trim-waveform peaks from a (typically OPFS-backed) File without ever + * holding the decoded PCM in memory. Throws on no/unsupported audio track; the + * caller (useAudioPeaks) degrades to no waveform. + */ +export async function computePeaksFromFileStreaming( + file: File, + signal?: AbortSignal, +): Promise { + const wasmUrl = new URL("./wasm/web-demuxer.wasm", window.location.href).href; + const demuxer = new WebDemuxer({ wasmFilePath: wasmUrl }); + try { + await withTimeout( + demuxer.load(file), + LOAD_TIMEOUT_MS, + "Timed out while parsing the source video for the waveform.", + ); + if (signal?.aborted) throw new DOMException("Aborted", "AbortError"); + + const mediaInfo = await withTimeout( + demuxer.getMediaInfo(), + LOAD_TIMEOUT_MS, + "Timed out while reading media info for the waveform.", + ); + + let audioConfig: AudioDecoderConfig; + try { + audioConfig = await demuxer.getDecoderConfig("audio"); + } catch { + throw new Error("No audio track found in this video."); + } + const codecCheck = await AudioDecoder.isConfigSupported(audioConfig); + if (!codecCheck.supported) { + throw new Error(`Audio codec not supported for waveform: ${audioConfig.codec}`); + } + const sampleRate = audioConfig.sampleRate || 48_000; + + // MediaRecorder WebM often reports a missing/bogus container duration + // (see streamingDecoder's validateDuration); fall back to a demux-only + // packet-timestamp scan so those recordings still get a waveform. + let durationSec = + Number.isFinite(mediaInfo.duration) && mediaInfo.duration > 0 ? mediaInfo.duration : 0; + if (durationSec <= 0) { + durationSec = await scanAudioDurationSec(demuxer, signal); + } + if (durationSec <= 0) { + throw new Error("Unknown duration; cannot bucket waveform peaks."); + } + + const blocks = Math.min(MAX_PEAK_BLOCKS, Math.ceil(durationSec * PEAK_BLOCKS_PER_SEC)); + const totalSamples = Math.max(1, Math.ceil(durationSec * sampleRate)); + const peaks = new Float32Array(blocks * 2); // [min0, max0, min1, max1, ...] + + const foldFrame = (frame: AudioData) => { + const startSample = Math.round((frame.timestamp / 1e6) * sampleRate); + const mono = audioDataFrameToMono(frame); + frame.close(); + for (let i = 0; i < mono.length; i++) { + const pos = startSample + i; + if (pos < 0 || pos >= totalSamples) continue; + let block = Math.floor((pos / totalSamples) * blocks); + if (block >= blocks) block = blocks - 1; + const sample = mono[i]; + if (sample < peaks[block * 2]) peaks[block * 2] = sample; + if (sample > peaks[block * 2 + 1]) peaks[block * 2 + 1] = sample; + } + }; + + let decodedFrames = 0; + let decodeError: DOMException | null = null; + const decoder = new AudioDecoder({ + output: (data: AudioData) => { + decodedFrames++; + foldFrame(data); + }, + error: (e: DOMException) => { + decodeError = e; + }, + }); + decoder.configure(audioConfig); + + try { + const reader = demuxer.read("audio", 0, durationSec + READ_END_PADDING_SEC).getReader(); + try { + while (!signal?.aborted && !decodeError) { + const { done, value: chunk } = await reader.read(); + if (done || !chunk) break; + decoder.decode(chunk); + while (decoder.decodeQueueSize > DECODE_QUEUE_BACKPRESSURE && !signal?.aborted) { + await new Promise((r) => setTimeout(r, 1)); + } + } + } finally { + try { + await reader.cancel(); + } catch { + /* already closed */ + } + } + + // Flush only on the clean path; an aborted or errored decode should + // not wait for the full pipeline to drain. + if (!signal?.aborted && !decodeError && decoder.state === "configured") { + await decoder.flush(); + } + } finally { + // Always release the decoder — a throw in the demux loop must not + // leak a configured AudioDecoder (they hold codec-native memory). + if (decoder.state !== "closed") { + try { + decoder.close(); + } catch { + /* already closed */ + } + } + } + if (signal?.aborted) throw new DOMException("Aborted", "AbortError"); + if (decodeError) throw decodeError; + if (decodedFrames === 0) { + throw new Error("Decoded zero audio frames from this video."); + } + return peaks; + } finally { + try { + demuxer.destroy(); + } catch { + /* already destroyed */ + } + } +} diff --git a/src/hooks/useAudioPeaks.ts b/src/hooks/useAudioPeaks.ts index 337f50344..daa0abf09 100644 --- a/src/hooks/useAudioPeaks.ts +++ b/src/hooks/useAudioPeaks.ts @@ -1,5 +1,8 @@ import { useEffect, useRef, useState } from "react"; +import { materializeLocalSourceFile, releaseLocalSourceFile } from "@/lib/exporter/localSourceFile"; +import { MAX_IN_MEMORY_SOURCE_BYTES } from "@/lib/exporter/sourceFileLimits"; import { loadFileAsArrayBuffer } from "@/lib/exporter/streamingDecoder"; +import { computePeaksFromFileStreaming } from "./streamingAudioPeaks"; let _audioCtx: AudioContext | null = null; /** Returns the shared AudioContext, creating it lazily on first call. */ @@ -58,6 +61,34 @@ function computePeaksInWorker( }); } +/** + * Routes to the right peaks pipeline for the source size. Small/remote files + * use the original decodeAudioData → worker path. Local recordings above the + * in-memory limit stream instead: the file is materialized into OPFS (reused by + * the export afterwards) and its audio is decoded chunk-by-chunk into peaks, so + * the whole recording is never held in memory. + */ +async function computePeaksForUrl(videoUrl: string, signal?: AbortSignal): Promise { + const isRemoteUrl = /^(https?:|blob:|data:)/i.test(videoUrl); + if (!isRemoteUrl && window.electronAPI?.getReadableFileInfo) { + const info = await window.electronAPI.getReadableFileInfo(videoUrl); + if (info.success && typeof info.size === "number" && info.size > MAX_IN_MEMORY_SOURCE_BYTES) { + const filename = (videoUrl.split(/[\\/]/).pop() || "video").replace(/^file:/, ""); + // signal also aborts the OPFS copy (unless the export shares it). + const file = await materializeLocalSourceFile(videoUrl, filename, { signal }); + try { + return await computePeaksFromFileStreaming(file, signal); + } finally { + releaseLocalSourceFile(file.name); + } + } + } + + const { data: arrayBuffer } = await loadFileAsArrayBuffer(videoUrl); + const audioBuffer = await getAudioCtx().decodeAudioData(arrayBuffer); + return computePeaksInWorker(audioBuffer, signal); +} + /** * Decodes audio from `videoUrl` into paired [min, max] peaks (length = 2 * N * blocks). Returns `null` while decoding, and stays `null` on no audio track or @@ -88,11 +119,7 @@ export function useAudioPeaks(videoUrl?: string): Float32Array | null { (async () => { try { - const { data: arrayBuffer } = await loadFileAsArrayBuffer(videoUrl); - if (cancelled) return; - const audioBuffer = await getAudioCtx().decodeAudioData(arrayBuffer); - if (cancelled) return; - const p = await computePeaksInWorker(audioBuffer, controller.signal); + const p = await computePeaksForUrl(videoUrl, controller.signal); if (cancelled) return; cacheRef.current.set(videoUrl, p); setPeaks(p); diff --git a/src/lib/captioning/extractMono16k.ts b/src/lib/captioning/extractMono16k.ts index bce9a9fd8..bf2c7320f 100644 --- a/src/lib/captioning/extractMono16k.ts +++ b/src/lib/captioning/extractMono16k.ts @@ -1,9 +1,17 @@ +import { materializeLocalSourceFile, releaseLocalSourceFile } from "@/lib/exporter/localSourceFile"; +import { MAX_IN_MEMORY_SOURCE_BYTES } from "@/lib/exporter/sourceFileLimits"; import { MAX_CAPTION_AUDIO_SEC } from "./captionConstants"; import { extractMonoPcmViaWebDemuxer } from "./extractMono16kWebDemuxer"; export { MAX_CAPTION_AUDIO_SEC }; const FETCH_TIMEOUT_MS = 120_000; +// The demuxer caption path holds every decoded AudioData frame plus full-rate +// merge buffers in memory (~50 MB per minute of 48 kHz audio all-in), so very +// long recordings would exhaust the renderer heap well before the 4 h caption +// ceiling. For sources too large to load in memory anyway, cap the decoded +// audio; captions come back truncated instead of crashing the renderer. +const LARGE_FILE_CAPTION_SEC = 30 * 60; async function fetchWithTimeout(url: string, signal?: AbortSignal): Promise { const ctrl = new AbortController(); @@ -29,13 +37,12 @@ async function fetchWithTimeout(url: string, signal?: AbortSignal): Promise { const isRemoteUrl = /^(https?:|blob:|data:)/i.test(videoUrl); - if (!isRemoteUrl && window.electronAPI?.readBinaryFile) { - const result = await window.electronAPI.readBinaryFile(videoUrl); - if (!result.success || !result.data) { - throw new Error(result.message || result.error || "Failed to read source video"); - } - const filename = (result.path || videoUrl).split(/[\\/]/).pop() || "video"; - return new File([result.data], filename, { type: "video/webm" }); + if (!isRemoteUrl && window.electronAPI) { + // Streams large recordings through OPFS instead of reading them whole, so + // captions work for multi-GB files just like export does. The signal also + // aborts the copy itself when the caption pass is cancelled. + const filename = (videoUrl.split(/[\\/]/).pop() || "video").replace(/^file:/, ""); + return materializeLocalSourceFile(videoUrl, filename, { signal }); } const response = await fetchWithTimeout(videoUrl, signal); @@ -149,11 +156,32 @@ export async function extractMono16kFromVideoUrl( } }; - const primary = await tryDecodeAudioDataPath(); - if (primary) { - return primary; - } + try { + // Large recordings skip the in-memory decodeAudioData path (it would load + // the whole file) and go straight to the streaming web-demuxer path below. + const isLargeFile = file.size > MAX_IN_MEMORY_SOURCE_BYTES; + const primary = isLargeFile ? null : await tryDecodeAudioDataPath(); + if (primary) { + return primary; + } - const pcm = await extractMonoPcmViaWebDemuxer(file, options?.signal); - return truncateAndResampleTo16k(pcm.mono, pcm.sampleRate, pcm.durationSec, options?.signal); + // For oversized sources, also cap how much audio the demuxer path decodes + // — its frame/merge buffers are in-memory and scale with duration. + const pcm = await extractMonoPcmViaWebDemuxer( + file, + options?.signal, + isLargeFile ? LARGE_FILE_CAPTION_SEC : undefined, + ); + const out = await truncateAndResampleTo16k( + pcm.mono, + pcm.sampleRate, + pcm.durationSec, + options?.signal, + ); + return { ...out, truncated: out.truncated || pcm.capped }; + } finally { + // Release the OPFS cache reference taken when streaming a large source. + // The File name is the cache-entry key (no-op for small/remote sources). + releaseLocalSourceFile(file.name); + } } diff --git a/src/lib/captioning/extractMono16kWebDemuxer.ts b/src/lib/captioning/extractMono16kWebDemuxer.ts index f86b6dc57..641f26351 100644 --- a/src/lib/captioning/extractMono16kWebDemuxer.ts +++ b/src/lib/captioning/extractMono16kWebDemuxer.ts @@ -10,7 +10,8 @@ function webDemuxerWasmUrl(): string { return new URL("../exporter/wasm/web-demuxer.wasm", window.location.href).href; } -function audioDataFrameToMono(frame: AudioData): Float32Array { +/** Mixes one WebCodecs AudioData frame down to mono (averaged across channels). */ +export function audioDataFrameToMono(frame: AudioData): Float32Array { const frames = frame.numberOfFrames; const ch = frame.numberOfChannels; const out = new Float32Array(frames); @@ -91,11 +92,17 @@ function withTimeout(promise: Promise, ms: number, message: string): Promi /** * Demux + WebCodecs audio decode (same stack as export). Use when `decodeAudioData` * can't handle the container (e.g. WebM with video). + * + * @param maxReadSec Optional cap on how much audio to demux/decode. The decoded + * frames and merge buffers are held in memory, so very long recordings must be + * capped below MAX_CAPTION_AUDIO_SEC to avoid exhausting the renderer heap. + * `capped` reports whether the cap actually cut the track short. */ export async function extractMonoPcmViaWebDemuxer( file: File, signal?: AbortSignal, -): Promise<{ mono: Float32Array; sampleRate: number; durationSec: number }> { + maxReadSec?: number, +): Promise<{ mono: Float32Array; sampleRate: number; durationSec: number; capped: boolean }> { const demuxer = new WebDemuxer({ wasmFilePath: webDemuxerWasmUrl() }); await withTimeout( demuxer.load(file), @@ -131,7 +138,8 @@ export async function extractMonoPcmViaWebDemuxer( // Many WebM/Matroska files report a too-short duration, so capping read at reported time stops // demux early and clips everything past that. Read to the caption-decode ceiling instead; the // demuxer stops when the track ends. - const readEndSec = MAX_CAPTION_AUDIO_SEC + READ_END_PADDING_SEC; + const readCapSec = Math.min(maxReadSec ?? MAX_CAPTION_AUDIO_SEC, MAX_CAPTION_AUDIO_SEC); + const readEndSec = readCapSec + READ_END_PADDING_SEC; const decodedFrames: AudioData[] = []; const decoder = new AudioDecoder({ @@ -182,6 +190,9 @@ export async function extractMonoPcmViaWebDemuxer( // metadata when frames lack duration. const durationSec = inferredDurationSec > 0.02 ? inferredDurationSec : reportedDurationSec; + // The cap cut the track short when the reported extent exceeds what we read. + const capped = reportedDurationSec > readCapSec + READ_END_PADDING_SEC; + const mono = mergeAndConsumeDecodedAudioToMonoLinear(decodedFrames, sampleRate, durationSec); - return { mono, sampleRate, durationSec }; + return { mono, sampleRate, durationSec, capped }; } diff --git a/src/lib/exporter/localSourceFile.test.ts b/src/lib/exporter/localSourceFile.test.ts new file mode 100644 index 000000000..7d4b14163 --- /dev/null +++ b/src/lib/exporter/localSourceFile.test.ts @@ -0,0 +1,457 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { materializeLocalSourceFile, releaseLocalSourceFile } from "./localSourceFile"; + +function stubElectronAPI(api: Record) { + vi.stubGlobal("window", { ...globalThis.window, electronAPI: api } as unknown); +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("materializeLocalSourceFile (small file path)", () => { + it("reads a small file in one shot and returns a File with its bytes", async () => { + const bytes = new Uint8Array([1, 2, 3, 4, 5]); + stubElectronAPI({ + getReadableFileInfo: vi.fn().mockResolvedValue({ + success: true, + size: bytes.byteLength, + mtimeMs: 1, + path: "/tmp/small.mp4", + }), + readBinaryFile: vi.fn().mockResolvedValue({ + success: true, + data: bytes.buffer, + path: "/tmp/small.mp4", + }), + }); + + const file = await materializeLocalSourceFile("/tmp/small.mp4", "small.mp4"); + + expect(file).toBeInstanceOf(File); + expect(file.size).toBe(bytes.byteLength); + expect(new Uint8Array(await file.arrayBuffer())).toEqual(bytes); + }); + + it("does not stream a small file through readFileChunk", async () => { + const readFileChunk = vi.fn(); + stubElectronAPI({ + getReadableFileInfo: vi + .fn() + .mockResolvedValue({ success: true, size: 10, mtimeMs: 1, path: "/tmp/s.mp4" }), + readBinaryFile: vi + .fn() + .mockResolvedValue({ success: true, data: new Uint8Array(10).buffer, path: "/tmp/s.mp4" }), + readFileChunk, + }); + + await materializeLocalSourceFile("/tmp/s.mp4", "s.mp4"); + + expect(readFileChunk).not.toHaveBeenCalled(); + }); + + it("throws when the file cannot be stat-ed", async () => { + stubElectronAPI({ + getReadableFileInfo: vi + .fn() + .mockResolvedValue({ success: false, message: "File path is not approved" }), + readBinaryFile: vi.fn(), + }); + + await expect(materializeLocalSourceFile("/tmp/missing.mp4", "x.mp4")).rejects.toThrow( + /not approved/, + ); + }); + + it("throws when the single-shot read fails", async () => { + stubElectronAPI({ + getReadableFileInfo: vi + .fn() + .mockResolvedValue({ success: true, size: 10, mtimeMs: 1, path: "/tmp/s.mp4" }), + readBinaryFile: vi + .fn() + .mockResolvedValue({ success: false, message: "Failed to read binary file" }), + }); + + await expect(materializeLocalSourceFile("/tmp/s.mp4", "s.mp4")).rejects.toThrow( + /Failed to read binary file/, + ); + }); +}); + +// ---- Minimal in-memory OPFS fake for the large-file streaming path ---- + +class FakeWritable { + private parts: Uint8Array[] = []; + constructor(private readonly onClose: (bytes: Uint8Array) => void) {} + async write(data: ArrayBuffer | Uint8Array) { + this.parts.push(data instanceof Uint8Array ? new Uint8Array(data) : new Uint8Array(data)); + } + async close() { + const total = this.parts.reduce((n, p) => n + p.byteLength, 0); + const merged = new Uint8Array(total); + let offset = 0; + for (const p of this.parts) { + merged.set(p, offset); + offset += p.byteLength; + } + this.onClose(merged); + } + async abort() { + this.parts = []; + } +} + +class FakeFileHandle { + bytes = new Uint8Array(0); + constructor(readonly name: string) {} + async getFile() { + return new File([this.bytes], this.name); + } + async createWritable() { + return new FakeWritable((b) => { + this.bytes = b; + }); + } +} + +class FakeDir { + files = new Map(); + subdirs = new Map(); + async getDirectoryHandle(name: string, opts?: { create?: boolean }) { + let dir = this.subdirs.get(name); + if (!dir && opts?.create) { + dir = new FakeDir(); + this.subdirs.set(name, dir); + } + if (!dir) throw new DOMException("NotFound", "NotFoundError"); + return dir as unknown as FileSystemDirectoryHandle; + } + async getFileHandle(name: string, opts?: { create?: boolean }) { + let file = this.files.get(name); + if (!file && opts?.create) { + file = new FakeFileHandle(name); + this.files.set(name, file); + } + if (!file) throw new DOMException("NotFound", "NotFoundError"); + return file as unknown as FileSystemFileHandle; + } + async *keys() { + yield* this.files.keys(); + } + async removeEntry(name: string) { + this.files.delete(name); + } +} + +function stubOpfs(root: FakeDir) { + vi.stubGlobal("navigator", { storage: { getDirectory: async () => root } } as unknown); +} + +function cacheDir(root: FakeDir): FakeDir | undefined { + return root.subdirs.get("openscreen-source-cache"); +} + +/** electronAPI whose readFileChunk serves slices of `source`. */ +function largeSourceApi(url: string, source: Uint8Array, mtimeMs = 1) { + return { + getReadableFileInfo: vi + .fn() + .mockResolvedValue({ success: true, size: source.byteLength, mtimeMs, path: url }), + readBinaryFile: vi.fn(), + readFileChunk: vi.fn(async (_url: string, offset: number, length: number) => ({ + success: true, + data: source.slice(offset, offset + length).buffer, + bytesRead: Math.min(length, source.byteLength - offset), + })), + }; +} + +describe("materializeLocalSourceFile (large file OPFS path)", () => { + const OPTS = { thresholdBytes: 4, chunkBytes: 3 }; + + it("streams a large file into OPFS in chunks and returns the exact bytes", async () => { + const source = new Uint8Array([10, 20, 30, 40, 50, 60, 70]); + const api = largeSourceApi("/rec/a.mp4", source); + stubElectronAPI(api); + stubOpfs(new FakeDir()); + + const file = await materializeLocalSourceFile("/rec/a.mp4", "a.mp4", OPTS); + + expect(file.size).toBe(source.byteLength); + expect(new Uint8Array(await file.arrayBuffer())).toEqual(source); + // 7 bytes / 3-byte chunks => 3 reads. + expect(api.readFileChunk).toHaveBeenCalledTimes(3); + expect(api.readBinaryFile).not.toHaveBeenCalled(); + + releaseLocalSourceFile(file.name); + }); + + it("reuses the cached copy on a second call without re-streaming", async () => { + const source = new Uint8Array([1, 2, 3, 4, 5, 6]); + const api = largeSourceApi("/rec/b.mp4", source); + stubElectronAPI(api); + stubOpfs(new FakeDir()); + + const first = await materializeLocalSourceFile("/rec/b.mp4", "b.mp4", OPTS); + const firstReads = api.readFileChunk.mock.calls.length; + const second = await materializeLocalSourceFile("/rec/b.mp4", "b.mp4", OPTS); + + expect(api.readFileChunk.mock.calls.length).toBe(firstReads); // no new reads + expect(second.name).toBe(first.name); + + releaseLocalSourceFile(first.name); + releaseLocalSourceFile(second.name); + }); + + it("keeps a cache entry that is still referenced by another active source", async () => { + const root = new FakeDir(); + stubOpfs(root); + + stubElectronAPI(largeSourceApi("/rec/a.mp4", new Uint8Array([1, 2, 3, 4, 5]))); + const a = await materializeLocalSourceFile("/rec/a.mp4", "a.mp4", OPTS); // A retained + + stubElectronAPI(largeSourceApi("/rec/b.mp4", new Uint8Array([6, 7, 8, 9, 10]))); + const b = await materializeLocalSourceFile("/rec/b.mp4", "b.mp4", OPTS); // prunes, A active + + // A must NOT have been pruned while still in use. + expect(cacheDir(root)?.files.size).toBe(2); + + releaseLocalSourceFile(a.name); + releaseLocalSourceFile(b.name); + }); + + it("prunes a cache entry once it has been released", async () => { + const root = new FakeDir(); + stubOpfs(root); + + stubElectronAPI(largeSourceApi("/rec/a.mp4", new Uint8Array([1, 2, 3, 4, 5]))); + const a = await materializeLocalSourceFile("/rec/a.mp4", "a.mp4", OPTS); // A retained + + stubElectronAPI(largeSourceApi("/rec/b.mp4", new Uint8Array([6, 7, 8, 9, 10]))); + const b = await materializeLocalSourceFile("/rec/b.mp4", "b.mp4", OPTS); // B retained + + releaseLocalSourceFile(a.name); // A no longer in use + + stubElectronAPI(largeSourceApi("/rec/c.mp4", new Uint8Array([11, 12, 13, 14, 15]))); + const c = await materializeLocalSourceFile("/rec/c.mp4", "c.mp4", OPTS); // prunes A, keeps B+C + + // A pruned; B (still active) and C remain. + expect(cacheDir(root)?.files.size).toBe(2); + + releaseLocalSourceFile(b.name); + releaseLocalSourceFile(c.name); + }); + + it("removes the partial cache entry when a chunk read fails mid-copy", async () => { + const root = new FakeDir(); + stubOpfs(root); + + const source = new Uint8Array([1, 2, 3, 4, 5, 6, 7]); + const api = largeSourceApi("/rec/err.mp4", source); + let reads = 0; + api.readFileChunk = vi.fn(async (_url: string, offset: number, length: number) => { + reads += 1; + if (reads === 2) return { success: false, message: "disk read failed" }; + return { + success: true, + data: source.slice(offset, offset + length).buffer, + bytesRead: Math.min(length, source.byteLength - offset), + }; + }); + stubElectronAPI(api); + + await expect(materializeLocalSourceFile("/rec/err.mp4", "err.mp4", OPTS)).rejects.toThrow( + /disk read failed/, + ); + + // The partial copy is cleaned up and holds no live reference. + expect(cacheDir(root)?.files.size ?? 0).toBe(0); + }); + + it("does not prune an entry that is still being written by a concurrent copy", async () => { + const root = new FakeDir(); + stubOpfs(root); + + const sourceA = new Uint8Array([1, 2, 3, 4, 5, 6]); + const sourceB = new Uint8Array([7, 8, 9, 10, 11]); + let releaseFirstChunk!: () => void; + const gate = new Promise((resolve) => { + releaseFirstChunk = resolve; + }); + const sources: Record = { + "/rec/a.mp4": sourceA, + "/rec/b.mp4": sourceB, + }; + // One shared API serving both URLs; A's first chunk read blocks on the gate + // so A sits mid-copy while B runs to completion (including B's prune pass). + const api = { + getReadableFileInfo: vi.fn(async (url: string) => ({ + success: true, + size: sources[url].byteLength, + mtimeMs: 1, + path: url, + })), + readBinaryFile: vi.fn(), + readFileChunk: vi.fn(async (url: string, offset: number, length: number) => { + if (url === "/rec/a.mp4" && offset === 0) await gate; + const bytes = sources[url]; + return { + success: true, + data: bytes.slice(offset, offset + length).buffer, + bytesRead: Math.min(length, bytes.byteLength - offset), + }; + }), + }; + stubElectronAPI(api); + + const aPromise = materializeLocalSourceFile("/rec/a.mp4", "a.mp4", OPTS); + // Wait until A is inside its gated first chunk read (past retain + prune). + while (!api.readFileChunk.mock.calls.some(([url]) => url === "/rec/a.mp4")) { + await new Promise((r) => setTimeout(r, 0)); + } + + const b = await materializeLocalSourceFile("/rec/b.mp4", "b.mp4", OPTS); + // B's prune must have kept A's in-progress entry alive. + expect(cacheDir(root)?.files.size).toBe(2); + + releaseFirstChunk(); + const a = await aPromise; + expect(new Uint8Array(await a.arrayBuffer())).toEqual(sourceA); + + releaseLocalSourceFile(a.name); + releaseLocalSourceFile(b.name); + }); +}); + +describe("materializeLocalSourceFile (in-flight dedup & abort)", () => { + const OPTS = { thresholdBytes: 4, chunkBytes: 3 }; + + it("deduplicates concurrent copies of the same entry into one stream", async () => { + const root = new FakeDir(); + stubOpfs(root); + const source = new Uint8Array([1, 2, 3, 4, 5, 6, 7]); + const api = largeSourceApi("/rec/dup.mp4", source); + stubElectronAPI(api); + + const [a, b] = await Promise.all([ + materializeLocalSourceFile("/rec/dup.mp4", "dup.mp4", OPTS), + materializeLocalSourceFile("/rec/dup.mp4", "dup.mp4", OPTS), + ]); + + // One shared copy: 7 bytes / 3-byte chunks => exactly 3 reads, not 6. + expect(api.readFileChunk).toHaveBeenCalledTimes(3); + expect(new Uint8Array(await a.arrayBuffer())).toEqual(source); + expect(new Uint8Array(await b.arrayBuffer())).toEqual(source); + expect(a.name).toBe(b.name); + + // Each caller took one reference; after both release, a later + // materialization of another entry prunes it. + releaseLocalSourceFile(a.name); + releaseLocalSourceFile(b.name); + stubElectronAPI(largeSourceApi("/rec/other.mp4", new Uint8Array([9, 9, 9, 9, 9]))); + const other = await materializeLocalSourceFile("/rec/other.mp4", "other.mp4", OPTS); + expect(cacheDir(root)?.files.size).toBe(1); + releaseLocalSourceFile(other.name); + }); + + it("aborts the copy via the AbortSignal and cleans up the partial entry", async () => { + const root = new FakeDir(); + stubOpfs(root); + const source = new Uint8Array([1, 2, 3, 4, 5, 6, 7]); + const api = largeSourceApi("/rec/ab.mp4", source); + let releaseFirstChunk!: () => void; + const gate = new Promise((resolve) => { + releaseFirstChunk = resolve; + }); + api.readFileChunk = vi.fn(async (_url: string, offset: number, length: number) => { + if (offset === 0) await gate; + return { + success: true, + data: source.slice(offset, offset + length).buffer, + bytesRead: Math.min(length, source.byteLength - offset), + }; + }); + stubElectronAPI(api); + + const controller = new AbortController(); + const promise = materializeLocalSourceFile("/rec/ab.mp4", "ab.mp4", { + ...OPTS, + signal: controller.signal, + }); + // Let the copy enter its gated first chunk, then abort mid-copy. + while (api.readFileChunk.mock.calls.length === 0) { + await new Promise((r) => setTimeout(r, 0)); + } + controller.abort(); + releaseFirstChunk(); + + await expect(promise).rejects.toThrow(/abort/i); + // Give the shared flight's cleanup a tick to settle. + await new Promise((r) => setTimeout(r, 0)); + expect(cacheDir(root)?.files.size ?? 0).toBe(0); + + // A retry after abort works from scratch. + const retry = await materializeLocalSourceFile("/rec/ab.mp4", "ab.mp4", OPTS); + expect(new Uint8Array(await retry.arrayBuffer())).toEqual(source); + releaseLocalSourceFile(retry.name); + }); + + it("keeps the shared copy alive while another joined caller is still interested", async () => { + const root = new FakeDir(); + stubOpfs(root); + const source = new Uint8Array([1, 2, 3, 4, 5, 6]); + const api = largeSourceApi("/rec/share.mp4", source); + let releaseFirstChunk!: () => void; + const gate = new Promise((resolve) => { + releaseFirstChunk = resolve; + }); + api.readFileChunk = vi.fn(async (_url: string, offset: number, length: number) => { + if (offset === 0) await gate; + return { + success: true, + data: source.slice(offset, offset + length).buffer, + bytesRead: Math.min(length, source.byteLength - offset), + }; + }); + stubElectronAPI(api); + + const controller = new AbortController(); + const abortable = materializeLocalSourceFile("/rec/share.mp4", "share.mp4", { + ...OPTS, + signal: controller.signal, + }); + const steady = materializeLocalSourceFile("/rec/share.mp4", "share.mp4", OPTS); + while (api.readFileChunk.mock.calls.length === 0) { + await new Promise((r) => setTimeout(r, 0)); + } + // One of two joined callers aborts: the shared copy must keep going. + controller.abort(); + releaseFirstChunk(); + + await expect(abortable).rejects.toThrow(/abort/i); + const file = await steady; + expect(new Uint8Array(await file.arrayBuffer())).toEqual(source); + releaseLocalSourceFile(file.name); + }); +}); + +describe("materializeLocalSourceFile (MIME inference)", () => { + it.each([ + ["/tmp/clip.webm", "video/webm"], + ["/tmp/clip.mp4", "video/mp4"], + ["/tmp/clip.mov", "video/quicktime"], + ["/tmp/clip.bin", "application/octet-stream"], + ])("infers the MIME type of %s as %s", async (path, expected) => { + stubElectronAPI({ + getReadableFileInfo: vi.fn().mockResolvedValue({ success: true, size: 4, mtimeMs: 1, path }), + readBinaryFile: vi + .fn() + .mockResolvedValue({ success: true, data: new Uint8Array(4).buffer, path }), + }); + + const file = await materializeLocalSourceFile(path, "clip"); + + expect(file.type).toBe(expected); + }); +}); diff --git a/src/lib/exporter/localSourceFile.ts b/src/lib/exporter/localSourceFile.ts new file mode 100644 index 000000000..8f4fbb8c3 --- /dev/null +++ b/src/lib/exporter/localSourceFile.ts @@ -0,0 +1,362 @@ +import { MAX_IN_MEMORY_SOURCE_BYTES } from "./sourceFileLimits"; + +/** + * Loads a local recording as a `File` suitable for `web-demuxer`, without ever + * holding the whole recording in memory. + * + * The naive path — `electronAPI.readBinaryFile` → `new File([arrayBuffer])` — + * breaks for long recordings in two ways: + * 1. The main process reads with Node's `fs.readFile`, which throws + * `ERR_FS_FILE_TOO_LARGE` for any file above 2 GiB (a hard cap on a single + * read). A 2h 1080p60 recording is ~6-7 GB, so it can never be read. + * 2. Even if it could, a multi-GB `ArrayBuffer`/`Blob` in the renderer would + * exhaust memory on typical machines (e.g. 16 GB RAM). + * + * `web-demuxer` reads a `File` on demand (it slices the file inside its worker), + * so it does not need the bytes up front. For recordings above a safe threshold + * we stream the file into an OPFS-backed file in fixed-size chunks and hand back + * the disk-backed `File` from `getFile()`. Memory stays flat regardless of size. + * + * Concurrency: copies are deduplicated per cache entry — concurrent callers of + * the same recording (e.g. the trim waveform and an export) share one in-flight + * copy instead of racing two writables on the same OPFS handle. Each caller can + * pass an `AbortSignal`; the underlying copy is aborted only once every joined + * caller has aborted. Successful callers take one cache reference each and must + * call {@link releaseLocalSourceFile} with the returned File's `.name`. + * + * Small recordings keep the original in-memory path — it is simpler and avoids + * an extra on-disk copy for the common case. + */ + +// Chunk size for streaming a large file into OPFS. Large enough to keep IPC +// overhead low, small enough that peak memory stays bounded. +const COPY_CHUNK_BYTES = 32 * 1024 * 1024; + +const OPFS_CACHE_DIR = "openscreen-source-cache"; + +export interface MaterializeProgress { + copiedBytes: number; + totalBytes: number; +} + +export interface MaterializeOptions { + onProgress?: (progress: MaterializeProgress) => void; + /** Aborts the wait; the shared copy stops once every joined caller aborts. */ + signal?: AbortSignal; + /** Override the in-memory threshold (testing only). */ + thresholdBytes?: number; + /** Override the OPFS copy chunk size (testing only). */ + chunkBytes?: number; +} + +/** + * Reference counts of OPFS cache entries currently read by a live demuxer, keyed + * by cache-entry name (which equals the returned File's `.name`). Pruning never + * removes a name with a live reference, so a concurrent export or caption pass + * reading a different recording — or a different revision of the same one — + * cannot have its copy deleted. Keying by cache name (not source URL) keeps + * revisions independent: releasing one never touches another's count. + */ +const activeCacheRefs = new Map(); + +/** One shared in-flight copy per cache entry; concurrent callers join it. */ +interface InflightCopy { + promise: Promise; + controller: AbortController; + consumers: number; + progressListeners: Set<(progress: MaterializeProgress) => void>; + lastProgress?: MaterializeProgress; +} + +const inflightCopies = new Map(); + +function retainCache(cacheName: string): void { + activeCacheRefs.set(cacheName, (activeCacheRefs.get(cacheName) ?? 0) + 1); +} + +/** + * Releases a reference taken by {@link materializeLocalSourceFile}. Pass the + * returned File's `.name`. No-op for small/remote sources, whose names were + * never retained. + */ +export function releaseLocalSourceFile(cacheName: string): void { + const refs = activeCacheRefs.get(cacheName); + if (refs === undefined) return; + if (refs <= 1) activeCacheRefs.delete(cacheName); + else activeCacheRefs.set(cacheName, refs - 1); +} + +/** Names that must survive pruning: referenced by a demuxer or mid-copy. */ +function keepSet(): Set { + const keep = new Set(activeCacheRefs.keys()); + for (const name of inflightCopies.keys()) keep.add(name); + return keep; +} + +/** + * Removes cache entries left behind by a previous session (or by exports whose + * source was never materialized again). Call once at app startup: nothing is + * referenced or mid-copy at that point, so everything stale is reclaimed. + */ +export async function clearStaleSourceCache(): Promise { + const getDirectory = navigator.storage?.getDirectory?.bind(navigator.storage); + if (!getDirectory) return; + try { + const root = await getDirectory(); + const dir = await root.getDirectoryHandle(OPFS_CACHE_DIR); + await pruneStaleEntries(dir, keepSet()); + } catch { + // No cache directory yet — nothing to clean. + } +} + +const MIME_BY_EXTENSION: Record = { + mp4: "video/mp4", + m4v: "video/mp4", + mov: "video/quicktime", + webm: "video/webm", + mkv: "video/x-matroska", + avi: "video/x-msvideo", +}; + +/** Infers a video MIME type from the file extension (recordings can be mp4 or webm). */ +function mimeTypeForPath(p: string): string { + const clean = p.toLowerCase().split(/[?#]/, 1)[0]; + const dot = clean.lastIndexOf("."); + const ext = dot >= 0 ? clean.slice(dot + 1) : ""; + return MIME_BY_EXTENSION[ext] ?? "application/octet-stream"; +} + +/** Stable non-cryptographic hash for building a cache key from a path. */ +function hashString(input: string): string { + let hash = 5381; + for (let i = 0; i < input.length; i++) { + hash = ((hash << 5) + hash + input.charCodeAt(i)) | 0; + } + return (hash >>> 0).toString(36); +} + +function throwIfAborted(signal?: AbortSignal): void { + if (signal?.aborted) throw new DOMException("Aborted", "AbortError"); +} + +/** + * Returns a `File` for a local recording path/URL, streaming large files through + * OPFS so nothing multi-GB is ever held in memory. + * + * @param videoUrl Local file path or `file://` URL of the recording. + * @param filename Preferred file name for the returned `File`. + * @param options Progress callback, abort signal, (testing) size overrides. + */ +export async function materializeLocalSourceFile( + videoUrl: string, + filename: string, + options?: MaterializeOptions, +): Promise { + const api = window.electronAPI; + if (!api) { + throw new Error("Local source loading is only available in the desktop app."); + } + + throwIfAborted(options?.signal); + const threshold = options?.thresholdBytes ?? MAX_IN_MEMORY_SOURCE_BYTES; + + const info = await api.getReadableFileInfo(videoUrl); + if (!info.success || typeof info.size !== "number") { + throw new Error(info.message || info.error || "Failed to read source video"); + } + throwIfAborted(options?.signal); + + // Common case: small enough to read in one shot. + if (info.size <= threshold) { + const result = await api.readBinaryFile(videoUrl); + if (!result.success || !result.data) { + throw new Error(result.message || result.error || "Failed to read source video"); + } + throwIfAborted(options?.signal); + const name = (result.path || filename).split(/[\\/]/).pop() || filename; + return new File([result.data], name, { type: mimeTypeForPath(name) }); + } + + // Large recording: stream into OPFS and hand back a disk-backed File. + // web-demuxer detects the container from content, so the File name is + // irrelevant here — the OPFS entry keeps its cache-key name. + return copyToOpfsFile(videoUrl, info.size, info.mtimeMs ?? 0, options); +} + +async function copyToOpfsFile( + videoUrl: string, + size: number, + mtimeMs: number, + options?: MaterializeOptions, +): Promise { + const getDirectory = navigator.storage?.getDirectory?.bind(navigator.storage); + if (!getDirectory) { + throw new Error( + "This recording is larger than 2 GB and cannot be exported: " + + "local storage (OPFS) is unavailable to stream it.", + ); + } + + const root = await getDirectory(); + const dir = await root.getDirectoryHandle(OPFS_CACHE_DIR, { create: true }); + + // Cache key ties the copy to this exact file revision so repeated exports of + // the same recording reuse the cached copy instead of re-streaming gigabytes. + const cacheName = `${hashString(videoUrl)}-${size}-${Math.round(mtimeMs)}.bin`; + const signal = options?.signal; + + // Join (or start) the shared in-flight copy for this entry. Loop so a caller + // that races a flight aborted by its last consumer can start a fresh one. + for (;;) { + throwIfAborted(signal); + + let flight = inflightCopies.get(cacheName); + if (flight?.controller.signal.aborted) { + await flight.promise.catch(() => undefined); + continue; + } + if (!flight) { + const controller = new AbortController(); + const fresh: InflightCopy = { + controller, + consumers: 0, + progressListeners: new Set(), + promise: Promise.resolve(), + }; + // Register BEFORE starting the copy so its own name is in keepSet() + // when the copy prunes, and so concurrent prunes keep mid-write entries. + inflightCopies.set(cacheName, fresh); + fresh.promise = runCopy( + dir, + cacheName, + videoUrl, + size, + controller.signal, + options?.chunkBytes ?? COPY_CHUNK_BYTES, + (p) => { + fresh.lastProgress = p; + for (const listener of fresh.progressListeners) listener(p); + }, + ).finally(() => { + if (inflightCopies.get(cacheName) === fresh) inflightCopies.delete(cacheName); + }); + // A flight abandoned by every consumer would otherwise be an unhandled rejection. + fresh.promise.catch(() => undefined); + flight = fresh; + } + + flight.consumers += 1; + if (options?.onProgress) { + flight.progressListeners.add(options.onProgress); + if (flight.lastProgress) options.onProgress(flight.lastProgress); + } + const joined = flight; + const onAbort = () => { + joined.consumers -= 1; + // Last interested caller gone: stop the underlying copy. + if (joined.consumers <= 0) joined.controller.abort(); + }; + signal?.addEventListener("abort", onAbort, { once: true }); + try { + await joined.promise; + throwIfAborted(signal); + const handle = await dir.getFileHandle(cacheName); + const file = await handle.getFile(); + retainCache(cacheName); + return file; + } finally { + signal?.removeEventListener("abort", onAbort); + if (options?.onProgress) joined.progressListeners.delete(options.onProgress); + if (!signal?.aborted) joined.consumers -= 1; + } + } +} + +/** Streams the source into the cache entry; resolves once it is complete on disk. */ +async function runCopy( + dir: FileSystemDirectoryHandle, + cacheName: string, + videoUrl: string, + size: number, + signal: AbortSignal, + chunkBytes: number, + emitProgress: (progress: MaterializeProgress) => void, +): Promise { + try { + await pruneStaleEntries(dir, keepSet()); + + const handle = await dir.getFileHandle(cacheName, { create: true }); + + // Reuse a complete prior copy. + const existing = await handle.getFile(); + if (existing.size === size) { + emitProgress({ copiedBytes: size, totalBytes: size }); + return; + } + + const writable = await handle.createWritable(); + try { + let offset = 0; + while (offset < size) { + throwIfAborted(signal); + const length = Math.min(chunkBytes, size - offset); + const chunk = await window.electronAPI.readFileChunk(videoUrl, offset, length); + if (!chunk.success || !chunk.data) { + throw new Error(chunk.message || chunk.error || "Failed to read source video chunk"); + } + // Guard against a short read that would otherwise loop forever. + if (chunk.data.byteLength === 0) { + throw new Error("Source video read returned no data before reaching the end."); + } + await writable.write(chunk.data); + offset += chunk.data.byteLength; + emitProgress({ copiedBytes: offset, totalBytes: size }); + } + await writable.close(); + } catch (error) { + try { + await writable.abort(); + } catch { + // ignore abort failure; surface the original error + } + throw error; + } + + const file = await handle.getFile(); + if (file.size !== size) { + throw new Error( + `Streamed copy is incomplete (${file.size} of ${size} bytes); the source video may still be in use.`, + ); + } + } catch (error) { + // Drop the partial copy so a retry does not resume from a corrupt file. + // (No caller has retained this entry — retains happen only after success — + // but keep the guard in case a prior complete copy of the same name is + // still being read.) + if (!activeCacheRefs.has(cacheName)) { + try { + await dir.removeEntry(cacheName); + } catch { + // ignore cleanup failure + } + } + throw error; + } +} + +/** Removes cached copies in the directory whose names are not in `keep`. */ +async function pruneStaleEntries(dir: FileSystemDirectoryHandle, keep: Set): Promise { + // FileSystemDirectoryHandle async iteration is available in Chromium/Electron. + const entries = ( + dir as unknown as { + keys?: () => AsyncIterableIterator; + } + ).keys?.(); + if (!entries) return; + const toRemove: string[] = []; + for await (const name of entries) { + if (!keep.has(name)) toRemove.push(name); + } + await Promise.all(toRemove.map((name) => dir.removeEntry(name).catch(() => undefined))); +} diff --git a/src/lib/exporter/sourceFileLimits.ts b/src/lib/exporter/sourceFileLimits.ts new file mode 100644 index 000000000..778e2ab50 --- /dev/null +++ b/src/lib/exporter/sourceFileLimits.ts @@ -0,0 +1,16 @@ +/** + * Largest source file we are willing to read whole via the `read-binary-file` + * IPC and hand around as a single in-memory `ArrayBuffer`/`Blob`. + * + * `read-binary-file` reads the file with Node's `fs.readFile` (which itself + * throws above 2 GiB) and returns the bytes over IPC, where Electron + * structured-clones them — copying the whole buffer in the main process. For a + * large recording this transiently needs ~2× the file size in the main process + * and crashes it on a memory-constrained machine (observed: a ~1 GB recording + * hard-crashes a 16 GB Mac). So the safe cutoff is far below the 2 GiB read cap. + * + * Above this size, recordings are streamed on demand instead — into OPFS in + * fixed-size chunks for demuxing (export/captions), and the in-memory + * source-copy and waveform paths are skipped. + */ +export const MAX_IN_MEMORY_SOURCE_BYTES = 256 * 1024 * 1024; diff --git a/src/lib/exporter/streamingDecoder.ts b/src/lib/exporter/streamingDecoder.ts index 472da8195..34cfcd568 100644 --- a/src/lib/exporter/streamingDecoder.ts +++ b/src/lib/exporter/streamingDecoder.ts @@ -1,7 +1,17 @@ import { WebDemuxer } from "web-demuxer"; import type { SpeedRegion, TrimRegion } from "@/components/video-editor/types"; +import { + type MaterializeProgress, + materializeLocalSourceFile, + releaseLocalSourceFile, +} from "./localSourceFile"; +import { MAX_IN_MEMORY_SOURCE_BYTES } from "./sourceFileLimits"; const SOURCE_LOAD_TIMEOUT_MS = 60_000; +// Large local recordings are streamed into OPFS before demuxing, which is +// bounded by disk/IPC throughput rather than a network round-trip. Allow far +// more time than a remote fetch so a multi-GB copy is not cut off mid-way. +const LOCAL_SOURCE_LOAD_TIMEOUT_MS = 15 * 60_000; const EPSILON_SEC = 0.001; /** * Build a full WebCodecs-compatible AV1 codec string from the AV1CodecConfigurationRecord. @@ -141,12 +151,25 @@ export async function loadFileAsArrayBuffer( const isRemoteUrl = /^(https?:|blob:|data:)/i.test(videoUrl); if (!isRemoteUrl && window.electronAPI) { - const { blob } = await StreamingVideoDecoder.loadLocalSourceFile(videoUrl); - return { data: await blob.arrayBuffer(), contentType: "" }; + // This path loads the entire file into an ArrayBuffer for decodeAudioData, + // and readBinaryFile also copies the bytes in the main process during IPC, + // so a large recording would exhaust memory and crash. Callers must route + // oversized files elsewhere (useAudioPeaks streams peaks via + // computePeaksFromFileStreaming); this guard is a safety net for any + // caller that does not. + const info = await window.electronAPI.getReadableFileInfo?.(videoUrl); + if (info?.success && typeof info.size === "number" && info.size > MAX_IN_MEMORY_SOURCE_BYTES) { + throw new Error("Recording is too large to load into memory for waveform rendering."); + } + const result = await window.electronAPI.readBinaryFile(videoUrl); + if (!result.success || !result.data) { + throw new Error(result.message || result.error || "Failed to read source video"); + } + return { data: result.data, contentType: "" }; } - const { blob } = await StreamingVideoDecoder.loadRemoteSourceFile(videoUrl); - return { data: await blob.arrayBuffer(), contentType: blob.type }; + const file = await StreamingVideoDecoder.loadRemoteSourceFile(videoUrl); + return { data: await file.arrayBuffer(), contentType: file.type }; } /** Caller must close the VideoFrame after use. */ @@ -168,15 +191,28 @@ export class StreamingVideoDecoder { private decoder: VideoDecoder | null = null; private cancelled = false; private metadata: DecodedVideoInfo | null = null; + // Name of the OPFS cache entry backing a large local source (equals the + // File's .name), released on destroy() so the copy can be pruned once no + // demuxer is reading it. Null for small/remote sources (never retained). + private sourceCacheName: string | null = null; + // Aborts an in-flight OPFS materialization when the decoder is cancelled, + // destroyed, or the load times out — otherwise a cancelled export would keep + // streaming gigabytes in the background and leak the cache reference. + private readonly loadAbort = new AbortController(); /** Routes to the appropriate loader based on whether the source is local or remote. */ - private async loadSourceFile(videoUrl: string): Promise<{ file: File; blob: Blob }> { + private async loadSourceFile( + videoUrl: string, + onProgress?: (progress: MaterializeProgress) => void, + ): Promise { const isRemoteUrl = /^(https?:|blob:|data:)/i.test(videoUrl); if (!isRemoteUrl && window.electronAPI) { return this.withTimeout( - StreamingVideoDecoder.loadLocalSourceFile(videoUrl), - SOURCE_LOAD_TIMEOUT_MS, + StreamingVideoDecoder.loadLocalSourceFile(videoUrl, onProgress, this.loadAbort.signal), + LOCAL_SOURCE_LOAD_TIMEOUT_MS, "Timed out while loading the source video.", + // Stop the underlying copy too; a bare reject would leave it running. + () => this.loadAbort.abort(), ); } return this.withTimeout( @@ -186,39 +222,39 @@ export class StreamingVideoDecoder { ); } - /** Loads a local video file via the Electron IPC bridge. */ - static async loadLocalSourceFile(videoUrl: string): Promise<{ file: File; blob: Blob }> { - const result = await window.electronAPI.readBinaryFile(videoUrl); - if (!result.success || !result.data) { - throw new Error(result.message || result.error || "Failed to read source video"); - } - - const filename = (result.path || videoUrl).split(/[\\/]/).pop() || "video"; - const blob = new Blob([result.data]); - return { - blob, - file: new File([blob], filename, { - type: blob.type || "application/octet-stream", - }), - }; + /** + * Loads a local video file for demuxing. Large recordings are streamed into + * an OPFS-backed File so nothing multi-GB is held in memory; web-demuxer reads + * the File on demand. See {@link materializeLocalSourceFile}. + */ + static async loadLocalSourceFile( + videoUrl: string, + onProgress?: (progress: MaterializeProgress) => void, + signal?: AbortSignal, + ): Promise { + const filename = (videoUrl.split(/[\\/]/).pop() || "video").replace(/^file:/, ""); + return materializeLocalSourceFile(videoUrl, filename, { onProgress, signal }); } /** Loads a remote or blob video URL via fetch. */ - static async loadRemoteSourceFile(videoUrl: string): Promise<{ file: File; blob: Blob }> { + static async loadRemoteSourceFile(videoUrl: string): Promise { const response = await fetch(videoUrl); if (!response.ok) { throw new Error(`Failed to fetch source video: ${response.status} ${response.statusText}`); } const blob = await response.blob(); const filename = videoUrl.split("/").pop() || "video"; - return { - blob, - file: new File([blob], filename, { type: blob.type }), - }; + return new File([blob], filename, { type: blob.type }); } - async loadMetadata(videoUrl: string): Promise { - const { file } = await this.loadSourceFile(videoUrl); + async loadMetadata( + videoUrl: string, + onSourceProgress?: (progress: MaterializeProgress) => void, + ): Promise { + const file = await this.loadSourceFile(videoUrl, onSourceProgress); + // For OPFS-streamed sources the File name is the cache-entry key; retained + // by materialize and released in destroy(). No-op key for small/remote. + this.sourceCacheName = file.name; // Relative URL so it resolves in both dev (http) and packaged (file://) builds const wasmUrl = new URL("./wasm/web-demuxer.wasm", window.location.href).href; @@ -735,11 +771,13 @@ export class StreamingVideoDecoder { /** Signals the decoder to stop processing at the next cancellation checkpoint. */ cancel(): void { this.cancelled = true; + this.loadAbort.abort(); } /** Cancels decoding and releases the VideoDecoder and WebDemuxer resources. */ destroy(): void { this.cancelled = true; + this.loadAbort.abort(); if (this.decoder) { try { @@ -758,12 +796,25 @@ export class StreamingVideoDecoder { } this.demuxer = null; } + + if (this.sourceCacheName) { + releaseLocalSourceFile(this.sourceCacheName); + this.sourceCacheName = null; + } } /** Wraps a promise with a hard timeout, rejecting with `message` if it exceeds `timeoutMs`. */ - private withTimeout(promise: Promise, timeoutMs: number, message: string): Promise { + private withTimeout( + promise: Promise, + timeoutMs: number, + message: string, + onTimeout?: () => void, + ): Promise { return new Promise((resolve, reject) => { - const timer = window.setTimeout(() => reject(new Error(message)), timeoutMs); + const timer = window.setTimeout(() => { + onTimeout?.(); + reject(new Error(message)); + }, timeoutMs); promise.then( (value) => { window.clearTimeout(timer); diff --git a/src/lib/exporter/types.ts b/src/lib/exporter/types.ts index 138d97ecb..e779a27b6 100644 --- a/src/lib/exporter/types.ts +++ b/src/lib/exporter/types.ts @@ -11,7 +11,7 @@ export interface ExportProgress { totalFrames: number; percentage: number; estimatedTimeRemaining: number; // seconds - phase?: "extracting" | "finalizing"; + phase?: "preparing" | "extracting" | "finalizing"; renderProgress?: number; // 0-100, GIF render phase } diff --git a/src/lib/exporter/videoExporter.ts b/src/lib/exporter/videoExporter.ts index 8f239656c..b89dfb74b 100644 --- a/src/lib/exporter/videoExporter.ts +++ b/src/lib/exporter/videoExporter.ts @@ -13,6 +13,7 @@ import { getPlatform } from "@/utils/platformUtils"; import { AudioProcessor } from "./audioEncoder"; import { FrameRenderer } from "./frameRenderer"; import { VideoMuxer } from "./muxer"; +import { MAX_IN_MEMORY_SOURCE_BYTES } from "./sourceFileLimits"; import { StreamingVideoDecoder } from "./streamingDecoder"; import { TimestampedVideoFrameQueue } from "./timestampedVideoFrameQueue"; import type { ExportConfig, ExportProgress, ExportResult } from "./types"; @@ -245,7 +246,20 @@ export class VideoExporter { const streamingDecoder = new StreamingVideoDecoder(); this.streamingDecoder = streamingDecoder; - const videoInfo = await streamingDecoder.loadMetadata(this.config.videoUrl); + const videoInfo = await streamingDecoder.loadMetadata( + this.config.videoUrl, + ({ copiedBytes, totalBytes }) => { + // Large recordings are streamed into OPFS before demuxing; surface + // that copy as a "preparing" phase so the dialog is not stuck at 0%. + this.reportProgress({ + currentFrame: 0, + totalFrames: 0, + percentage: totalBytes > 0 ? (copiedBytes / totalBytes) * 100 : 0, + estimatedTimeRemaining: 0, + phase: "preparing", + }); + }, + ); const sourceCopyResult = await this.trySourceCopyFastPath(videoInfo); if (sourceCopyResult) { return sourceCopyResult; @@ -728,6 +742,20 @@ export class VideoExporter { const isRemoteUrl = /^(https?:|blob:|data:)/i.test(videoUrl); if (!isRemoteUrl && window.electronAPI?.readBinaryFile) { + // The source-copy fast path reads the whole file into a Blob. That is + // impossible for recordings above Node's 2 GiB single-read cap, so bail + // out and let the (streaming) re-encode path handle them instead. + if (window.electronAPI.getReadableFileInfo) { + const info = await window.electronAPI.getReadableFileInfo(videoUrl); + if ( + info.success && + typeof info.size === "number" && + info.size > MAX_IN_MEMORY_SOURCE_BYTES + ) { + return null; + } + } + const result = await window.electronAPI.readBinaryFile(videoUrl); if (!result.success || !result.data) { return null; diff --git a/src/main.tsx b/src/main.tsx index ef7497386..28d128507 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -2,9 +2,19 @@ import React from "react"; import ReactDOM from "react-dom/client"; import App from "./App.tsx"; import { I18nProvider } from "./contexts/I18nContext"; +import { clearStaleSourceCache } from "./lib/exporter/localSourceFile"; import "./index.css"; const windowType = new URLSearchParams(window.location.search).get("windowType") || ""; + +// Reclaim multi-GB OPFS source copies left behind by a previous session (they +// are only pruned opportunistically during the next large-file load otherwise). +// Nothing is referenced at startup, so everything stale is safe to remove. +if (!windowType) { + window.setTimeout(() => { + clearStaleSourceCache().catch(() => undefined); + }, 5_000); +} const showNotes = new URLSearchParams(window.location.search).get("showNotes") === "true"; if ( showNotes ||