From f0e077c53f9fc827d40e62134a99376f8dc54f5c Mon Sep 17 00:00:00 2001 From: my_mac <1317811579@qq.com> Date: Mon, 6 Jul 2026 09:09:06 -0400 Subject: [PATCH 1/6] fix: stream >2GB recordings into OPFS so long videos can export Exporting a recording larger than ~2 GiB failed with "Failed to read binary file". The renderer loaded the whole source into memory via the read-binary-file IPC (Node `fs.readFile`), which throws ERR_FS_FILE_TOO_LARGE above 2 GiB, so any long recording (e.g. a 2h 1080p60 capture at ~6.6 GB) could never be exported. Even under the cap, a multi-GB ArrayBuffer/Blob would exhaust memory on a typical machine. web-demuxer reads a File on demand, so it never needs the bytes up front. Add a chunked range-read IPC (`get-readable-file-info` + `read-file-chunk`) and a renderer helper that streams large recordings into an OPFS-backed File, handing web-demuxer a disk-backed File instead of an in-memory one. Memory stays flat regardless of length. Small recordings keep the existing single-shot path. Wire the streaming loader into the export decoder and the captions audio extractor; skip the in-memory source-copy fast path and the waveform ArrayBuffer read for oversized files so they degrade gracefully. Verified against a real 6.6 GB / 2h11m recording: OPFS copy peaked at ~137 MB heap, web-demuxer parsed metadata and decoded 1920x1080 frames with heap staying ~27 MB. Co-Authored-By: Claude Opus 4.8 --- electron/electron-env.d.ts | 19 +++ electron/ipc/handlers.ts | 70 +++++++++ electron/preload.ts | 6 + src/lib/captioning/extractMono16k.ts | 21 +-- src/lib/exporter/localSourceFile.test.ts | 86 +++++++++++ src/lib/exporter/localSourceFile.ts | 173 +++++++++++++++++++++++ src/lib/exporter/streamingDecoder.ts | 57 ++++---- src/lib/exporter/videoExporter.ts | 13 ++ 8 files changed, 410 insertions(+), 35 deletions(-) create mode 100644 src/lib/exporter/localSourceFile.test.ts create mode 100644 src/lib/exporter/localSourceFile.ts 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..8478ae00a 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -2580,6 +2580,76 @@ 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), + }; + } + }); + + // 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" }; + } + + 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/lib/captioning/extractMono16k.ts b/src/lib/captioning/extractMono16k.ts index bce9a9fd8..09988fac3 100644 --- a/src/lib/captioning/extractMono16k.ts +++ b/src/lib/captioning/extractMono16k.ts @@ -1,9 +1,14 @@ +import { materializeLocalSourceFile } from "@/lib/exporter/localSourceFile"; import { MAX_CAPTION_AUDIO_SEC } from "./captionConstants"; import { extractMonoPcmViaWebDemuxer } from "./extractMono16kWebDemuxer"; export { MAX_CAPTION_AUDIO_SEC }; const FETCH_TIMEOUT_MS = 120_000; +// Above this size, `file.arrayBuffer()` for the decodeAudioData path would try to +// hold the whole recording in memory. Skip straight to the streaming web-demuxer +// path instead (it reads audio packets on demand). +const MAX_DECODE_AUDIO_DATA_BYTES = 1.5 * 1024 * 1024 * 1024; async function fetchWithTimeout(url: string, signal?: AbortSignal): Promise { const ctrl = new AbortController(); @@ -29,13 +34,11 @@ 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. + const filename = (videoUrl.split(/[\\/]/).pop() || "video").replace(/^file:/, ""); + return materializeLocalSourceFile(videoUrl, filename); } const response = await fetchWithTimeout(videoUrl, signal); @@ -149,7 +152,9 @@ export async function extractMono16kFromVideoUrl( } }; - const primary = await tryDecodeAudioDataPath(); + // 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 primary = file.size > MAX_DECODE_AUDIO_DATA_BYTES ? null : await tryDecodeAudioDataPath(); if (primary) { return primary; } diff --git a/src/lib/exporter/localSourceFile.test.ts b/src/lib/exporter/localSourceFile.test.ts new file mode 100644 index 000000000..3c60f1056 --- /dev/null +++ b/src/lib/exporter/localSourceFile.test.ts @@ -0,0 +1,86 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { materializeLocalSourceFile } from "./localSourceFile"; + +/** + * The large-file (OPFS streaming) path is verified end-to-end against a real + * multi-GB recording; these unit tests cover the small-file branch and the + * error handling that does not depend on OPFS. + */ + +function stubElectronAPI(api: Record) { + vi.stubGlobal("window", { ...globalThis.window, electronAPI: api } as unknown); +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("materializeLocalSourceFile", () => { + 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/, + ); + }); +}); diff --git a/src/lib/exporter/localSourceFile.ts b/src/lib/exporter/localSourceFile.ts new file mode 100644 index 000000000..969167364 --- /dev/null +++ b/src/lib/exporter/localSourceFile.ts @@ -0,0 +1,173 @@ +/** + * 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. + * + * Small recordings keep the original in-memory path — it is simpler and avoids + * an extra on-disk copy for the common case. + */ + +// Stay comfortably under Node's 2 GiB single-read cap so read-binary-file never +// throws ERR_FS_FILE_TOO_LARGE. Anything larger is streamed via OPFS instead. +const LARGE_FILE_THRESHOLD_BYTES = 1.5 * 1024 * 1024 * 1024; + +// 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; +} + +/** 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); +} + +/** + * 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 onProgress Optional progress callback for the large-file copy. + */ +export async function materializeLocalSourceFile( + videoUrl: string, + filename: string, + onProgress?: (progress: MaterializeProgress) => void, +): Promise { + const api = window.electronAPI; + if (!api) { + throw new Error("Local source loading is only available in the desktop app."); + } + + 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"); + } + + // Common case: small enough to read in one shot. + if (info.size <= LARGE_FILE_THRESHOLD_BYTES) { + const result = await api.readBinaryFile(videoUrl); + if (!result.success || !result.data) { + throw new Error(result.message || result.error || "Failed to read source video"); + } + const name = (result.path || filename).split(/[\\/]/).pop() || filename; + return new File([result.data], name, { type: "video/mp4" }); + } + + // 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, onProgress); +} + +async function copyToOpfsFile( + videoUrl: string, + size: number, + mtimeMs: number, + onProgress?: (progress: MaterializeProgress) => void, +): 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`; + + await pruneStaleEntries(dir, cacheName); + + const handle = await dir.getFileHandle(cacheName, { create: true }); + + // Reuse a complete prior copy. + const existing = await handle.getFile(); + if (existing.size === size) { + onProgress?.({ copiedBytes: size, totalBytes: size }); + return existing; + } + + const writable = await handle.createWritable(); + try { + let offset = 0; + while (offset < size) { + const length = Math.min(COPY_CHUNK_BYTES, 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"); + } + await writable.write(chunk.data); + offset += chunk.data.byteLength; + onProgress?.({ copiedBytes: offset, totalBytes: size }); + // 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.close(); + } catch (error) { + try { + await writable.abort(); + } catch { + // ignore abort failure; surface the original error + } + // Drop the partial copy so a retry does not resume from a corrupt file. + try { + await dir.removeEntry(cacheName); + } catch { + // ignore cleanup failure + } + 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.`, + ); + } + return file; +} + +/** Removes any cached copies in the directory other than the current key. */ +async function pruneStaleEntries(dir: FileSystemDirectoryHandle, keepName: string): 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 (name !== keepName) toRemove.push(name); + } + await Promise.all(toRemove.map((name) => dir.removeEntry(name).catch(() => undefined))); +} diff --git a/src/lib/exporter/streamingDecoder.ts b/src/lib/exporter/streamingDecoder.ts index 472da8195..3d526dd14 100644 --- a/src/lib/exporter/streamingDecoder.ts +++ b/src/lib/exporter/streamingDecoder.ts @@ -1,7 +1,12 @@ import { WebDemuxer } from "web-demuxer"; import type { SpeedRegion, TrimRegion } from "@/components/video-editor/types"; +import { materializeLocalSourceFile } from "./localSourceFile"; 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 +146,20 @@ 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 (it feeds the trim + // waveform), so it deliberately uses the single-shot read rather than the + // OPFS streaming loader. Recordings above ~2 GiB fail here and the caller + // (useAudioPeaks) degrades to no waveform — the export path does not rely + // on this function. + 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. */ @@ -170,12 +183,12 @@ export class StreamingVideoDecoder { private metadata: DecodedVideoInfo | null = null; /** 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): Promise { const isRemoteUrl = /^(https?:|blob:|data:)/i.test(videoUrl); if (!isRemoteUrl && window.electronAPI) { return this.withTimeout( StreamingVideoDecoder.loadLocalSourceFile(videoUrl), - SOURCE_LOAD_TIMEOUT_MS, + LOCAL_SOURCE_LOAD_TIMEOUT_MS, "Timed out while loading the source video.", ); } @@ -186,39 +199,29 @@ 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): Promise { + const filename = (videoUrl.split(/[\\/]/).pop() || "video").replace(/^file:/, ""); + return materializeLocalSourceFile(videoUrl, filename); } /** 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); + const file = await this.loadSourceFile(videoUrl); // 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; diff --git a/src/lib/exporter/videoExporter.ts b/src/lib/exporter/videoExporter.ts index 8f239656c..d2208982a 100644 --- a/src/lib/exporter/videoExporter.ts +++ b/src/lib/exporter/videoExporter.ts @@ -19,6 +19,9 @@ import type { ExportConfig, ExportProgress, ExportResult } from "./types"; const ENCODER_STALL_TIMEOUT_MS = 15_000; const ENCODER_FLUSH_TIMEOUT_MS = 20_000; +// The source-copy fast path reads the whole file into memory; stay under Node's +// 2 GiB single-read cap so larger recordings fall back to the streaming path. +const SOURCE_COPY_MAX_BYTES = 1.5 * 1024 * 1024 * 1024; /** * Waits for the encoder's queue to drain below maxEncodeQueue before returning. @@ -728,6 +731,16 @@ 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 > SOURCE_COPY_MAX_BYTES) { + return null; + } + } + const result = await window.electronAPI.readBinaryFile(videoUrl); if (!result.success || !result.data) { return null; From 55a97af2ff49722a40dec680e36ee276774d9d8f Mon Sep 17 00:00:00 2001 From: my_mac <1317811579@qq.com> Date: Mon, 6 Jul 2026 10:41:48 -0400 Subject: [PATCH 2/6] fix: guard OPFS source cache and surface copy progress Address review feedback on the >2GB export fix: - OPFS cache pruning now reference-counts live sources and never deletes an entry a demuxer is still reading. releaseLocalSourceFile() is called from StreamingVideoDecoder.destroy() and the captions extractor's finally. Previously pruning kept only the newest entry, which could remove a file still in use by a concurrent export/caption pass. - Extract the shared in-memory size threshold into sourceFileLimits.ts (was duplicated across localSourceFile, extractMono16k, videoExporter). - Report OPFS copy progress as a "preparing" export phase so the dialog reflects the multi-GB copy instead of sitting at 0%. - Add unit coverage for the OPFS streaming/eviction path: chunked copy, cache reuse, and the in-use pruning guard. Co-Authored-By: Claude Opus 4.8 --- src/components/video-editor/ExportDialog.tsx | 15 +- src/lib/captioning/extractMono16k.ts | 28 +-- src/lib/exporter/localSourceFile.test.ts | 174 ++++++++++++++++++- src/lib/exporter/localSourceFile.ts | 85 +++++++-- src/lib/exporter/sourceFileLimits.ts | 12 ++ src/lib/exporter/streamingDecoder.ts | 36 +++- src/lib/exporter/types.ts | 2 +- src/lib/exporter/videoExporter.ts | 25 ++- 8 files changed, 323 insertions(+), 54 deletions(-) create mode 100644 src/lib/exporter/sourceFileLimits.ts 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/lib/captioning/extractMono16k.ts b/src/lib/captioning/extractMono16k.ts index 09988fac3..8763c0aed 100644 --- a/src/lib/captioning/extractMono16k.ts +++ b/src/lib/captioning/extractMono16k.ts @@ -1,14 +1,11 @@ -import { materializeLocalSourceFile } from "@/lib/exporter/localSourceFile"; +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; -// Above this size, `file.arrayBuffer()` for the decodeAudioData path would try to -// hold the whole recording in memory. Skip straight to the streaming web-demuxer -// path instead (it reads audio packets on demand). -const MAX_DECODE_AUDIO_DATA_BYTES = 1.5 * 1024 * 1024 * 1024; async function fetchWithTimeout(url: string, signal?: AbortSignal): Promise { const ctrl = new AbortController(); @@ -152,13 +149,18 @@ export async function extractMono16kFromVideoUrl( } }; - // 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 primary = file.size > MAX_DECODE_AUDIO_DATA_BYTES ? null : 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 primary = file.size > MAX_IN_MEMORY_SOURCE_BYTES ? null : await tryDecodeAudioDataPath(); + if (primary) { + return primary; + } - const pcm = await extractMonoPcmViaWebDemuxer(file, options?.signal); - return truncateAndResampleTo16k(pcm.mono, pcm.sampleRate, pcm.durationSec, options?.signal); + const pcm = await extractMonoPcmViaWebDemuxer(file, options?.signal); + return truncateAndResampleTo16k(pcm.mono, pcm.sampleRate, pcm.durationSec, options?.signal); + } finally { + // Release the OPFS cache reference taken when streaming a large source. + releaseLocalSourceFile(videoUrl); + } } diff --git a/src/lib/exporter/localSourceFile.test.ts b/src/lib/exporter/localSourceFile.test.ts index 3c60f1056..2d83f1ed9 100644 --- a/src/lib/exporter/localSourceFile.test.ts +++ b/src/lib/exporter/localSourceFile.test.ts @@ -1,11 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { materializeLocalSourceFile } from "./localSourceFile"; - -/** - * The large-file (OPFS streaming) path is verified end-to-end against a real - * multi-GB recording; these unit tests cover the small-file branch and the - * error handling that does not depend on OPFS. - */ +import { materializeLocalSourceFile, releaseLocalSourceFile } from "./localSourceFile"; function stubElectronAPI(api: Record) { vi.stubGlobal("window", { ...globalThis.window, electronAPI: api } as unknown); @@ -15,7 +9,7 @@ afterEach(() => { vi.unstubAllGlobals(); }); -describe("materializeLocalSourceFile", () => { +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({ @@ -84,3 +78,167 @@ describe("materializeLocalSourceFile", () => { ); }); }); + +// ---- 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("/rec/a.mp4"); + }); + + 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()); + + await materializeLocalSourceFile("/rec/b.mp4", "b.mp4", OPTS); + const firstReads = api.readFileChunk.mock.calls.length; + await materializeLocalSourceFile("/rec/b.mp4", "b.mp4", OPTS); + + expect(api.readFileChunk.mock.calls.length).toBe(firstReads); // no new reads + + releaseLocalSourceFile("/rec/b.mp4"); + releaseLocalSourceFile("/rec/b.mp4"); + }); + + 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]))); + await materializeLocalSourceFile("/rec/a.mp4", "a.mp4", OPTS); // A retained + + stubElectronAPI(largeSourceApi("/rec/b.mp4", new Uint8Array([6, 7, 8, 9, 10]))); + await materializeLocalSourceFile("/rec/b.mp4", "b.mp4", OPTS); // prunes, but A is active + + // A must NOT have been pruned while still in use. + expect(cacheDir(root)?.files.size).toBe(2); + + releaseLocalSourceFile("/rec/a.mp4"); + releaseLocalSourceFile("/rec/b.mp4"); + }); + + 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]))); + await materializeLocalSourceFile("/rec/a.mp4", "a.mp4", OPTS); // A retained + + stubElectronAPI(largeSourceApi("/rec/b.mp4", new Uint8Array([6, 7, 8, 9, 10]))); + await materializeLocalSourceFile("/rec/b.mp4", "b.mp4", OPTS); // B retained + + releaseLocalSourceFile("/rec/a.mp4"); // A no longer in use + + stubElectronAPI(largeSourceApi("/rec/c.mp4", new Uint8Array([11, 12, 13, 14, 15]))); + await materializeLocalSourceFile("/rec/c.mp4", "c.mp4", OPTS); // prunes A, keeps B, adds C + + // A pruned; B (still active) and C remain. + expect(cacheDir(root)?.files.size).toBe(2); + + releaseLocalSourceFile("/rec/b.mp4"); + releaseLocalSourceFile("/rec/c.mp4"); + }); +}); diff --git a/src/lib/exporter/localSourceFile.ts b/src/lib/exporter/localSourceFile.ts index 969167364..0c478872b 100644 --- a/src/lib/exporter/localSourceFile.ts +++ b/src/lib/exporter/localSourceFile.ts @@ -1,3 +1,5 @@ +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. @@ -19,10 +21,6 @@ * an extra on-disk copy for the common case. */ -// Stay comfortably under Node's 2 GiB single-read cap so read-binary-file never -// throws ERR_FS_FILE_TOO_LARGE. Anything larger is streamed via OPFS instead. -const LARGE_FILE_THRESHOLD_BYTES = 1.5 * 1024 * 1024 * 1024; - // 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; @@ -34,6 +32,49 @@ export interface MaterializeProgress { totalBytes: number; } +export interface MaterializeOptions { + onProgress?: (progress: MaterializeProgress) => void; + /** Override the in-memory threshold (testing only). */ + thresholdBytes?: number; + /** Override the OPFS copy chunk size (testing only). */ + chunkBytes?: number; +} + +/** + * Cache entries currently referenced by a live demuxer, keyed by source URL. + * Pruning never removes a name that is still in use, so a concurrent export or + * caption pass reading a different recording cannot have its copy deleted. + * Callers must {@link releaseLocalSourceFile} when the demuxer is destroyed. + */ +const activeSources = new Map(); + +function retainSource(videoUrl: string, cacheName: string): void { + const entry = activeSources.get(videoUrl); + if (entry && entry.cacheName === cacheName) { + entry.refs += 1; + } else { + // A new file revision (size/mtime change) supersedes any prior entry. + activeSources.set(videoUrl, { cacheName, refs: 1 }); + } +} + +/** + * Releases a reference taken by {@link materializeLocalSourceFile} for a large + * (OPFS-streamed) source. No-op for small sources and for URLs never streamed. + */ +export function releaseLocalSourceFile(videoUrl: string): void { + const entry = activeSources.get(videoUrl); + if (!entry) return; + entry.refs -= 1; + if (entry.refs <= 0) activeSources.delete(videoUrl); +} + +function activeCacheNames(): Set { + const names = new Set(); + for (const entry of activeSources.values()) names.add(entry.cacheName); + return names; +} + /** Stable non-cryptographic hash for building a cache key from a path. */ function hashString(input: string): string { let hash = 5381; @@ -49,25 +90,27 @@ function hashString(input: string): string { * * @param videoUrl Local file path or `file://` URL of the recording. * @param filename Preferred file name for the returned `File`. - * @param onProgress Optional progress callback for the large-file copy. + * @param options Progress callback and (testing) threshold/chunk overrides. */ export async function materializeLocalSourceFile( videoUrl: string, filename: string, - onProgress?: (progress: MaterializeProgress) => void, + options?: MaterializeOptions, ): Promise { const api = window.electronAPI; if (!api) { throw new Error("Local source loading is only available in the desktop app."); } + 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"); } // Common case: small enough to read in one shot. - if (info.size <= LARGE_FILE_THRESHOLD_BYTES) { + 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"); @@ -79,14 +122,14 @@ export async function materializeLocalSourceFile( // 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, onProgress); + return copyToOpfsFile(videoUrl, info.size, info.mtimeMs ?? 0, options); } async function copyToOpfsFile( videoUrl: string, size: number, mtimeMs: number, - onProgress?: (progress: MaterializeProgress) => void, + options?: MaterializeOptions, ): Promise { const getDirectory = navigator.storage?.getDirectory?.bind(navigator.storage); if (!getDirectory) { @@ -103,33 +146,38 @@ async function copyToOpfsFile( // the same recording reuse the cached copy instead of re-streaming gigabytes. const cacheName = `${hashString(videoUrl)}-${size}-${Math.round(mtimeMs)}.bin`; - await pruneStaleEntries(dir, cacheName); + // Keep the entry we are about to use plus anything a live demuxer still reads. + const keep = activeCacheNames(); + keep.add(cacheName); + await pruneStaleEntries(dir, keep); const handle = await dir.getFileHandle(cacheName, { create: true }); // Reuse a complete prior copy. const existing = await handle.getFile(); if (existing.size === size) { - onProgress?.({ copiedBytes: size, totalBytes: size }); + options?.onProgress?.({ copiedBytes: size, totalBytes: size }); + retainSource(videoUrl, cacheName); return existing; } + const chunkBytes = options?.chunkBytes ?? COPY_CHUNK_BYTES; const writable = await handle.createWritable(); try { let offset = 0; while (offset < size) { - const length = Math.min(COPY_CHUNK_BYTES, size - offset); + 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"); } - await writable.write(chunk.data); - offset += chunk.data.byteLength; - onProgress?.({ copiedBytes: offset, totalBytes: size }); // 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; + options?.onProgress?.({ copiedBytes: offset, totalBytes: size }); } await writable.close(); } catch (error) { @@ -153,11 +201,12 @@ async function copyToOpfsFile( `Streamed copy is incomplete (${file.size} of ${size} bytes); the source video may still be in use.`, ); } + retainSource(videoUrl, cacheName); return file; } -/** Removes any cached copies in the directory other than the current key. */ -async function pruneStaleEntries(dir: FileSystemDirectoryHandle, keepName: string): Promise { +/** 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 { @@ -167,7 +216,7 @@ async function pruneStaleEntries(dir: FileSystemDirectoryHandle, keepName: strin if (!entries) return; const toRemove: string[] = []; for await (const name of entries) { - if (name !== keepName) toRemove.push(name); + 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..fcd9de6a7 --- /dev/null +++ b/src/lib/exporter/sourceFileLimits.ts @@ -0,0 +1,12 @@ +/** + * Largest source file we are willing to read fully into memory in one shot. + * + * Node's `fs.readFile` (behind the `read-binary-file` IPC) throws + * `ERR_FS_FILE_TOO_LARGE` above 2 GiB, and a multi-GB `ArrayBuffer`/`Blob` would + * exhaust memory on typical machines anyway. Recordings above this threshold are + * streamed on demand instead (into OPFS for demuxing; skipped for the in-memory + * source-copy and waveform/decodeAudioData paths). + * + * Kept comfortably below the 2 GiB cap to leave headroom. + */ +export const MAX_IN_MEMORY_SOURCE_BYTES = 1.5 * 1024 * 1024 * 1024; diff --git a/src/lib/exporter/streamingDecoder.ts b/src/lib/exporter/streamingDecoder.ts index 3d526dd14..706ef056a 100644 --- a/src/lib/exporter/streamingDecoder.ts +++ b/src/lib/exporter/streamingDecoder.ts @@ -1,6 +1,10 @@ import { WebDemuxer } from "web-demuxer"; import type { SpeedRegion, TrimRegion } from "@/components/video-editor/types"; -import { materializeLocalSourceFile } from "./localSourceFile"; +import { + type MaterializeProgress, + materializeLocalSourceFile, + releaseLocalSourceFile, +} from "./localSourceFile"; const SOURCE_LOAD_TIMEOUT_MS = 60_000; // Large local recordings are streamed into OPFS before demuxing, which is @@ -181,13 +185,20 @@ export class StreamingVideoDecoder { private decoder: VideoDecoder | null = null; private cancelled = false; private metadata: DecodedVideoInfo | null = null; + // Local source URL streamed into OPFS, released on destroy() so its cache + // copy can be pruned once no demuxer is reading it. + private localSourceUrl: string | null = null; /** Routes to the appropriate loader based on whether the source is local or remote. */ - private async loadSourceFile(videoUrl: string): Promise { + private async loadSourceFile( + videoUrl: string, + onProgress?: (progress: MaterializeProgress) => void, + ): Promise { const isRemoteUrl = /^(https?:|blob:|data:)/i.test(videoUrl); if (!isRemoteUrl && window.electronAPI) { + this.localSourceUrl = videoUrl; return this.withTimeout( - StreamingVideoDecoder.loadLocalSourceFile(videoUrl), + StreamingVideoDecoder.loadLocalSourceFile(videoUrl, onProgress), LOCAL_SOURCE_LOAD_TIMEOUT_MS, "Timed out while loading the source video.", ); @@ -204,9 +215,12 @@ export class StreamingVideoDecoder { * 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): Promise { + static async loadLocalSourceFile( + videoUrl: string, + onProgress?: (progress: MaterializeProgress) => void, + ): Promise { const filename = (videoUrl.split(/[\\/]/).pop() || "video").replace(/^file:/, ""); - return materializeLocalSourceFile(videoUrl, filename); + return materializeLocalSourceFile(videoUrl, filename, { onProgress }); } /** Loads a remote or blob video URL via fetch. */ @@ -220,8 +234,11 @@ export class StreamingVideoDecoder { 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); // 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; @@ -761,6 +778,11 @@ export class StreamingVideoDecoder { } this.demuxer = null; } + + if (this.localSourceUrl) { + releaseLocalSourceFile(this.localSourceUrl); + this.localSourceUrl = null; + } } /** Wraps a promise with a hard timeout, rejecting with `message` if it exceeds `timeoutMs`. */ 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 d2208982a..b89dfb74b 100644 --- a/src/lib/exporter/videoExporter.ts +++ b/src/lib/exporter/videoExporter.ts @@ -13,15 +13,13 @@ 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"; const ENCODER_STALL_TIMEOUT_MS = 15_000; const ENCODER_FLUSH_TIMEOUT_MS = 20_000; -// The source-copy fast path reads the whole file into memory; stay under Node's -// 2 GiB single-read cap so larger recordings fall back to the streaming path. -const SOURCE_COPY_MAX_BYTES = 1.5 * 1024 * 1024 * 1024; /** * Waits for the encoder's queue to drain below maxEncodeQueue before returning. @@ -248,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; @@ -736,7 +747,11 @@ export class VideoExporter { // 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 > SOURCE_COPY_MAX_BYTES) { + if ( + info.success && + typeof info.size === "number" && + info.size > MAX_IN_MEMORY_SOURCE_BYTES + ) { return null; } } From 655cf004fc25c7b610e9106a9ab3eb52a622fde2 Mon Sep 17 00:00:00 2001 From: my_mac <1317811579@qq.com> Date: Mon, 6 Jul 2026 11:12:42 -0400 Subject: [PATCH 3/6] fix: key OPFS cache refcount by cache entry, not source URL Address the follow-up review on the OPFS cache guard: - Reference-count cache entries by cache-entry name (the returned File's .name) instead of by source URL. Keying by URL discarded an older revision's refcount when a new revision (size/mtime change) superseded it, so releasing the old revision could decrement the wrong entry and prematurely drop a still-in-use copy. Callers now release with the File's name (StreamingVideoDecoder.destroy and the captions extractor). - Add a test that a failed chunk read mid-copy throws and removes the partial cache entry, exercising the catch-block cleanup. Co-Authored-By: Claude Opus 4.8 --- src/lib/captioning/extractMono16k.ts | 3 +- src/lib/exporter/localSourceFile.test.ts | 57 +++++++++++++++++------- src/lib/exporter/localSourceFile.ts | 47 +++++++++---------- src/lib/exporter/streamingDecoder.ts | 17 ++++--- 4 files changed, 75 insertions(+), 49 deletions(-) diff --git a/src/lib/captioning/extractMono16k.ts b/src/lib/captioning/extractMono16k.ts index 8763c0aed..999113e9d 100644 --- a/src/lib/captioning/extractMono16k.ts +++ b/src/lib/captioning/extractMono16k.ts @@ -161,6 +161,7 @@ export async function extractMono16kFromVideoUrl( return truncateAndResampleTo16k(pcm.mono, pcm.sampleRate, pcm.durationSec, options?.signal); } finally { // Release the OPFS cache reference taken when streaming a large source. - releaseLocalSourceFile(videoUrl); + // The File name is the cache-entry key (no-op for small/remote sources). + releaseLocalSourceFile(file.name); } } diff --git a/src/lib/exporter/localSourceFile.test.ts b/src/lib/exporter/localSourceFile.test.ts index 2d83f1ed9..16b7c0007 100644 --- a/src/lib/exporter/localSourceFile.test.ts +++ b/src/lib/exporter/localSourceFile.test.ts @@ -184,7 +184,7 @@ describe("materializeLocalSourceFile (large file OPFS path)", () => { expect(api.readFileChunk).toHaveBeenCalledTimes(3); expect(api.readBinaryFile).not.toHaveBeenCalled(); - releaseLocalSourceFile("/rec/a.mp4"); + releaseLocalSourceFile(file.name); }); it("reuses the cached copy on a second call without re-streaming", async () => { @@ -193,14 +193,15 @@ describe("materializeLocalSourceFile (large file OPFS path)", () => { stubElectronAPI(api); stubOpfs(new FakeDir()); - await materializeLocalSourceFile("/rec/b.mp4", "b.mp4", OPTS); + const first = await materializeLocalSourceFile("/rec/b.mp4", "b.mp4", OPTS); const firstReads = api.readFileChunk.mock.calls.length; - await materializeLocalSourceFile("/rec/b.mp4", "b.mp4", OPTS); + 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("/rec/b.mp4"); - releaseLocalSourceFile("/rec/b.mp4"); + releaseLocalSourceFile(first.name); + releaseLocalSourceFile(second.name); }); it("keeps a cache entry that is still referenced by another active source", async () => { @@ -208,16 +209,16 @@ describe("materializeLocalSourceFile (large file OPFS path)", () => { stubOpfs(root); stubElectronAPI(largeSourceApi("/rec/a.mp4", new Uint8Array([1, 2, 3, 4, 5]))); - await materializeLocalSourceFile("/rec/a.mp4", "a.mp4", OPTS); // A retained + const a = await materializeLocalSourceFile("/rec/a.mp4", "a.mp4", OPTS); // A retained stubElectronAPI(largeSourceApi("/rec/b.mp4", new Uint8Array([6, 7, 8, 9, 10]))); - await materializeLocalSourceFile("/rec/b.mp4", "b.mp4", OPTS); // prunes, but A is active + 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("/rec/a.mp4"); - releaseLocalSourceFile("/rec/b.mp4"); + releaseLocalSourceFile(a.name); + releaseLocalSourceFile(b.name); }); it("prunes a cache entry once it has been released", async () => { @@ -225,20 +226,46 @@ describe("materializeLocalSourceFile (large file OPFS path)", () => { stubOpfs(root); stubElectronAPI(largeSourceApi("/rec/a.mp4", new Uint8Array([1, 2, 3, 4, 5]))); - await materializeLocalSourceFile("/rec/a.mp4", "a.mp4", OPTS); // A retained + const a = await materializeLocalSourceFile("/rec/a.mp4", "a.mp4", OPTS); // A retained stubElectronAPI(largeSourceApi("/rec/b.mp4", new Uint8Array([6, 7, 8, 9, 10]))); - await materializeLocalSourceFile("/rec/b.mp4", "b.mp4", OPTS); // B retained + const b = await materializeLocalSourceFile("/rec/b.mp4", "b.mp4", OPTS); // B retained - releaseLocalSourceFile("/rec/a.mp4"); // A no longer in use + releaseLocalSourceFile(a.name); // A no longer in use stubElectronAPI(largeSourceApi("/rec/c.mp4", new Uint8Array([11, 12, 13, 14, 15]))); - await materializeLocalSourceFile("/rec/c.mp4", "c.mp4", OPTS); // prunes A, keeps B, adds C + 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("/rec/b.mp4"); - releaseLocalSourceFile("/rec/c.mp4"); + 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); }); }); diff --git a/src/lib/exporter/localSourceFile.ts b/src/lib/exporter/localSourceFile.ts index 0c478872b..f247d45d5 100644 --- a/src/lib/exporter/localSourceFile.ts +++ b/src/lib/exporter/localSourceFile.ts @@ -41,38 +41,33 @@ export interface MaterializeOptions { } /** - * Cache entries currently referenced by a live demuxer, keyed by source URL. - * Pruning never removes a name that is still in use, so a concurrent export or - * caption pass reading a different recording cannot have its copy deleted. - * Callers must {@link releaseLocalSourceFile} when the demuxer is destroyed. + * 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 activeSources = new Map(); - -function retainSource(videoUrl: string, cacheName: string): void { - const entry = activeSources.get(videoUrl); - if (entry && entry.cacheName === cacheName) { - entry.refs += 1; - } else { - // A new file revision (size/mtime change) supersedes any prior entry. - activeSources.set(videoUrl, { cacheName, refs: 1 }); - } +const activeCacheRefs = new Map(); + +function retainCache(cacheName: string): void { + activeCacheRefs.set(cacheName, (activeCacheRefs.get(cacheName) ?? 0) + 1); } /** - * Releases a reference taken by {@link materializeLocalSourceFile} for a large - * (OPFS-streamed) source. No-op for small sources and for URLs never streamed. + * 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(videoUrl: string): void { - const entry = activeSources.get(videoUrl); - if (!entry) return; - entry.refs -= 1; - if (entry.refs <= 0) activeSources.delete(videoUrl); +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); } function activeCacheNames(): Set { - const names = new Set(); - for (const entry of activeSources.values()) names.add(entry.cacheName); - return names; + return new Set(activeCacheRefs.keys()); } /** Stable non-cryptographic hash for building a cache key from a path. */ @@ -157,7 +152,7 @@ async function copyToOpfsFile( const existing = await handle.getFile(); if (existing.size === size) { options?.onProgress?.({ copiedBytes: size, totalBytes: size }); - retainSource(videoUrl, cacheName); + retainCache(cacheName); return existing; } @@ -201,7 +196,7 @@ async function copyToOpfsFile( `Streamed copy is incomplete (${file.size} of ${size} bytes); the source video may still be in use.`, ); } - retainSource(videoUrl, cacheName); + retainCache(cacheName); return file; } diff --git a/src/lib/exporter/streamingDecoder.ts b/src/lib/exporter/streamingDecoder.ts index 706ef056a..1c26c51a2 100644 --- a/src/lib/exporter/streamingDecoder.ts +++ b/src/lib/exporter/streamingDecoder.ts @@ -185,9 +185,10 @@ export class StreamingVideoDecoder { private decoder: VideoDecoder | null = null; private cancelled = false; private metadata: DecodedVideoInfo | null = null; - // Local source URL streamed into OPFS, released on destroy() so its cache - // copy can be pruned once no demuxer is reading it. - private localSourceUrl: string | 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; /** Routes to the appropriate loader based on whether the source is local or remote. */ private async loadSourceFile( @@ -196,7 +197,6 @@ export class StreamingVideoDecoder { ): Promise { const isRemoteUrl = /^(https?:|blob:|data:)/i.test(videoUrl); if (!isRemoteUrl && window.electronAPI) { - this.localSourceUrl = videoUrl; return this.withTimeout( StreamingVideoDecoder.loadLocalSourceFile(videoUrl, onProgress), LOCAL_SOURCE_LOAD_TIMEOUT_MS, @@ -239,6 +239,9 @@ export class StreamingVideoDecoder { 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; @@ -779,9 +782,9 @@ export class StreamingVideoDecoder { this.demuxer = null; } - if (this.localSourceUrl) { - releaseLocalSourceFile(this.localSourceUrl); - this.localSourceUrl = null; + if (this.sourceCacheName) { + releaseLocalSourceFile(this.sourceCacheName); + this.sourceCacheName = null; } } From 03834c85eb3e1c1f40703308dd749f56b12e753f Mon Sep 17 00:00:00 2001 From: my_mac <1317811579@qq.com> Date: Mon, 6 Jul 2026 12:47:57 -0400 Subject: [PATCH 4/6] fix: lower in-memory source threshold and skip waveform for huge files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 1.5 GiB threshold was too high. read-binary-file returns the whole file over IPC, which Electron structured-clones (copies) in the main process, so a ~1 GB recording transiently needs ~2x its size there and hard-crashes a memory-constrained machine (observed on a 16 GB Mac) — well below the 2 GiB fs.readFile cap. Two paths hit this: - Export/demux source load and captions now stream via OPFS above 256 MiB (lowered MAX_IN_MEMORY_SOURCE_BYTES), so moderate recordings no longer ship a giant buffer over IPC. - The trim waveform (loadFileAsArrayBuffer) cannot stream — decodeAudioData needs the whole file — so it now skips recordings above the limit; useAudioPeaks already degrades to no waveform on throw. Fixes an editor-entry crash when opening a ~1 GB recording. Co-Authored-By: Claude Opus 4.8 --- src/lib/exporter/sourceFileLimits.ts | 20 ++++++++++++-------- src/lib/exporter/streamingDecoder.ts | 15 +++++++++++---- 2 files changed, 23 insertions(+), 12 deletions(-) diff --git a/src/lib/exporter/sourceFileLimits.ts b/src/lib/exporter/sourceFileLimits.ts index fcd9de6a7..778e2ab50 100644 --- a/src/lib/exporter/sourceFileLimits.ts +++ b/src/lib/exporter/sourceFileLimits.ts @@ -1,12 +1,16 @@ /** - * Largest source file we are willing to read fully into memory in one shot. + * 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`. * - * Node's `fs.readFile` (behind the `read-binary-file` IPC) throws - * `ERR_FS_FILE_TOO_LARGE` above 2 GiB, and a multi-GB `ArrayBuffer`/`Blob` would - * exhaust memory on typical machines anyway. Recordings above this threshold are - * streamed on demand instead (into OPFS for demuxing; skipped for the in-memory - * source-copy and waveform/decodeAudioData paths). + * `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. * - * Kept comfortably below the 2 GiB cap to leave headroom. + * 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 = 1.5 * 1024 * 1024 * 1024; +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 1c26c51a2..f4cd6819a 100644 --- a/src/lib/exporter/streamingDecoder.ts +++ b/src/lib/exporter/streamingDecoder.ts @@ -5,6 +5,7 @@ import { 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 @@ -151,10 +152,16 @@ export async function loadFileAsArrayBuffer( if (!isRemoteUrl && window.electronAPI) { // This path loads the entire file into an ArrayBuffer (it feeds the trim - // waveform), so it deliberately uses the single-shot read rather than the - // OPFS streaming loader. Recordings above ~2 GiB fail here and the caller - // (useAudioPeaks) degrades to no waveform — the export path does not rely - // on this function. + // waveform) and cannot stream, since decodeAudioData needs the whole file. + // readBinaryFile also copies the bytes in the main process during IPC, so a + // large recording would exhaust memory and crash. The waveform is cosmetic, + // so skip it above the in-memory limit; useAudioPeaks (the only caller) + // catches the throw and degrades to no waveform. The export path streams + // instead and does not rely on this function. + 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"); From 35937a90bcfcb5aabfec1ad91e6a6e811f8fb791 Mon Sep 17 00:00:00 2001 From: my_mac <1317811579@qq.com> Date: Mon, 6 Jul 2026 13:13:31 -0400 Subject: [PATCH 5/6] =?UTF-8?q?fix:=20address=20review=20=E2=80=94=20prune?= =?UTF-8?q?=20race,=20chunk=20cap,=20MIME,=20streaming=20waveform?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback from #74: - Fix the concurrent-prune race: retain the OPFS cache entry BEFORE pruning/writing, so a concurrent materialization's prune pass can no longer delete an entry that is still mid-write. On failure the reference is released and the partial copy removed only if no other in-flight operation still references it. Regression test included. - Cap read-file-chunk requests at 64 MiB on the main-process side so a buggy or compromised renderer cannot force an arbitrarily large Buffer.allocUnsafe. - Infer the small-file MIME type from the extension (mp4/webm/mov/...) instead of hardcoding video/mp4. - Restore the trim waveform for large recordings instead of skipping it: new streaming peaks path demuxes the audio track and folds each decoded AudioData frame straight into min/max buckets (same output format as audioPeaksWorker), so memory stays flat. Verified on a 288 MB file: all 24000 blocks populated, renderer heap 25→27 MB. Co-Authored-By: Claude Opus 4.8 --- electron/ipc/handlers.ts | 7 + src/hooks/streamingAudioPeaks.ts | 151 ++++++++++++++++++ src/hooks/useAudioPeaks.ts | 36 ++++- .../captioning/extractMono16kWebDemuxer.ts | 3 +- src/lib/exporter/localSourceFile.test.ts | 74 +++++++++ src/lib/exporter/localSourceFile.ts | 122 ++++++++------ src/lib/exporter/streamingDecoder.ts | 13 +- 7 files changed, 345 insertions(+), 61 deletions(-) create mode 100644 src/hooks/streamingAudioPeaks.ts diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 8478ae00a..ae8f5525d 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -2611,6 +2611,10 @@ export function registerIpcHandlers( } }); + // 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 @@ -2627,6 +2631,9 @@ export function registerIpcHandlers( 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 { diff --git a/src/hooks/streamingAudioPeaks.ts b/src/hooks/streamingAudioPeaks.ts new file mode 100644 index 000000000..a0560089b --- /dev/null +++ b/src/hooks/streamingAudioPeaks.ts @@ -0,0 +1,151 @@ +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; + +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.", + ); + const durationSec = + Number.isFinite(mediaInfo.duration) && mediaInfo.duration > 0 ? mediaInfo.duration : 0; + if (durationSec <= 0) { + throw new Error("Unknown duration; cannot bucket waveform peaks."); + } + + 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; + + 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); + + 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 */ + } + } + + if (decoder.state === "configured") { + await decoder.flush(); + decoder.close(); + } + 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..d6eb687a5 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,33 @@ 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:/, ""); + const file = await materializeLocalSourceFile(videoUrl, filename); + 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 +118,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/extractMono16kWebDemuxer.ts b/src/lib/captioning/extractMono16kWebDemuxer.ts index f86b6dc57..3a00aa0bf 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); diff --git a/src/lib/exporter/localSourceFile.test.ts b/src/lib/exporter/localSourceFile.test.ts index 16b7c0007..705bd11d3 100644 --- a/src/lib/exporter/localSourceFile.test.ts +++ b/src/lib/exporter/localSourceFile.test.ts @@ -268,4 +268,78 @@ describe("materializeLocalSourceFile (large file OPFS path)", () => { // 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 (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 index f247d45d5..62b1b6237 100644 --- a/src/lib/exporter/localSourceFile.ts +++ b/src/lib/exporter/localSourceFile.ts @@ -70,6 +70,23 @@ function activeCacheNames(): Set { return new Set(activeCacheRefs.keys()); } +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; @@ -111,7 +128,7 @@ export async function materializeLocalSourceFile( throw new Error(result.message || result.error || "Failed to read source video"); } const name = (result.path || filename).split(/[\\/]/).pop() || filename; - return new File([result.data], name, { type: "video/mp4" }); + return new File([result.data], name, { type: mimeTypeForPath(name) }); } // Large recording: stream into OPFS and hand back a disk-backed File. @@ -141,63 +158,72 @@ async function copyToOpfsFile( // the same recording reuse the cached copy instead of re-streaming gigabytes. const cacheName = `${hashString(videoUrl)}-${size}-${Math.round(mtimeMs)}.bin`; - // Keep the entry we are about to use plus anything a live demuxer still reads. - const keep = activeCacheNames(); - keep.add(cacheName); - await pruneStaleEntries(dir, keep); + // Retain BEFORE pruning/writing: a concurrent materialization builds its keep + // set from the refcounts, so retaining only after the copy finished would let + // it prune this entry mid-write and fail the copy. Released in the catch + // below on failure; on success the caller releases once the demuxer is done. + retainCache(cacheName); + try { + await pruneStaleEntries(dir, activeCacheNames()); - const handle = await dir.getFileHandle(cacheName, { create: true }); + const handle = await dir.getFileHandle(cacheName, { create: true }); - // Reuse a complete prior copy. - const existing = await handle.getFile(); - if (existing.size === size) { - options?.onProgress?.({ copiedBytes: size, totalBytes: size }); - retainCache(cacheName); - return existing; - } + // Reuse a complete prior copy. + const existing = await handle.getFile(); + if (existing.size === size) { + options?.onProgress?.({ copiedBytes: size, totalBytes: size }); + return existing; + } - const chunkBytes = options?.chunkBytes ?? COPY_CHUNK_BYTES; - const writable = await handle.createWritable(); - try { - let offset = 0; - while (offset < size) { - 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"); + const chunkBytes = options?.chunkBytes ?? COPY_CHUNK_BYTES; + const writable = await handle.createWritable(); + try { + let offset = 0; + while (offset < size) { + 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; + options?.onProgress?.({ copiedBytes: offset, totalBytes: size }); } - // 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.close(); + } catch (error) { + try { + await writable.abort(); + } catch { + // ignore abort failure; surface the original error } - await writable.write(chunk.data); - offset += chunk.data.byteLength; - options?.onProgress?.({ copiedBytes: offset, totalBytes: size }); + throw error; } - await writable.close(); - } catch (error) { - try { - await writable.abort(); - } catch { - // ignore abort failure; surface the original 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.`, + ); } - // Drop the partial copy so a retry does not resume from a corrupt file. - try { - await dir.removeEntry(cacheName); - } catch { - // ignore cleanup failure + return file; + } catch (error) { + // Drop this operation's reference, then drop the (possibly partial) copy + // so a retry does not resume from a corrupt file — unless another + // in-flight operation still references the same entry. + releaseLocalSourceFile(cacheName); + if (!activeCacheRefs.has(cacheName)) { + try { + await dir.removeEntry(cacheName); + } catch { + // ignore cleanup failure + } } 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.`, - ); - } - retainCache(cacheName); - return file; } /** Removes cached copies in the directory whose names are not in `keep`. */ diff --git a/src/lib/exporter/streamingDecoder.ts b/src/lib/exporter/streamingDecoder.ts index f4cd6819a..79af8bf99 100644 --- a/src/lib/exporter/streamingDecoder.ts +++ b/src/lib/exporter/streamingDecoder.ts @@ -151,13 +151,12 @@ export async function loadFileAsArrayBuffer( const isRemoteUrl = /^(https?:|blob:|data:)/i.test(videoUrl); if (!isRemoteUrl && window.electronAPI) { - // This path loads the entire file into an ArrayBuffer (it feeds the trim - // waveform) and cannot stream, since decodeAudioData needs the whole file. - // readBinaryFile also copies the bytes in the main process during IPC, so a - // large recording would exhaust memory and crash. The waveform is cosmetic, - // so skip it above the in-memory limit; useAudioPeaks (the only caller) - // catches the throw and degrades to no waveform. The export path streams - // instead and does not rely on this function. + // 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."); From b264da4a088addb43824a685b39f9405cb39a3cb Mon Sep 17 00:00:00 2001 From: my_mac <1317811579@qq.com> Date: Mon, 6 Jul 2026 14:08:20 -0400 Subject: [PATCH 6/6] fix: abortable, deduplicated OPFS copies; harden waveform and caption paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Findings from an adversarial multi-agent cross-review of this branch: - Cancelling an export during the "preparing" phase used to leak the OPFS cache reference forever (destroy() ran before sourceCacheName was set) while the multi-GB copy kept streaming in the background. Copies are now abortable: materializeLocalSourceFile takes an AbortSignal, the decoder's cancel()/destroy() and the load timeout abort it, and references are only taken when a caller actually receives the File — no success, no retain, nothing to leak. GifExporter benefits via the same decoder hook. - Concurrent materializations of the same recording (waveform + export) used to race two writables on one OPFS handle and could invalidate the File another consumer was reading. In-flight copies are now deduplicated per cache entry: joiners share one stream (and its progress events), and the underlying copy aborts only when every joined caller has aborted. - The streaming waveform now falls back to a demux-only packet-timestamp scan when the container duration is missing/bogus (MediaRecorder WebM), and always closes its AudioDecoder on error/abort paths. - The caption demuxer path buffers decoded PCM in memory, so oversized sources now cap decoded audio at 30 min and surface `truncated` instead of exhausting the renderer heap on multi-hour recordings. - Stale multi-GB OPFS copies from previous sessions are reclaimed at app startup (previously only pruned during the next large-file load). Tests: in-flight dedup, mid-copy abort + cleanup, and shared-copy survival when one of two joined callers aborts (307 total passing). Co-Authored-By: Claude Opus 4.8 --- src/hooks/streamingAudioPeaks.ts | 92 ++++++++--- src/hooks/useAudioPeaks.ts | 3 +- src/lib/captioning/extractMono16k.ts | 30 +++- .../captioning/extractMono16kWebDemuxer.ts | 16 +- src/lib/exporter/localSourceFile.test.ts | 112 +++++++++++++ src/lib/exporter/localSourceFile.ts | 155 ++++++++++++++++-- src/lib/exporter/streamingDecoder.ts | 25 ++- src/main.tsx | 10 ++ 8 files changed, 391 insertions(+), 52 deletions(-) diff --git a/src/hooks/streamingAudioPeaks.ts b/src/hooks/streamingAudioPeaks.ts index a0560089b..8e906ad21 100644 --- a/src/hooks/streamingAudioPeaks.ts +++ b/src/hooks/streamingAudioPeaks.ts @@ -23,6 +23,36 @@ 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) => { @@ -63,11 +93,6 @@ export async function computePeaksFromFileStreaming( LOAD_TIMEOUT_MS, "Timed out while reading media info for the waveform.", ); - const durationSec = - Number.isFinite(mediaInfo.duration) && mediaInfo.duration > 0 ? mediaInfo.duration : 0; - if (durationSec <= 0) { - throw new Error("Unknown duration; cannot bucket waveform peaks."); - } let audioConfig: AudioDecoderConfig; try { @@ -81,6 +106,18 @@ export async function computePeaksFromFileStreaming( } 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, ...] @@ -113,28 +150,41 @@ export async function computePeaksFromFileStreaming( }); decoder.configure(audioConfig); - 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)); + 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 { - try { - await reader.cancel(); - } catch { - /* already closed */ + // 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 (decoder.state === "configured") { - await decoder.flush(); - decoder.close(); - } if (signal?.aborted) throw new DOMException("Aborted", "AbortError"); if (decodeError) throw decodeError; if (decodedFrames === 0) { diff --git a/src/hooks/useAudioPeaks.ts b/src/hooks/useAudioPeaks.ts index d6eb687a5..daa0abf09 100644 --- a/src/hooks/useAudioPeaks.ts +++ b/src/hooks/useAudioPeaks.ts @@ -74,7 +74,8 @@ async function computePeaksForUrl(videoUrl: string, signal?: AbortSignal): Promi 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:/, ""); - const file = await materializeLocalSourceFile(videoUrl, filename); + // 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 { diff --git a/src/lib/captioning/extractMono16k.ts b/src/lib/captioning/extractMono16k.ts index 999113e9d..bf2c7320f 100644 --- a/src/lib/captioning/extractMono16k.ts +++ b/src/lib/captioning/extractMono16k.ts @@ -6,6 +6,12 @@ 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(); @@ -33,9 +39,10 @@ async function loadSourceVideoFile(videoUrl: string, signal?: AbortSignal): Prom 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. + // 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); + return materializeLocalSourceFile(videoUrl, filename, { signal }); } const response = await fetchWithTimeout(videoUrl, signal); @@ -152,13 +159,26 @@ export async function extractMono16kFromVideoUrl( 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 primary = file.size > MAX_IN_MEMORY_SOURCE_BYTES ? null : await tryDecodeAudioDataPath(); + 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). diff --git a/src/lib/captioning/extractMono16kWebDemuxer.ts b/src/lib/captioning/extractMono16kWebDemuxer.ts index 3a00aa0bf..641f26351 100644 --- a/src/lib/captioning/extractMono16kWebDemuxer.ts +++ b/src/lib/captioning/extractMono16kWebDemuxer.ts @@ -92,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), @@ -132,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({ @@ -183,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 index 705bd11d3..7d4b14163 100644 --- a/src/lib/exporter/localSourceFile.test.ts +++ b/src/lib/exporter/localSourceFile.test.ts @@ -324,6 +324,118 @@ describe("materializeLocalSourceFile (large file OPFS path)", () => { }); }); +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"], diff --git a/src/lib/exporter/localSourceFile.ts b/src/lib/exporter/localSourceFile.ts index 62b1b6237..8f4fbb8c3 100644 --- a/src/lib/exporter/localSourceFile.ts +++ b/src/lib/exporter/localSourceFile.ts @@ -17,6 +17,13 @@ import { MAX_IN_MEMORY_SOURCE_BYTES } from "./sourceFileLimits"; * 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. */ @@ -34,6 +41,8 @@ export interface MaterializeProgress { 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). */ @@ -50,6 +59,17 @@ export interface MaterializeOptions { */ 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); } @@ -66,8 +86,28 @@ export function releaseLocalSourceFile(cacheName: string): void { else activeCacheRefs.set(cacheName, refs - 1); } -function activeCacheNames(): Set { - return new Set(activeCacheRefs.keys()); +/** 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 = { @@ -96,13 +136,17 @@ function hashString(input: string): string { 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 and (testing) threshold/chunk overrides. + * @param options Progress callback, abort signal, (testing) size overrides. */ export async function materializeLocalSourceFile( videoUrl: string, @@ -114,12 +158,14 @@ export async function materializeLocalSourceFile( 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) { @@ -127,6 +173,7 @@ export async function materializeLocalSourceFile( 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) }); } @@ -157,29 +204,102 @@ async function copyToOpfsFile( // 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; - // Retain BEFORE pruning/writing: a concurrent materialization builds its keep - // set from the refcounts, so retaining only after the copy finished would let - // it prune this entry mid-write and fail the copy. Released in the catch - // below on failure; on success the caller releases once the demuxer is done. - retainCache(cacheName); + // 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, activeCacheNames()); + 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) { - options?.onProgress?.({ copiedBytes: size, totalBytes: size }); - return existing; + emitProgress({ copiedBytes: size, totalBytes: size }); + return; } - const chunkBytes = options?.chunkBytes ?? COPY_CHUNK_BYTES; 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) { @@ -191,7 +311,7 @@ async function copyToOpfsFile( } await writable.write(chunk.data); offset += chunk.data.byteLength; - options?.onProgress?.({ copiedBytes: offset, totalBytes: size }); + emitProgress({ copiedBytes: offset, totalBytes: size }); } await writable.close(); } catch (error) { @@ -209,12 +329,11 @@ async function copyToOpfsFile( `Streamed copy is incomplete (${file.size} of ${size} bytes); the source video may still be in use.`, ); } - return file; } catch (error) { - // Drop this operation's reference, then drop the (possibly partial) copy - // so a retry does not resume from a corrupt file — unless another - // in-flight operation still references the same entry. - releaseLocalSourceFile(cacheName); + // 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); diff --git a/src/lib/exporter/streamingDecoder.ts b/src/lib/exporter/streamingDecoder.ts index 79af8bf99..34cfcd568 100644 --- a/src/lib/exporter/streamingDecoder.ts +++ b/src/lib/exporter/streamingDecoder.ts @@ -195,6 +195,10 @@ export class StreamingVideoDecoder { // 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( @@ -204,9 +208,11 @@ export class StreamingVideoDecoder { const isRemoteUrl = /^(https?:|blob:|data:)/i.test(videoUrl); if (!isRemoteUrl && window.electronAPI) { return this.withTimeout( - StreamingVideoDecoder.loadLocalSourceFile(videoUrl, onProgress), + 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( @@ -224,9 +230,10 @@ export class StreamingVideoDecoder { 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 }); + return materializeLocalSourceFile(videoUrl, filename, { onProgress, signal }); } /** Loads a remote or blob video URL via fetch. */ @@ -764,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 { @@ -795,9 +804,17 @@ export class StreamingVideoDecoder { } /** 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/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 ||