{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 ||