Skip to main content

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}