Skip to content

Commit 2ad49f4

Browse files
rucchoclaude
andcommitted
apply push backpressure instead of unbounded output channel in vt video 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>
1 parent 0960c35 commit 2ad49f4

1 file changed

Lines changed: 74 additions & 33 deletions

File tree

  • InstantReplay.Externals/unienc/crates/unienc_apple_vt/src/video

InstantReplay.Externals/unienc/crates/unienc_apple_vt/src/video/mod.rs

Lines changed: 74 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,23 @@ use unienc_common::{
2424
use crate::{MetalTexture, common::UnsafeSendRetained, metal};
2525
use unienc_common::TryFromUnityNativeTexturePointer;
2626

27+
/// Capacity of the bounded channel carrying encoded frames from the VideoToolbox callback to the
28+
/// consumer. `push` reserves a slot before submitting each frame, so when the consumer falls behind
29+
/// and the channel fills up, `push` suspends (backpressure) instead of the callback dropping
30+
/// already-encoded frames (which would break the P-frame reference chain).
31+
const OUTPUT_CHANNEL_CAPACITY: usize = 32;
32+
33+
/// A reserved output-channel slot handed to a single encode callback via the per-frame source-frame
34+
/// ref-con. Completing the send through it can neither block the callback nor drop the frame.
35+
type OutputPermit = mpsc::OwnedPermit<Result<VideoEncodedData>>;
36+
2737
pub struct VideoToolboxEncoder {
2838
input: VideoToolboxEncoderInput,
2939
output: VideoToolboxEncoderOutput,
3040
}
3141
pub struct VideoToolboxEncoderInput {
3242
session: CompressionSession,
33-
tx: Box<mpsc::UnboundedSender<Result<VideoEncodedData>>>,
43+
tx: Box<mpsc::Sender<Result<VideoEncodedData>>>,
3444
width: u32,
3545
height: u32,
3646
bitrate: u32,
@@ -43,7 +53,7 @@ struct CompressionSession {
4353
unsafe impl Send for VideoToolboxEncoderInput {}
4454

4555
pub struct VideoToolboxEncoderOutput {
46-
rx: mpsc::UnboundedReceiver<Result<VideoEncodedData>>,
56+
rx: mpsc::Receiver<Result<VideoEncodedData>>,
4757
}
4858

4959
pub struct VideoEncodedData {
@@ -105,31 +115,33 @@ impl EncodedData for VideoEncodedData {
105115
}
106116

107117
unsafe extern "C-unwind" fn handle_video_encode_output(
108-
output_callback_ref_con: *mut c_void,
109-
_source_frame_ref_con: *mut c_void,
118+
_output_callback_ref_con: *mut c_void,
119+
source_frame_ref_con: *mut c_void,
110120
status: i32,
111121
_info_flags: VTEncodeInfoFlags,
112122
sample_buffer: *mut CMSampleBuffer,
113123
) {
114-
let tx = unsafe {
115-
&*(output_callback_ref_con as *const mpsc::UnboundedSender<Result<VideoEncodedData>>)
116-
};
124+
// Each submitted frame carries a pre-reserved output-channel slot (an OwnedPermit) as its
125+
// source-frame ref-con. Completing the send through that permit means this callback never
126+
// blocks (it runs on VideoToolbox's internal queue, which complete_frames() waits on) and
127+
// never drops an encoded frame (dropping would break the reference chain of subsequent
128+
// P-frames). Backpressure is instead applied in `push`, which awaits the reservation before
129+
// submitting the frame, so a full channel throttles the producer rather than corrupting output.
130+
let permit = unsafe { *Box::from_raw(source_frame_ref_con as *mut OutputPermit) };
117131

118132
// Propagate encode failures to the output side instead of silently ignoring them.
119133
if let Err(err) = status.to_result() {
120-
// send only fails when the output has already been dropped
121-
_ = tx.send(Err(err));
134+
permit.send(Err(err));
122135
return;
123136
}
124137

125138
if let Some(sample_buffer) = unsafe { Retained::retain(sample_buffer) } {
126-
// The channel is unbounded, so encoded frames are never dropped here. Dropping a frame at
127-
// this layer would break the reference chain of subsequent P-frames; any backpressure /
128-
// dropping policy is applied by the consumer (BoundedEncodedDataBuffer on the C# side).
129-
// Blocking this callback is not an option either: it runs on VideoToolbox's internal
130-
// queue, and complete_frames() waits for pending callbacks to return.
131-
_ = tx.send(Ok(VideoEncodedData::new(sample_buffer.into())));
132-
} // otherwise the frame was dropped by the encoder itself (e.g. kVTEncodeInfo_FrameDropped)
139+
permit.send(Ok(VideoEncodedData::new(sample_buffer.into())));
140+
} else {
141+
// The encoder itself dropped the frame (e.g. kVTEncodeInfo_FrameDropped); dropping the
142+
// permit returns the reserved slot to the channel.
143+
drop(permit);
144+
}
133145
}
134146

135147
unsafe extern "C-unwind" fn release_pixel_buffer(
@@ -155,6 +167,19 @@ impl EncoderInput for VideoToolboxEncoderInput {
155167
type Data = VideoSample<MetalTexture>;
156168

157169
async fn push(&mut self, data: Self::Data) -> unienc_common::Result<()> {
170+
// Reserve an output-channel slot before doing any encode work. When the consumer is behind
171+
// and the channel is full, this await suspends `push`, propagating backpressure up the
172+
// pipeline so the C# side drops *input* frames (via DroppingChannelInput) instead of us
173+
// dropping already-encoded output. Holding the OwnedPermit (Send) across the blit await
174+
// below is fine; if building the frame fails, dropping the permit releases the slot.
175+
let permit = self
176+
.tx
177+
.as_ref()
178+
.clone()
179+
.reserve_owned()
180+
.await
181+
.map_err(AppleError::from)?;
182+
158183
let buffer = match data.frame {
159184
unienc_common::VideoFrame::Bgra32(bgra32) => {
160185
let buffer = bgra32.buffer;
@@ -232,30 +257,49 @@ impl EncoderInput for VideoToolboxEncoderInput {
232257
}
233258
};
234259

260+
// Hand the reserved slot to this frame's encode callback via the source-frame ref-con. No
261+
// await happens past this point, so the raw pointer is never held across a suspension.
262+
let permit_ptr = Box::into_raw(Box::new(permit));
263+
235264
let mut retry = 0;
236265

237-
loop {
266+
let res = loop {
238267
let res = unsafe {
239268
self.session.inner.encode_frame(
240269
&buffer,
241270
CMTime::with_seconds(data.timestamp, 720),
242271
kCMTimeInvalid,
243272
None,
244-
std::ptr::null_mut(),
273+
permit_ptr as *mut c_void,
245274
std::ptr::null_mut(),
246275
)
247276
};
248277

249278
if res == kVTInvalidSessionErr && retry == 0 {
250-
// VTCompressionSession turns invalid when the app enters background on iOS
251-
// retrying once
279+
// VTCompressionSession turns invalid when the app enters background on iOS; retry
280+
// once with a fresh session, re-passing the same reserved permit.
252281
retry += 1;
253-
self.session =
254-
CompressionSession::new(self.width, self.height, self.bitrate, &*self.tx)?;
255-
continue;
282+
match CompressionSession::new(self.width, self.height, self.bitrate) {
283+
Ok(session) => {
284+
self.session = session;
285+
continue;
286+
}
287+
Err(err) => {
288+
// No callback will run for this frame; reclaim the permit to free the slot.
289+
drop(unsafe { Box::from_raw(permit_ptr) });
290+
return Err(err.into());
291+
}
292+
}
256293
}
257294

258-
break res.to_result()?;
295+
break res;
296+
};
297+
298+
if let Err(err) = res.to_result() {
299+
// The frame was not accepted, so the callback will never consume the permit; reclaim it
300+
// to release the reserved slot.
301+
drop(unsafe { Box::from_raw(permit_ptr) });
302+
return Err(err.into());
259303
}
260304

261305
Ok(())
@@ -291,12 +335,7 @@ impl Drop for VideoToolboxEncoderInput {
291335
}
292336

293337
impl CompressionSession {
294-
fn new(
295-
width: u32,
296-
height: u32,
297-
bitrate: u32,
298-
tx: *const mpsc::UnboundedSender<Result<VideoEncodedData>>,
299-
) -> Result<Self> {
338+
fn new(width: u32, height: u32, bitrate: u32) -> Result<Self> {
300339
let mut session: *mut VTCompressionSession = std::ptr::null_mut();
301340

302341
unsafe {
@@ -309,7 +348,9 @@ impl CompressionSession {
309348
None,
310349
None,
311350
Some(handle_video_encode_output),
312-
tx as *mut c_void,
351+
// The output callback receives its channel slot per-frame via the source-frame
352+
// ref-con (see `push`), so no session-level ref-con is needed.
353+
std::ptr::null_mut(),
313354
NonNull::new(&mut session).ok_or(AppleError::NonNullCreationFailed)?,
314355
)
315356
.to_result()?;
@@ -348,14 +389,14 @@ impl CompressionSession {
348389

349390
impl VideoToolboxEncoder {
350391
pub fn new(options: &impl unienc_common::VideoEncoderOptions) -> Result<Self> {
351-
let (tx, rx) = mpsc::unbounded_channel();
392+
let (tx, rx) = mpsc::channel(OUTPUT_CHANNEL_CAPACITY);
352393
let tx = Box::new(tx);
353394

354395
let (width, height, bitrate) = (options.width(), options.height(), options.bitrate());
355396

356397
Ok(VideoToolboxEncoder {
357398
input: VideoToolboxEncoderInput {
358-
session: CompressionSession::new(width, height, bitrate, &*tx)?,
399+
session: CompressionSession::new(width, height, bitrate)?,
359400
tx,
360401
width,
361402
height,

0 commit comments

Comments
 (0)