Fix silent frame drop and missing trailing audio in Apple VideoToolbox encoder#150
Draft
ruccho wants to merge 4 commits into
Draft
Fix silent frame drop and missing trailing audio in Apple VideoToolbox encoder#150ruccho wants to merge 4 commits into
ruccho wants to merge 4 commits into
Conversation
the VTCompressionSession output callback used try_send on a bounded channel and silently discarded encoded H.264 frames when the channel was full, breaking the reference chain of subsequent P-frames. it also ignored the encode status, making encoder failures undetectable. use an unbounded channel carrying Result so no encoded frame is ever dropped at this layer (backpressure/dropping is handled upstream by BoundedEncodedDataBuffer on the C# side) and propagate encode errors to the output pull path. blocking the callback was not an option since it runs on VideoToolbox's internal queue and complete_frames() waits for pending callbacks. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
the fill_complex_buffer loop in push ends once the input buffer is consumed, so complete packets buffered inside the AudioConverter and the final partial (<1024 samples) packet were never emitted, cutting off the tail of every recording. signal end of stream when the encoder input is dropped by returning noErr with zero packets from the input proc, and loop until the converter reports no more output. the output channel is now unbounded so the drop-time drain can send synchronously from any thread. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…eo encoder Replace the unbounded output channel with a bounded one and reserve a slot in `push` (via reserve_owned) before submitting each frame, handing the OwnedPermit to the encode callback through the per-frame source-frame ref-con. A full channel now suspends `push` — propagating backpressure up to the C# DroppingChannelInput, which drops input frames — instead of growing memory unboundedly. The callback still never blocks (it runs on VideoToolbox's queue awaited by complete_frames) and never drops encoded frames. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Two bugs in the Apple VideoToolbox implementation.
1. Silent frame drop and ignored encode status in the VT output callback
handle_video_encode_output(a) fully ignored_status(the encode result), so encode failures went undetected, and (b) silently dropped encoded H.264 frames whentx.try_sendreturned Full (bounded channel, capacity 32). A dropped P-frame breaks references of subsequent frames and produces block noise; frames flushed viacomplete_frameson Drop could also be lost.2. No EOS drain in the audio encoder, dropping trailing samples
The
fill_complex_bufferloop terminates once input is consumed, so completed packets remaining inside the AudioConverter plus any sub-1024-sample remainder are never drained at end of input. Every session lost the last few tens of milliseconds of audio.Fix
Video: propagate backpressure to
pushinstead of dropping at the callbackThe callback must neither drop encoded frames (corrupts the P-frame reference chain) nor block (it runs on VideoToolbox's internal queue, which
complete_frames()waits on). So flow control is moved to the producer:push, reserve a channel slot up front viareserve_owned().awaitbefore doing any encode work. When the consumer falls behind and the channel is full, this suspendspush, propagating backpressure up the pipeline so the C#DroppingChannelInputdrops input frames rather than the encoder corrupting output.OwnedPermitto that frame's encode callback via VideoToolbox's per-framesource_frame_ref_con. The callback completes the send through the permit, which can neither block nor drop. Encode failures are propagated asErrthrough the channel; frames the encoder itself drops (kVTEncodeInfo_FrameDropped) release the permit.kVTInvalidSessionErrretry) reclaim the permit so the reserved slot is never leaked.This closes the earlier concern that an unbounded channel provided no upstream backpressure: the encoded-output channel is now hard-bounded, and when full it throttles the producer (which the input-side dropping stage absorbs) instead of growing memory.
The audio channel intentionally stays unbounded: audio is low-bandwidth, dropping audio packets would create irrecoverable gaps (there is no keyframe/eviction equivalent), packets are produced synchronously inside
pushat input rate, and the end-of-stream drain runs fromDropwhere anawaitis not available. It is drained continuously by the consumer, so it does not grow in practice.Audio: drain the converter at end of stream
Added an EOS flag to
fill_complex_buffer: on input exhaustion it returns "0 packets + noErr" to signal end of stream to the converter (the standard flush). Addeddrain(), invoked fromDrop for AudioToolboxEncoderInput(input Drop = EOS signal), to flush the completed packets and the final partial packet.Verification (macOS)
cargo check -p unienc_apple_vt/cargo clippy: no new warnings (the pre-existingruntimedead-code andmetal/mod.rsArc warnings are unrelated).cargo test -p unienc_apple_vt -p unienc_common: all pass.cargo test -p unienc --test integration_test(real mp4 E2E, exercising push → encode → bounded channel + backpressure → pull → mux): pass, no deadlock. ffprobe on the output shows all 10 pushed video frames present and ~474 AAC packets (≈10.03s, trailing audio retained); A/V durations match.Note: the Android/Windows/Linux/WebCodecs implementations are out of scope for this PR.
🤖 Generated with Claude Code