moq_video/encode/sink.rs
1//! An [`Encoder`](super::Encoder) that owns the thread it runs on, so any
2//! thread (or task) can drive it.
3//!
4//! Off macOS the encoder runs on a dedicated OS thread (mirroring the capture
5//! pump): the Windows hardware encoder is a Media
6//! Foundation MFT whose COM handles must be created, driven, and dropped all on
7//! one thread (COM apartments are per-thread), and whose encode call blocks on
8//! MFT events. Driving it inline on a tokio worker would unbalance the
9//! per-thread COM refcount as the future migrates between workers and park a
10//! worker on a stalled MFT. A synchronous caller has the same problem for the
11//! same reason: an FFI object shared between threads opens the apartment on
12//! whichever thread built it and closes it on whichever thread drops it.
13//! Confining the whole encoder lifetime to one thread fixes both; frames are
14//! `Send` there (Windows D3D11 textures and CPU I420 both are) and packets come
15//! back over a channel.
16//!
17//! macOS keeps encoding inline: VideoToolbox has no COM apartment to balance and
18//! doesn't block on an event loop, so a thread would only add a hop, and its
19//! zero-copy `CVPixelBuffer` surface is `!Send` and couldn't cross to one anyway.
20
21use std::sync::Arc;
22
23use super::Encoded;
24use super::encoder::Config;
25use crate::{Error, Frame};
26
27#[cfg(target_os = "macos")]
28use inline::Inner;
29#[cfg(not(target_os = "macos"))]
30use threaded::Inner;
31
32/// An [`Encoder`](super::Encoder) confined to one thread, driven from anywhere.
33///
34/// Same shape as [`Encoder`](super::Encoder), one method at a time, except that
35/// the calls are `async` and [`encode`](Self::encode) takes the frame by value
36/// (it may cross a thread). Reach for this instead of an `Encoder` whenever the
37/// encoder outlives a single thread's stack: an object shared across threads, an
38/// FFI handle, a task that migrates between executor workers. An `Encoder` you
39/// build, drive, and drop inside one function needs none of it.
40///
41/// Awaiting rather than blocking is the point: the codec runs on its own thread,
42/// so the executor keeps its worker while a slow hardware encoder works through
43/// a frame. A caller with no executor to yield to (an FFI boundary that must
44/// return a result synchronously) blocks on these futures itself.
45///
46/// # Cancellation
47///
48/// These futures are not cancel-safe, and the sink says so rather than letting
49/// it slide. The codec runs on its own thread, so a request that has been queued
50/// runs whether or not anyone is still waiting: dropping the future (racing it in
51/// a `select!`, giving it a timeout) leaves the codec a step ahead of the stream,
52/// holding output nobody received. Rather than let the next call carry on and
53/// publish a track quietly missing those frames, the sink refuses every call
54/// after a cancelled one. Drop it and open another.
55///
56/// Racing an encode against a shutdown signal is fine, since the sink is on its
57/// way out anyway. What does not work is cancelling one and carrying on.
58///
59/// macOS never refuses, because there is no thread to run ahead: the encoder
60/// runs inline, so a dropped future either had not started the call or had
61/// already finished it. Write to the contract above regardless, or the same code
62/// loses frames off macOS.
63pub struct Sink(Inner);
64
65impl Sink {
66 /// Open an encoder for `config` on its own thread. Returns once the encoder
67 /// is built (or its construction fails), so a bad config or a missing backend
68 /// surfaces here rather than on the first frame.
69 pub async fn open(config: &Config) -> Result<Self, Error> {
70 Ok(Self(Inner::open(config).await?))
71 }
72
73 /// The encoder name in use, e.g. `"mediafoundation"`.
74 pub fn name(&self) -> &str {
75 self.0.name()
76 }
77
78 /// Cut a new group at the next frame, like
79 /// [`Encoder::cut`](super::Encoder::cut).
80 ///
81 /// Queued behind the frames already in flight rather than applied to
82 /// whichever one the codec happens to be on, so it opens the group at the
83 /// next frame you pass to [`encode`](Self::encode). Awaited for the
84 /// backend's verdict: a backend that cannot cut refuses here with
85 /// [`Error::CutUnsupported`](crate::Error::CutUnsupported), the same answer
86 /// the direct encoder gives, rather than queueing a request it will ignore.
87 pub async fn cut(&mut self) -> Result<(), Error> {
88 self.0.cut().await
89 }
90
91 /// Encode one frame, waiting for its access units.
92 ///
93 /// Otherwise [`Encoder::encode`](super::Encoder::encode): zero or more access
94 /// units, each stamped with the frame it came from.
95 ///
96 /// Takes ownership, since the frame may be moved to the encode thread, but
97 /// takes it as anything that can become an [`Arc`] so a caller fanning one
98 /// frame out to several encoders (a transcode ladder) hands over a clone of
99 /// the handle rather than a copy of the pixels. Pass a [`Frame`] and it is
100 /// wrapped for you.
101 pub async fn encode(&mut self, frame: impl Into<Arc<Frame>>) -> Result<Vec<Encoded>, Error> {
102 self.0.encode(frame.into()).await
103 }
104
105 /// Retune the encoder, waiting for the backend's verdict. See
106 /// [`Encoder::set_bitrate`](super::Encoder::set_bitrate) for what a failure
107 /// means (not fatal: stop adapting, keep encoding).
108 pub async fn set_bitrate(&mut self, bitrate: moq_net::bandwidth::Rate) -> Result<(), Error> {
109 self.0.set_bitrate(bitrate).await
110 }
111
112 /// Empty the codec at a boundary the output has to respect, leaving it ready
113 /// for the frames that follow. See [`Encoder::flush`](super::Encoder::flush).
114 ///
115 /// A live track needs this at every group boundary: a backend that pipelines
116 /// is still holding the last frames of a group when it ends, and they would
117 /// otherwise surface in the next group ahead of its keyframe, where a
118 /// subscriber joining there cannot decode them. Publishing frame by frame
119 /// with no group structure needs none of it.
120 pub async fn flush(&mut self) -> Result<Vec<Encoded>, Error> {
121 self.0.flush().await
122 }
123
124 /// Drain the codec, returning every access unit it was still holding, and
125 /// shut the encoder down.
126 ///
127 /// Consumes the sink, like [`Encoder::finish`](super::Encoder::finish).
128 /// Dropping a sink without this is fine and tears down just as cleanly, it
129 /// just discards the tail: publish the returned frames before ending a track,
130 /// or its last pictures never reach a subscriber.
131 pub async fn finish(self) -> Result<Vec<Encoded>, Error> {
132 self.0.finish().await
133 }
134}
135
136#[cfg(not(target_os = "macos"))]
137mod threaded {
138 use std::sync::Arc;
139
140 use tokio::sync::{mpsc, oneshot};
141
142 use super::super::Encoded;
143 use super::super::encoder::{Config, Encoder};
144 use crate::worker::{Ready, Worker};
145 use crate::{Error, Frame};
146
147 /// Work for the encode thread. Every variant goes down the same channel so a
148 /// cut or a bitrate change lands in order with the frames around it, rather
149 /// than racing them.
150 enum Request {
151 /// A frame to encode, plus a oneshot to return the resulting access units
152 /// (or an error) in order.
153 Encode {
154 frame: Arc<Frame>,
155 resp: oneshot::Sender<Result<Vec<Encoded>, Error>>,
156 },
157 /// Cut a group at the next frame, reporting whether the backend can: a
158 /// refusal has to reach the caller, since the alternative is a group
159 /// boundary that silently never happens.
160 Cut { resp: oneshot::Sender<Result<(), Error>> },
161 /// Retune to a new bitrate, reporting whether the backend took it so the
162 /// caller can stop adapting against an encoder that can't. The round trip
163 /// is affordable because the rate control policy only sends one of these
164 /// when the target moves meaningfully, not per frame.
165 SetBitrate {
166 bitrate: moq_net::bandwidth::Rate,
167 resp: oneshot::Sender<Result<(), Error>>,
168 },
169 /// Empty the codec at a group boundary, leaving it running. Unlike
170 /// `Finish` the encoder survives, so this is served like any other
171 /// request.
172 Flush {
173 resp: oneshot::Sender<Result<Vec<Encoded>, Error>>,
174 },
175 /// Drain the codec and shut down, returning the tail. Last request the
176 /// thread serves: `Encoder::finish` consumes the encoder, so the loop has
177 /// to break out rather than come back round for another frame.
178 Finish {
179 resp: oneshot::Sender<Result<Vec<Encoded>, Error>>,
180 },
181 }
182
183 /// Build an encoder for `config` and serve requests until the channel closes.
184 /// Runs entirely on the encode thread; see [`crate::worker`].
185 fn run(config: Config, ready: Ready, mut requests: mpsc::UnboundedReceiver<Request>) {
186 let mut encoder = match Encoder::new(&config) {
187 Ok(encoder) => encoder,
188 Err(err) => return ready.err(err),
189 };
190 // If the awaiting `open` was cancelled, give up before encoding.
191 if !ready.ok(encoder.name()) {
192 return;
193 }
194
195 // Serve each request in arrival order. The encoder and its COM / MFT
196 // handles are created, used, and dropped only on this thread. `finish`
197 // consumes the encoder, so it breaks out and drains below rather than
198 // serving another request.
199 let mut draining = None;
200 while let Some(req) = requests.blocking_recv() {
201 match req {
202 Request::Encode { frame, resp } => {
203 let _ = resp.send(encoder.encode(&frame));
204 }
205 Request::Cut { resp } => {
206 let _ = resp.send(encoder.cut());
207 }
208 Request::SetBitrate { bitrate, resp } => {
209 let _ = resp.send(encoder.set_bitrate(bitrate));
210 }
211 Request::Flush { resp } => {
212 let _ = resp.send(encoder.flush());
213 }
214 Request::Finish { resp } => {
215 draining = Some(resp);
216 break;
217 }
218 }
219 }
220 // The drain runs here, on this thread, and consumes the encoder; otherwise
221 // `encoder` drops here. Either way the COM apartment it opened closes on
222 // the thread that opened it.
223 if let Some(resp) = draining {
224 let _ = resp.send(encoder.finish());
225 }
226 }
227
228 /// An [`Encoder`] running on its own thread. See the module docs.
229 pub struct Inner(Worker<Request>);
230
231 impl Inner {
232 pub async fn open(config: &Config) -> Result<Self, Error> {
233 let config = config.clone();
234 let worker = Worker::open("moq-video-encode", move |ready, requests| run(config, ready, requests)).await?;
235 Ok(Self(worker))
236 }
237
238 pub fn name(&self) -> &str {
239 self.0.name()
240 }
241
242 pub async fn cut(&mut self) -> Result<(), Error> {
243 self.0.request(|resp| Request::Cut { resp }).await
244 }
245
246 pub async fn encode(&mut self, frame: Arc<Frame>) -> Result<Vec<Encoded>, Error> {
247 self.0.request(|resp| Request::Encode { frame, resp }).await
248 }
249
250 pub async fn set_bitrate(&mut self, bitrate: moq_net::bandwidth::Rate) -> Result<(), Error> {
251 self.0.request(|resp| Request::SetBitrate { bitrate, resp }).await
252 }
253
254 pub async fn flush(&mut self) -> Result<Vec<Encoded>, Error> {
255 self.0.request(|resp| Request::Flush { resp }).await
256 }
257
258 pub async fn finish(mut self) -> Result<Vec<Encoded>, Error> {
259 // `self` drops on the way out, which drops the sender and joins the
260 // thread that just drained and released the encoder.
261 self.0.request(|resp| Request::Finish { resp }).await
262 }
263 }
264}
265
266#[cfg(target_os = "macos")]
267mod inline {
268 use std::sync::Arc;
269
270 use super::super::Encoded;
271 use super::super::encoder::{Config, Encoder};
272 use crate::{Error, Frame};
273
274 /// An [`Encoder`] driven inline on the calling thread (see the module docs).
275 pub struct Inner(Encoder);
276
277 // SAFETY: VideoToolbox and Core Foundation handles may move between threads
278 // when calls remain serialized. `Sink` provides that serialization; the
279 // synchronous `Encoder` remains thread-bound.
280 unsafe impl Send for Inner {}
281
282 impl Inner {
283 pub async fn open(config: &Config) -> Result<Self, Error> {
284 Ok(Self(Encoder::new(config)?))
285 }
286
287 pub fn name(&self) -> &str {
288 self.0.name()
289 }
290
291 /// Async only to match the threaded `Inner`; there's no thread to hand this
292 /// to, so it runs inline. The same holds for the calls below.
293 pub async fn cut(&mut self) -> Result<(), Error> {
294 self.0.cut()
295 }
296
297 pub async fn encode(&mut self, frame: Arc<Frame>) -> Result<Vec<Encoded>, Error> {
298 self.0.encode(&frame)
299 }
300
301 pub async fn set_bitrate(&mut self, bitrate: moq_net::bandwidth::Rate) -> Result<(), Error> {
302 self.0.set_bitrate(bitrate)
303 }
304
305 pub async fn flush(&mut self) -> Result<Vec<Encoded>, Error> {
306 self.0.flush()
307 }
308
309 pub async fn finish(self) -> Result<Vec<Encoded>, Error> {
310 self.0.finish()
311 }
312 }
313}
314
315#[cfg(test)]
316mod tests {
317 #[cfg(not(target_os = "macos"))]
318 use std::collections::HashSet;
319 #[cfg(not(target_os = "macos"))]
320 use std::sync::{Arc, Mutex};
321 #[cfg(not(target_os = "macos"))]
322 use std::thread::ThreadId;
323
324 use super::super::backend::probe;
325 use super::super::{Codec, Kind};
326 use super::*;
327 use crate::{I420, Surface};
328
329 /// A mid-gray frame at the probe backend's resolution, stamped as the
330 /// `index`th frame of a 30fps stream.
331 fn gray(index: u64) -> Frame {
332 let size = crate::Size::new(320, 240);
333 let i420 = I420::new(size, vec![0x80u8; I420::len(size).unwrap()]).unwrap();
334 Frame::new(
335 Surface::I420(i420),
336 moq_net::Timestamp::from_micros(index * 33_333).unwrap(),
337 )
338 }
339
340 fn probe_config() -> Config {
341 let mut config = Config::new(320, 240, crate::Rate::new(30, 1).unwrap());
342 config.codec = Codec::H264;
343 config.kind = Kind::Named(probe::NAME.into());
344 config
345 }
346
347 /// The sink and the direct encoder answer a cut the same way: queued for the
348 /// next frame on a backend that can, and that frame is the one the codec
349 /// sees it on rather than whichever it was busy with.
350 #[test]
351 fn a_cut_lands_on_the_next_frame() {
352 let _probe = probe::exclusive();
353
354 let mut sink = pollster::block_on(Sink::open(&probe_config())).unwrap();
355 pollster::block_on(sink.encode(gray(0))).unwrap();
356 pollster::block_on(sink.cut()).unwrap();
357 pollster::block_on(sink.encode(gray(1))).unwrap();
358 pollster::block_on(sink.encode(gray(2))).unwrap();
359 drop(sink);
360
361 let events: Vec<_> = probe::take()
362 .into_iter()
363 .map(|(event, _)| event)
364 .filter(|event| matches!(*event, "encode" | "cut"))
365 .collect();
366 assert_eq!(events, vec!["encode", "encode", "cut", "encode"]);
367 }
368
369 /// A backend that cannot cut refuses through the sink exactly as it does
370 /// directly, and the sink stays usable: the refusal is the caller's to act
371 /// on, not a poisoned session.
372 #[test]
373 fn a_backend_that_cannot_cut_refuses_through_the_sink() {
374 let _probe = probe::exclusive();
375
376 let mut config = probe_config();
377 config.kind = Kind::Named(probe::NO_CUT.into());
378 let mut sink = pollster::block_on(Sink::open(&config)).unwrap();
379 assert_eq!(sink.name(), probe::NO_CUT);
380
381 let err = pollster::block_on(sink.cut()).expect_err("the backend cannot cut");
382 assert!(
383 matches!(err, Error::CutUnsupported(name) if name == probe::NO_CUT),
384 "unexpected error: {err:?}"
385 );
386
387 pollster::block_on(sink.encode(gray(0))).unwrap();
388 drop(sink);
389 let log = probe::take();
390 assert!(
391 !log.iter().any(|(event, _)| *event == "cut"),
392 "a refused cut still reached the codec: {log:?}"
393 );
394 }
395
396 /// Regression: a queued request runs on the encode thread whether or not the
397 /// caller is still waiting, so a cancelled `encode` leaves the codec a step
398 /// ahead of the stream with output nobody received. Carrying on would publish
399 /// a track quietly missing those frames, which is worse than an error: only
400 /// the publisher could ever tell, and only by decoding its own output.
401 ///
402 /// macOS is exempt by design: the inline sink encodes on the calling thread,
403 /// so there is nothing to run ahead (see the module docs).
404 #[cfg(not(target_os = "macos"))]
405 #[test]
406 fn a_cancelled_call_poisons_the_sink() {
407 let _probe = probe::exclusive();
408
409 let mut sink = pollster::block_on(Sink::open(&probe_config())).unwrap();
410
411 // Cancel an encode the moment it starts waiting, the shape a `select!` or a
412 // timeout produces. Holding the codec inside the call is what makes the
413 // cancel land mid-flight rather than race the encode thread for it.
414 let gate = probe::hold();
415 pollster::block_on(async {
416 let mut encode = Box::pin(sink.encode(gray(0)));
417 assert!(
418 futures::poll!(encode.as_mut()).is_pending(),
419 "the encode should still be waiting on the held codec"
420 );
421 // Dropped here, with the request queued and the reply still to come.
422 });
423 drop(gate);
424
425 // The codec really did run, so the stream is missing whatever came back.
426 let err = pollster::block_on(sink.encode(gray(1))).expect_err("the sink should refuse");
427 assert!(err.to_string().contains("cancelled"), "unexpected error: {err}");
428 // ...and it stays refused rather than recovering on the call after.
429 assert!(pollster::block_on(sink.flush()).is_err());
430
431 drop(sink);
432 let log = probe::take();
433 assert!(
434 log.iter().any(|(event, _)| *event == "encode"),
435 "the cancelled request should still have reached the codec: {log:?}"
436 );
437 }
438
439 /// Regression: the Windows backend opens a COM apartment on the thread that
440 /// builds the codec and closes it on the thread that drops it, so a codec
441 /// reachable from more than one thread has to own a thread of its own. Both
442 /// FFI bindings held a bare `Encoder` and drove it from whichever thread
443 /// called in, which leaked the opening thread's initialization and ran
444 /// `CoUninitialize` on a thread that never initialized COM.
445 ///
446 /// Asserted on every platform rather than only Windows: the confinement is
447 /// what the bindings now rely on, so it should fail here rather than on a
448 /// machine none of CI has. macOS is exempt by design: the inline sink has
449 /// no thread of its own to confine anything to.
450 #[cfg(not(target_os = "macos"))]
451 #[test]
452 fn the_codec_stays_on_one_thread_however_it_is_driven() {
453 let _probe = probe::exclusive();
454
455 let sink = Arc::new(Mutex::new(Some(
456 pollster::block_on(Sink::open(&probe_config())).unwrap(),
457 )));
458
459 // Drive it the way an FFI handle gets driven: a fresh caller thread every
460 // time, none of them the thread that opened it.
461 let mut callers = vec![std::thread::current().id()];
462 let mut flushed = Vec::new();
463 for index in 0..3u64 {
464 let sink = sink.clone();
465 let caller = std::thread::spawn(move || {
466 let mut guard = sink.lock().unwrap();
467 let sink = guard.as_mut().unwrap();
468 pollster::block_on(sink.cut()).unwrap();
469 pollster::block_on(sink.encode(gray(index))).unwrap();
470 pollster::block_on(sink.set_bitrate(moq_net::bandwidth::Rate::from_bps(500_000 + index))).unwrap();
471 // Only the first frame closes a group, so the two after it stay in
472 // the codec and leave the drain below something to find.
473 let flushed = match index {
474 0 => pollster::block_on(sink.flush()).unwrap(),
475 _ => Vec::new(),
476 };
477 (std::thread::current().id(), flushed)
478 });
479 let (caller, drained) = caller.join().unwrap();
480 callers.push(caller);
481 flushed.extend(drained);
482 }
483
484 // The probe holds each frame back by one, so a flush that reached the codec
485 // hands back the frame the group ended on. An inherited no-op would return
486 // nothing here and silently drop it into the next group.
487 let flushed: Vec<_> = flushed.iter().map(|frame| frame.timestamp.as_micros()).collect();
488 assert_eq!(flushed, vec![0], "the group boundary did not empty the codec");
489
490 // ...and finished, so dropped, from yet another.
491 let closer = std::thread::spawn(move || {
492 let sink = sink.lock().unwrap().take().unwrap();
493 let tail = pollster::block_on(sink.finish()).unwrap();
494 (std::thread::current().id(), tail)
495 });
496 let (closer, tail) = closer.join().unwrap();
497 callers.push(closer);
498
499 // Frame 2 never came back from an encode call and no flush claimed it, so
500 // the drain has to. Dropping the sink instead would lose it silently.
501 let tail: Vec<_> = tail.iter().map(|frame| frame.timestamp.as_micros()).collect();
502 assert_eq!(tail, vec![2 * 33_333], "the drain lost the codec's tail");
503
504 let log = probe::take();
505 for what in ["open", "encode", "set_bitrate", "flush", "finish", "drop"] {
506 assert!(log.iter().any(|(event, _)| *event == what), "no {what} in {log:?}");
507 }
508
509 let threads: HashSet<ThreadId> = log.iter().map(|(_, id)| *id).collect();
510 assert_eq!(threads.len(), 1, "the codec ran on more than one thread: {log:?}");
511
512 let codec = threads.into_iter().next().unwrap();
513 assert!(
514 !callers.contains(&codec),
515 "the codec ran on a caller's thread rather than its own: {log:?}"
516 );
517 }
518}