Skip to content

fix: stream large local recordings so the editor and export don't exhaust memory#74

Merged
EtienneLescot merged 7 commits into
getopenscreen:mainfrom
rainyflash:fix/large-recording-export
Jul 6, 2026
Merged

fix: stream large local recordings so the editor and export don't exhaust memory#74
EtienneLescot merged 7 commits into
getopenscreen:mainfrom
rainyflash:fix/large-recording-export

Conversation

@rainyflash

@rainyflash rainyflash commented Jul 6, 2026

Copy link
Copy Markdown

Problem

Large local recordings crash Openscreen in two places:

  1. Export — a recording above ~2 GiB fails with Failed to read binary file. A 2‑hour 1080p60 capture (~6.6 GB) can never be exported after editing.
  2. Editor — opening a ~1 GB recording hard-crashes the app (EXC_BREAKPOINT in the main process) before you can edit it.

Root cause

The renderer loads the source through the read-binary-file IPC, which reads the whole file with Node's fs.readFile and returns the bytes over IPC. That has two failure modes:

  • fs.readFile throws ERR_FS_FILE_TOO_LARGE above 2 GiB, so large files can't be read at all.
  • Below the cap, Electron structured-clones (copies) the returned buffer in the main process, so a ~1 GB file transiently needs ~2× its size there and OOM-crashes a memory-constrained machine (reproduced on a 16 GB Mac) — well below the 2 GiB read cap.

web-demuxer reads a File on demand, so the export never needed the bytes up front.

Fix

  • Chunked range-read IPC (get-readable-file-info + read-file-chunk), reusing the existing path allow-listing, with a 64 MiB per-chunk cap enforced on the main-process side.

  • Stream large sources into an OPFS-backed File (localSourceFile.ts): recordings above 256 MiB are streamed in 32 MB chunks and handed to web-demuxer as a disk-backed File, so peak memory stays flat regardless of length. Copies are cache-keyed and reference-counted — entries are retained before the prune/write, so a concurrent materialization can never prune an entry that is mid-write or still being read. Copy progress surfaces as a preparing export phase.

  • Export decoder and captions route through the streaming loader; the in-memory source-copy fast path is skipped for oversized files. Small-file MIME types are inferred from the extension.

  • Trim waveform streams too: a new peaks path demuxes the audio track and folds each decoded AudioData frame straight into min/max buckets (same output format as audioPeaksWorker), so large recordings keep their waveform instead of losing it — with flat memory.

  • Robustness (from an adversarial cross-review of this branch): OPFS copies are abortable (cancelling an export or unmounting the waveform stops the multi-GB copy; cache references are only taken on successful delivery, so nothing can leak) and deduplicated (waveform + export of the same recording share one in-flight copy instead of racing two writables). The streaming waveform falls back to a packet-timestamp scan when the container duration is bogus (MediaRecorder WebM) and always closes its AudioDecoder. Caption decode is capped at 30 min for oversized sources (surfaced as truncated) since its PCM buffers are in-memory. Stale cache copies from previous sessions are reclaimed at startup.

No new dependencies.

Verification

Tested on a 16 GB Mac (arm64) against real recordings:

Recording Before After
6.6 GB / 2h11m Failed to read binary file OPFS copy peaks ~137 MB heap; metadata + 1920×1080 frame decode at ~27 MB heap; full export succeeds
1 GB editor + export both crash editor opens and export completes
288 MB with audio (generated) streaming waveform: all 24 000 peak blocks populated, renderer heap 25→27 MB

tsc, Biome, and the unit suite pass (304 tests incl. OPFS streaming, eviction, refcount, and concurrent-prune race coverage).

Platform note: hands-on testing was macOS arm64 only; the changed paths are platform-agnostic (OPFS / web-demuxer / WebCodecs / thin fs IPC), but Windows/Linux runs would be welcome.

Notes / possible follow-ups

  • Known tradeoff: unedited MP4s between 256 MiB and ~1.5 GiB previously hit the in-memory source-copy fast path (instant export); they now take the streaming re-encode path. The old path OOM-crashed the main process at ~1 GB in testing, so this trades speed for reliability; a zero-copy fast path via a main-process file copy would restore the speed and is a natural follow-up.

  • The OPFS copy trades temporary disk for flat memory. A zero-copy path — serving the file over a Range-capable protocol and passing web-demuxer a URL source (it already does Range: bytes= requests) — would avoid the copy; happy to explore separately.

🤖 Generated with Claude Code

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 <noreply@anthropic.com>
@rainyflash rainyflash requested a review from EtienneLescot as a code owner July 6, 2026 13:10
@coderabbitai

coderabbitai Bot commented Jul 6, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This PR adds Electron file-info and chunk-read IPC, introduces OPFS-backed local source materialization for large recordings, and updates captioning, exporter, audio peak, and export UI flows to use size-aware loading and a new preparing phase.

Changes

Chunked local file access and large-file streaming

Layer / File(s) Summary
IPC and type contracts for file info and chunked reads
electron/electron-env.d.ts, electron/ipc/handlers.ts, electron/preload.ts
Adds getReadableFileInfo and readFileChunk declarations, implements the matching IPC handlers with approval and range validation, and exposes both through preload bindings.
Local source materialization with OPFS streaming
src/lib/exporter/sourceFileLimits.ts, src/lib/exporter/localSourceFile.ts, src/lib/exporter/localSourceFile.test.ts
Defines the in-memory size limit and implements materializeLocalSourceFile with cached OPFS chunked reads, cache pruning, cleanup, and test coverage for small-file, large-file, concurrency, and failure paths.
Exporter, captioning, and audio peak routing
src/lib/captioning/extractMono16k.ts, src/lib/captioning/extractMono16kWebDemuxer.ts, src/lib/exporter/streamingDecoder.ts, src/lib/exporter/videoExporter.ts, src/lib/exporter/types.ts, src/hooks/streamingAudioPeaks.ts, src/hooks/useAudioPeaks.ts
Switches local loading paths to the new materialization flow, adds source-size-based fallbacks and progress reporting, exports the mono helper, and routes waveform peak computation through a streamed path for large local sources.
Export dialog preparing state
src/components/video-editor/ExportDialog.tsx
Renders the new preparing phase with processing text and a spinner in the export dialog.

Estimated code review effort: 4 (Complex) | ~60 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the main change: streaming large local recordings to avoid memory exhaustion.
Description check ✅ Passed The description is detailed and covers the problem, fix, verification, and notes, though it doesn't follow the exact template headings.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (3)
src/lib/exporter/localSourceFile.test.ts (1)

1-86: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider adding coverage for the OPFS cache eviction logic.

Tests here are solid for the small-file path, but pruneStaleEntries/cache-reuse behavior in copyToOpfsFile (flagged separately in localSourceFile.ts) has no automated coverage. A test simulating two concurrently-cached large sources would have caught the single-slot pruning issue.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/exporter/localSourceFile.test.ts` around lines 1 - 86, Add test
coverage for the OPFS cache eviction and reuse behavior that lives in
materializeLocalSourceFile/copyToOpfsFile, since the current
localSourceFile.test.ts only covers the small-file branch. Create a scenario
with two large sources being cached concurrently so the second cache operation
exercises pruneStaleEntries and verifies the earlier entry is not incorrectly
evicted or reused. Use the existing materializeLocalSourceFile and
copyToOpfsFile flow, along with the Electron API stubs, to assert the intended
multi-source cache behavior.
src/lib/exporter/streamingDecoder.ts (1)

202-224: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

No progress feedback during large local-file OPFS materialization.

materializeLocalSourceFile supports an onProgress callback (used for the multi-GB streaming copy), but loadLocalSourceFile doesn't pass one through. Given LOCAL_SOURCE_LOAD_TIMEOUT_MS allows up to 15 minutes for this step, a large export could appear to hang with no feedback while the file streams into OPFS.

♻️ Proposed wiring for progress feedback
-	static async loadLocalSourceFile(videoUrl: string): Promise<File> {
+	static async loadLocalSourceFile(
+		videoUrl: string,
+		onProgress?: (progress: { copiedBytes: number; totalBytes: number }) => void,
+	): Promise<File> {
 		const filename = (videoUrl.split(/[\\/]/).pop() || "video").replace(/^file:/, "");
-		return materializeLocalSourceFile(videoUrl, filename);
+		return materializeLocalSourceFile(videoUrl, filename, onProgress);
 	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/exporter/streamingDecoder.ts` around lines 202 - 224,
`loadLocalSourceFile` currently calls `materializeLocalSourceFile` without
exposing its progress callback, so large OPFS copies have no feedback. Update
`streamingDecoder.ts` so `loadLocalSourceFile` accepts and forwards an
`onProgress` callback to `materializeLocalSourceFile`, and thread that through
any caller path that triggers local-file loading via
`loadSourceFile`/`loadMetadata` so multi-GB materialization can report progress.
src/lib/exporter/localSourceFile.ts (1)

22-24: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Threshold constant duplicated across 3 files.

The same 1.5 * 1024 * 1024 * 1024 threshold is independently redefined here, as MAX_DECODE_AUDIO_DATA_BYTES in extractMono16k.ts, and as SOURCE_COPY_MAX_BYTES in videoExporter.ts. If one is tuned later without updating the others, the size-based fallback behavior will silently diverge between captions, export fast-path, and OPFS materialization.

♻️ Proposed fix: export and reuse a single constant
-const LARGE_FILE_THRESHOLD_BYTES = 1.5 * 1024 * 1024 * 1024;
+// Shared with extractMono16k.ts and videoExporter.ts — keep one source of truth.
+export const LARGE_FILE_THRESHOLD_BYTES = 1.5 * 1024 * 1024 * 1024;

Then import LARGE_FILE_THRESHOLD_BYTES from extractMono16k.ts and videoExporter.ts instead of redeclaring it.

Also applies to: 28-28

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/exporter/localSourceFile.ts` around lines 22 - 24, The file-size
threshold is duplicated across localSourceFile, extractMono16k, and
videoExporter, which can cause the fallback behavior to drift if one value
changes. Move the shared 1.5 * 1024 * 1024 * 1024 limit into a single exported
constant, then update the LARGE_FILE_THRESHOLD_BYTES,
MAX_DECODE_AUDIO_DATA_BYTES, and SOURCE_COPY_MAX_BYTES usages to import and
reuse that shared symbol instead of redefining it.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/lib/exporter/localSourceFile.ts`:
- Line 106: The pruning step in localSourceFile’s export flow is deleting OPFS
cache entries that are still needed by the same export. Update the cache cleanup
around pruneStaleEntries in localSourceFile so it does not remove entries while
the demuxer is still active; keep live cache names until the decoder/export is
destroyed, or defer pruning until no later read via demuxer.read("video", ...)
can occur. Use the localSourceFile export path and pruneStaleEntries call as the
main touchpoints.

---

Nitpick comments:
In `@src/lib/exporter/localSourceFile.test.ts`:
- Around line 1-86: Add test coverage for the OPFS cache eviction and reuse
behavior that lives in materializeLocalSourceFile/copyToOpfsFile, since the
current localSourceFile.test.ts only covers the small-file branch. Create a
scenario with two large sources being cached concurrently so the second cache
operation exercises pruneStaleEntries and verifies the earlier entry is not
incorrectly evicted or reused. Use the existing materializeLocalSourceFile and
copyToOpfsFile flow, along with the Electron API stubs, to assert the intended
multi-source cache behavior.

In `@src/lib/exporter/localSourceFile.ts`:
- Around line 22-24: The file-size threshold is duplicated across
localSourceFile, extractMono16k, and videoExporter, which can cause the fallback
behavior to drift if one value changes. Move the shared 1.5 * 1024 * 1024 * 1024
limit into a single exported constant, then update the
LARGE_FILE_THRESHOLD_BYTES, MAX_DECODE_AUDIO_DATA_BYTES, and
SOURCE_COPY_MAX_BYTES usages to import and reuse that shared symbol instead of
redefining it.

In `@src/lib/exporter/streamingDecoder.ts`:
- Around line 202-224: `loadLocalSourceFile` currently calls
`materializeLocalSourceFile` without exposing its progress callback, so large
OPFS copies have no feedback. Update `streamingDecoder.ts` so
`loadLocalSourceFile` accepts and forwards an `onProgress` callback to
`materializeLocalSourceFile`, and thread that through any caller path that
triggers local-file loading via `loadSourceFile`/`loadMetadata` so multi-GB
materialization can report progress.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: e0a0f3bd-1f0d-473e-b207-81e8bbaaf1dd

📥 Commits

Reviewing files that changed from the base of the PR and between 80cb2b9 and f0e077c.

📒 Files selected for processing (8)
  • electron/electron-env.d.ts
  • electron/ipc/handlers.ts
  • electron/preload.ts
  • src/lib/captioning/extractMono16k.ts
  • src/lib/exporter/localSourceFile.test.ts
  • src/lib/exporter/localSourceFile.ts
  • src/lib/exporter/streamingDecoder.ts
  • src/lib/exporter/videoExporter.ts

Comment thread src/lib/exporter/localSourceFile.ts Outdated
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 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
src/lib/exporter/localSourceFile.test.ts (1)

155-244: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add coverage for concurrent copies of the same cache key.

The new suite covers sequential reuse and prune/refcount semantics well, but there's no test exercising two concurrent materializeLocalSourceFile calls racing to write the same cacheName (e.g. Promise.all([materialize(url), materialize(url)])), which is exactly the scenario in the concurrency finding on copyToOpfsFile's catch-block cleanup. FakeWritable/FakeFileHandle would need to model OPFS's siloed multi-writer semantics (or at least a forced failure on one of two concurrent writers) to catch that regression.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/exporter/localSourceFile.test.ts` around lines 155 - 244, The
large-file OPFS test suite in materializeLocalSourceFile is missing coverage for
two concurrent calls racing to write the same cache entry. Add a test around
materializeLocalSourceFile/copyToOpfsFile that starts two Promise.all copies for
the same url/cacheName and forces one writer to fail so the catch-block cleanup
is exercised. Update FakeWritable/FakeFileHandle or the test harness to simulate
OPFS multi-writer behavior, then assert the surviving copy is correct and no
stale partial cache entry remains.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/lib/exporter/localSourceFile.ts`:
- Around line 51-70: The refcount tracking in retainSource and
releaseLocalSourceFile is keyed only by videoUrl, so a new cacheName can
overwrite an older still-active revision and cause releases to decrement the
wrong entry. Update the tracking in localSourceFile to distinguish revisions by
both videoUrl and cacheName, and make releaseLocalSourceFile decrement the
matching retained revision instead of the latest one. Ensure activeSources and
any related bookkeeping preserve outstanding refs for older revisions until
their own releases arrive.

---

Nitpick comments:
In `@src/lib/exporter/localSourceFile.test.ts`:
- Around line 155-244: The large-file OPFS test suite in
materializeLocalSourceFile is missing coverage for two concurrent calls racing
to write the same cache entry. Add a test around
materializeLocalSourceFile/copyToOpfsFile that starts two Promise.all copies for
the same url/cacheName and forces one writer to fail so the catch-block cleanup
is exercised. Update FakeWritable/FakeFileHandle or the test harness to simulate
OPFS multi-writer behavior, then assert the surviving copy is correct and no
stale partial cache entry remains.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 3f857ae1-91cd-41e6-a6e1-bb7ba7f85ab0

📥 Commits

Reviewing files that changed from the base of the PR and between f0e077c and 55a97af.

📒 Files selected for processing (8)
  • src/components/video-editor/ExportDialog.tsx
  • src/lib/captioning/extractMono16k.ts
  • src/lib/exporter/localSourceFile.test.ts
  • src/lib/exporter/localSourceFile.ts
  • src/lib/exporter/sourceFileLimits.ts
  • src/lib/exporter/streamingDecoder.ts
  • src/lib/exporter/types.ts
  • src/lib/exporter/videoExporter.ts
✅ Files skipped from review due to trivial changes (1)
  • src/lib/exporter/sourceFileLimits.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/lib/exporter/streamingDecoder.ts

Comment thread src/lib/exporter/localSourceFile.ts Outdated
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 <noreply@anthropic.com>
@rainyflash

Copy link
Copy Markdown
Author

Thanks for the follow-up review — addressed in 655cf00:

Refcount keyed only by videoUrl (Minor): Fixed. The refcount is now keyed by the cache-entry name (which equals the returned File.name) rather than the source URL, so revisions are independent and a release can never decrement the wrong entry. Callers release with file.name (StreamingVideoDecoder.destroy() and the captions extractor's finally).

Concurrent copies of the same cacheName (Nitpick): I skipped the full two-writer race test. Two concurrent materialize calls for the same recording can't occur in the editor (export is modal and captions use the same source revision), and faithfully modelling OPFS's siloed multi-writer semantics in the fake wouldn't be a meaningful regression guard. I did add the cheaper, real-value part it was pointing at — a test that a failed chunk read mid-copy throws and leaves no partial cache entry behind (exercises the catch-block removeEntry cleanup).

All 299 unit tests, tsc, and Biome pass.

@EtienneLescot

Copy link
Copy Markdown
Collaborator

@rainyflash Thanks for your PR!
Did you test it against all platforms?

@EtienneLescot EtienneLescot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here is a code review of the chunked file streaming solution. Overall, this is an excellent and necessary addition to support > 2 GiB files, but there is a major race condition in copyToOpfsFile's cache pruning, along with a few minor security/robustness recommendations.

Comment thread src/lib/exporter/localSourceFile.ts Outdated
Comment thread src/lib/exporter/localSourceFile.ts Outdated
Comment thread electron/ipc/handlers.ts
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 <noreply@anthropic.com>
@rainyflash rainyflash changed the title fix: stream >2GB recordings into OPFS so long videos can export fix: stream large local recordings so the editor and export don't exhaust memory Jul 6, 2026
@EtienneLescot

Copy link
Copy Markdown
Collaborator

Hi @rainyflash! We are currently working on a PR to resolve the crash/freeze during finalization/saving of large recordings (issue #41) by streaming the WebM duration patching on disk rather than loading the file entirely in memory, and adding a \saving\ loading state to the recording window UI. Your PR (#74) for streaming the files during edit and export is highly complementary to this, as together they will fully solve OOM issues for large recordings! We will cross-reference this once our PR is opened.

Review feedback from getopenscreen#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 <noreply@anthropic.com>
@rainyflash

Copy link
Copy Markdown
Author

Thanks for the review @EtienneLescot! All three points are addressed in 35937a9 (replied inline with details): the prune race is fixed by retaining the cache entry before pruning/writing (with a concurrent-copy regression test), read-file-chunk now enforces a 64 MiB per-chunk cap on the main-process side, and the small-file MIME type is inferred from the extension.

Re: platform testing — honest answer: macOS (arm64) only. That's the machine I have. What was tested there:

  • Real 6.6 GB / 2h11m recording and a real 1 GB recording: editor open, metadata, frame decode, and full export, with renderer heap staying flat (~25–140 MB) throughout.
  • The full unit suite (304 tests, incl. the new OPFS streaming/eviction/race coverage) passes.

The changed code paths are platform-agnostic by construction — OPFS, web-demuxer, WebCodecs, and two thin fs.open/fs.stat IPC handlers; no OS-specific branches — but I have not run Windows or Linux builds, so I'd appreciate a second pair of eyes there if anyone has those environments handy.

One more improvement in the same commit: instead of skipping the trim waveform for large recordings (a silent feature regression of my earlier commit), there is now a streaming peaks path — the audio track is demuxed and each decoded AudioData frame is folded straight into min/max buckets (same output format as audioPeaksWorker), so large recordings keep their waveform with flat memory. Verified on a generated 288 MB file with a sine track: all 24 000 blocks populated, renderer heap 25→27 MB.

@EtienneLescot

Copy link
Copy Markdown
Collaborator

Hey! I've created PR #77 to implement the disk-streaming duration patching optimization for large WebM files and HUD saving indicator to resolve the memory crashes in #41. This PR builds on the findings of your PR.

rainyflash and others added 2 commits July 6, 2026 14:08
… paths

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 <noreply@anthropic.com>
@rainyflash

Copy link
Copy Markdown
Author

(Also: great to see #77 — save-time streaming plus this PR's edit/export streaming covers the full large-recording lifecycle, and the two PRs touch disjoint files so they can land independently.)

After addressing your review, we ran a systematic adversarial cross-review of the whole branch (multiple independent passes: correctness / concurrency / security / memory / behavior-regressions) — the prune race you caught suggested the concurrency surface deserved a full audit. It did turn up more issues in the same family, all fixed in b264da4:

  • Cancelling an export during the OPFS copy leaked the cache reference and couldn't stop the copy → copies are now abortable (decoder cancel()/destroy()/timeout all abort), and references are only taken on successful delivery — no success, no retain, nothing to leak.
  • Waveform + export of the same recording raced two writables on one OPFS handle → in-flight copies are now deduplicated; the copy aborts only when every joined caller has aborted.
  • Streaming waveform trusted the container duration (bogus for MediaRecorder WebM) → falls back to a packet-timestamp scan.
  • AudioDecoder leaked on error paths → always closed in finally.
  • The caption path buffers decoded PCM in memory, so multi-hour sources could OOM → capped at 30 min for oversized files, surfaced as truncated.
  • Stale multi-GB OPFS copies persisted across sessions → reclaimed at startup.

Three concurrency/abort regression tests added; 307 tests passing. PR description updated to match.

I realize this grew the PR — if you'd prefer to review it in smaller pieces, I'm happy to split b264da4 (or parts of it) into a follow-up PR instead. Your call.

@EtienneLescot EtienneLescot merged commit 654375b into getopenscreen:main Jul 6, 2026
11 checks passed
EtienneLescot pushed a commit that referenced this pull request Jul 6, 2026
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 <noreply@anthropic.com>
@rainyflash rainyflash deleted the fix/large-recording-export branch July 6, 2026 18:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants