Skip to content

Fix silent frame drop and missing trailing audio in Apple VideoToolbox encoder#150

Draft
ruccho wants to merge 4 commits into
mainfrom
fix/apple-vt-frame-drop
Draft

Fix silent frame drop and missing trailing audio in Apple VideoToolbox encoder#150
ruccho wants to merge 4 commits into
mainfrom
fix/apple-vt-frame-drop

Conversation

@ruccho

@ruccho ruccho commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator

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 when tx.try_send returned Full (bounded channel, capacity 32). A dropped P-frame breaks references of subsequent frames and produces block noise; frames flushed via complete_frames on Drop could also be lost.

2. No EOS drain in the audio encoder, dropping trailing samples
The fill_complex_buffer loop 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 push instead of dropping at the callback

The 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:

  • Keep a bounded output channel (capacity 32).
  • In push, reserve a channel slot up front via reserve_owned().await before doing any encode work. When the consumer falls behind and the channel is full, this suspends push, propagating backpressure up the pipeline so the C# DroppingChannelInput drops input frames rather than the encoder corrupting output.
  • Hand the reserved OwnedPermit to that frame's encode callback via VideoToolbox's per-frame source_frame_ref_con. The callback completes the send through the permit, which can neither block nor drop. Encode failures are propagated as Err through the channel; frames the encoder itself drops (kVTEncodeInfo_FrameDropped) release the permit.
  • Error/retry paths (including the iOS-background kVTInvalidSessionErr retry) 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 push at input rate, and the end-of-stream drain runs from Drop where an await is 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). Added drain(), invoked from Drop 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-existing runtime dead-code and metal/mod.rs Arc 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

ruccho and others added 4 commits July 3, 2026 11:44
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>
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