Skip to content

fix(export): realtime writer inputs — offline AV export ran at ~0.2 fps and dropped frames - #374

Merged
jeffcrouse merged 1 commit into
mainfrom
fix-av-export-realtime-input
Sep 19, 2026
Merged

jeffcrouse merged 1 commit into
mainfrom
fix-av-export-realtime-input

Conversation

@jeffcrouse

Copy link
Copy Markdown
Member

Summary

The deterministic offline export (export_av, ADR-0032 Phase C) was crawling at ~0.2 fps and silently dropping frames: a 116-frame export took ~10 minutes and the file held 49 frames. Found while generating real media for the gate-1 Review workspace.

Root cause

The offline path set the AVAssetWriter inputs to expectsMediaDataInRealTime = NO, assuming that's the faster-than-realtime mode. Measured, it's the opposite for our producer: in non-realtime mode Apple's H.264 encoder holds the video input not-ready until it has a batch of ~16 frames (or a ~1.5 s internal timeout flushes one). Vivid appends one frame per frame-loop tick, so every frame ate that timeout, and when wait_ready gave up the frame was dropped without a word.

Reproduced standalone in Swift with the exporter's exact settings, one frame per 120 Hz timer tick:

input mode result
non-realtime (before) 37 frames in 20 s
non-realtime, frame reordering off / power-efficiency off / VT realtime hint no change
non-realtime, appends in bursts of 16 fast — confirms the batching
realtime (this PR) 120 frames in ~1 s, zero waits

Back-to-back appends are fast in both modes, which is why the export looked fine when first validated (#325–#327).

Changes

  • Both inputs are expectsMediaDataInRealTime = YES in every mode. Determinism never came from that flag — it comes from the explicit PTS in write_video_frame / write_audio_samples, unchanged.
  • wait_ready polls with usleep instead of pumping a nested NSRunLoop — readiness is flipped from the writer's queue, and the pump was running hosted plugins' GUI timers mid-export with the audio device paused.
  • A refused frame is no longer silent (ADR-0019): AvBounceResult.dropped_frames, reported by av_export_status, the completion toast ("N frames DROPPED", Warning), and the MCP docstring.

Verified

Same 2-bar export on the plugin-heavy song-sketch copy: ~2 s, 116/116 frames in the file, dropped_frames: 0.

Test plan

  • test_av_bounce — refusing mock: 5 of 15 refused ⇒ dropped_frames == 5, video 10, audio complete; healthy mock ⇒ 0 (279 checks)
  • Live export before/after on the same project (above)
  • Full headless ctest green (91); MCP parity + layering guards green
  • CI (test_av_bounce is in the audio-engine tier)

🤖 Generated with Claude Code

….2 fps and dropped frames

The deterministic offline export (ADR-0032 Phase C) put its AVAssetWriter inputs in
non-realtime mode on the assumption that is the faster-than-realtime setting. Measured, it is
the opposite for OUR producer: in non-realtime mode Apple's H.264 encoder holds the video
input NOT-ready until it has a batch of ~16 frames (or a ~1.5 s internal timeout flushes one).
Vivid appends one frame per frame-loop tick, so every frame waited on that timeout: ~0.2 fps,
and when wait_ready gave up the frame was DROPPED without a word — a 116-frame export took
~10 minutes and the file held 49 frames. Reproduced standalone (Swift, same settings): a
one-frame-per-tick producer does 37 frames in 20 s non-realtime vs 120 frames in ~1 s
realtime; back-to-back appends are fast in both modes, which is why the export looked fine
when first validated.

- Both inputs are now `expectsMediaDataInRealTime = YES` in every mode. Offline determinism
  never came from that flag — it comes from the explicit PTS in write_video_frame /
  write_audio_samples, unchanged.
- wait_ready polls with usleep instead of pumping a nested NSRunLoop: readiness is flipped
  from the writer's queue, not the main run loop, and the pump was running hosted plugins'
  GUI timers mid-export with the audio device paused.
- A refused frame is no longer silent (ADR-0019): AvExportJob counts it,
  AvBounceResult.dropped_frames carries it, av_export_status reports it, the completion
  toast says "N frames DROPPED" at Warning level, and the MCP docstring tells agents to treat
  such an export as incomplete.

Verified: the same 2-bar export on the plugin-heavy song-sketch copy now completes in ~2 s
with 116/116 frames in the file and dropped_frames=0. test_av_bounce gains a refusing mock
(5 of 15 refused => dropped_frames 5, audio complete) and a healthy-encoder zero check.
Full headless ctest green; MCP parity + layering guards green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jeffcrouse
jeffcrouse enabled auto-merge (squash) September 19, 2026 13:54
@github-actions

Copy link
Copy Markdown
Contributor

Production gate (core): ✅ PASS

  • 91/91 tests passed (0 failed) in 20.043s

@jeffcrouse
jeffcrouse merged commit 22c043e into main Sep 19, 2026
8 checks passed
@jeffcrouse
jeffcrouse deleted the fix-av-export-realtime-input branch September 19, 2026 14:02
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.

1 participant