Skip to main content

moq_video/encode/
encoder.rs

1//! Video encoder front end.
2//!
3//! Accepts raw [`Frame`]s and delegates the actual encode to a
4//! [`Backend`](super::backend::Backend). The resulting frames carry Annex-B in
5//! the framing the catalog importer for [`Config::codec`] expects: H.264
6//! (`moq_mux::codec::h264`) or H.265 (`moq_mux::codec::h265`).
7
8use super::Encoded;
9use super::backend::{self, Backend};
10use crate::{Color, Error, Frame, Size};
11
12/// Output video codec. `#[non_exhaustive]` so new codecs can be added without
13/// breaking external `match`es.
14///
15/// Not every codec has a backend on every platform: H.265 is hardware-only
16/// (VideoToolbox on macOS today). Building an [`Encoder`] returns
17/// [`Error::NoEncoder`](crate::Error::NoEncoder) when nothing can encode the
18/// requested codec on this machine.
19#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
20#[non_exhaustive]
21pub enum Codec {
22	/// H.264 / AVC, Annex-B with in-band SPS/PPS (the "avc3" shape). The widest
23	/// support and the default.
24	#[default]
25	H264,
26	/// H.265 / HEVC, Annex-B with in-band VPS/SPS/PPS (the "hev1" shape).
27	H265,
28}
29
30/// Which encoder implementation to use. `#[non_exhaustive]` so new selection
31/// strategies can be added without breaking external `match`es.
32#[derive(Clone, Debug, Default, PartialEq, Eq)]
33#[non_exhaustive]
34pub enum Kind {
35	/// Prefer a platform hardware encoder, falling back to the openh264 software
36	/// encoder when none is available.
37	#[default]
38	Auto,
39	/// Hardware only; error if none is available.
40	Hardware,
41	/// Software only (openh264 for H.264).
42	Software,
43	/// A specific backend by name, e.g. `"videotoolbox"`, `"nvenc"`, `"vaapi"`,
44	/// or `"openh264"`.
45	Named(String),
46}
47
48/// Encoder configuration. `width` / `height` / `framerate` are the encoded
49/// output; input frames must already be at this resolution.
50///
51/// `#[non_exhaustive]`: build via [`Config::new`] and set the optional fields,
52/// so future knobs don't break callers.
53#[derive(Clone, Debug)]
54#[non_exhaustive]
55pub struct Config {
56	pub width: u32,
57	pub height: u32,
58	pub framerate: u32,
59	/// Target bitrate in bits per second. `None` derives a sane default
60	/// from resolution and framerate (~0.07 bits per pixel per second).
61	pub bitrate: Option<u64>,
62	/// Keyframe interval in frames. Subscribers joining mid-stream wait at
63	/// most this many frames before they can start decoding.
64	pub gop: u32,
65	/// Output codec. Defaults to [`Codec::H264`].
66	pub codec: Codec,
67	pub kind: Kind,
68	/// The color space of the input frames, written into the bitstream's VUI so a
69	/// decoder doesn't have to guess. `None` uses [`Color::infer`], which is both
70	/// what the crate's own RGB conversions produce and what a player falls back
71	/// to, so leaving it unset keeps the pixels and the label in agreement.
72	///
73	/// Set it only when feeding frames the crate did not convert and whose space
74	/// you know from elsewhere.
75	pub color: Option<Color>,
76}
77
78impl Config {
79	/// A config encoding `width` x `height` at `framerate`, with the default
80	/// codec, GOP, and bitrate.
81	pub fn new(width: u32, height: u32, framerate: u32) -> Self {
82		Self {
83			width,
84			height,
85			framerate,
86			bitrate: None,
87			// ~2 seconds at the configured framerate.
88			gop: framerate.saturating_mul(2).max(1),
89			codec: Codec::default(),
90			kind: Kind::Auto,
91			color: None,
92		}
93	}
94
95	/// The encoded resolution.
96	pub fn size(&self) -> Size {
97		Size::new(self.width, self.height)
98	}
99
100	/// Resolved input color space: explicit override, or the size-based guess
101	/// every player makes for an untagged stream. Backends write this into the
102	/// VUI; the crate's RGB conversions pick the same answer for the same size,
103	/// so the samples match what the bitstream claims.
104	pub(crate) fn resolved_color(&self) -> Color {
105		self.color.unwrap_or_else(|| Color::infer(self.size()))
106	}
107
108	/// Resolved bitrate: explicit override, or a pixels-per-second estimate.
109	pub(crate) fn resolved_bitrate(&self) -> u64 {
110		self.bitrate.unwrap_or_else(|| {
111			// 0.07 bits per pixel per second matches the JS publisher's
112			// default and lands ~4.4 Mbps for 1080p30.
113			((self.size().pixels() * self.framerate as u64) as f64 * 0.07) as u64
114		})
115	}
116}
117
118/// Video encoder. Build one with [`Encoder::new`], feed it raw [`Frame`]s via
119/// [`encode`](Self::encode), and publish the resulting [`Encoded`] access units
120/// through a [`Producer`](super::Producer) built for the same [`Codec`].
121pub struct Encoder {
122	backend: Box<dyn Backend>,
123	codec: Codec,
124	size: Size,
125	bitrate: u64,
126	/// What the backend wrote into the bitstream's VUI, kept so a frame declaring
127	/// a different space is caught rather than silently mislabeled.
128	color: Color,
129	/// A keyframe asked for by [`Encoder::keyframe`], applied to the next frame.
130	/// Held rather than applied immediately because the caller decides a group
131	/// boundary before it has the frame that opens it.
132	pending_keyframe: bool,
133}
134
135impl Encoder {
136	/// Open an encoder for `config`.
137	pub fn new(config: &Config) -> Result<Self, Error> {
138		// Validate at the construction boundary so both entry points (the
139		// capture loop and a bring-your-own-frames caller) reject a zero
140		// framerate, which would produce a degenerate codec time base.
141		if config.framerate == 0 {
142			return Err(Error::InvalidFramerate(0));
143		}
144		// I420 chroma is subsampled 2x2, so the encoded resolution must be even.
145		let size = config.size();
146		size.validate("encoder")?;
147
148		let backend = backend::open(config)?;
149		Ok(Self {
150			backend,
151			codec: config.codec,
152			size,
153			bitrate: config.resolved_bitrate(),
154			color: config.resolved_color(),
155			pending_keyframe: false,
156		})
157	}
158
159	/// The encoder name in use, e.g. `"videotoolbox"`.
160	pub fn name(&self) -> &str {
161		self.backend.name()
162	}
163
164	/// The resolution this encoder emits, which every frame fed to it must match.
165	pub fn size(&self) -> Size {
166		self.size
167	}
168
169	/// The current target bitrate in bits per second: what
170	/// [`Config::bitrate`] resolved to at open, or the last value
171	/// [`set_bitrate`](Self::set_bitrate) accepted.
172	pub fn bitrate(&self) -> u64 {
173		self.bitrate
174	}
175
176	/// Retune the live encoder to `bitrate` bits per second, taking effect from
177	/// roughly the next frame. No IDR is forced, so this is cheap enough to
178	/// drive from a congestion controller: pair it with
179	/// [`rate::Control`](super::rate::Control), which decides *when* the target
180	/// is worth moving.
181	///
182	/// Setting the rate the encoder is already at does nothing and succeeds.
183	///
184	/// # Errors
185	///
186	/// Returns [`Error::BitrateUnsupported`] if this backend can't retune while
187	/// running. That's not fatal: the encoder keeps running at its current rate,
188	/// so a caller driving a control loop should stop adapting rather than stop
189	/// encoding.
190	pub fn set_bitrate(&mut self, bitrate: u64) -> Result<(), Error> {
191		if bitrate == self.bitrate {
192			return Ok(());
193		}
194		self.backend.set_bitrate(bitrate)?;
195		// Only after the backend accepts it, so a failed set doesn't leave the
196		// getter reporting a rate the encoder isn't using.
197		self.bitrate = bitrate;
198		Ok(())
199	}
200
201	/// The codec this encoder emits. A [`Producer`](super::Producer) must be
202	/// built for the same codec to publish its packets.
203	pub fn codec(&self) -> Codec {
204		self.codec
205	}
206
207	/// Ask for the next frame to be encoded as a keyframe (an IDR), on top of the
208	/// ones [`Config::gop`] already inserts on its own.
209	///
210	/// Rarely needed: the encoder keys frames automatically, so reach for this only
211	/// when something outside the encoder needs a decodable starting point at a
212	/// specific frame. Opening a new group is the usual reason (a subscriber has to
213	/// be able to start there); resuming after an idle gap is another.
214	///
215	/// The request waits for the next [`encode`](Self::encode) rather than applying
216	/// at once, so it is safe to call before the frame exists. Calling it repeatedly
217	/// before a frame arrives asks for one keyframe, not several.
218	pub fn keyframe(&mut self) {
219		self.pending_keyframe = true;
220	}
221
222	/// Encode one raw [`Frame`], whether it came from capture, a decoder (the
223	/// transcode input path), or your own pixels via
224	/// [`Surface::rgba`](crate::Surface::rgba).
225	///
226	/// Returns zero or more encoded access units, each carrying the timestamp of the
227	/// raw frame it came from: a backend that buffers hands back an earlier frame's
228	/// output, so the two don't always line up.
229	///
230	/// A GPU surface feeds a hardware encoder on the same device directly
231	/// (NVDEC -> NVENC never leaves the GPU, a `CVPixelBuffer` goes straight to
232	/// VideoToolbox); anything else falls back to a CPU I420 upload. The frame must
233	/// already be at the encoder's resolution: decode with
234	/// [`decode::Config::resize`](crate::decode::Config), or scale first with
235	/// [`Frame::resize`](crate::Frame::resize).
236	pub fn encode(&mut self, frame: &Frame) -> Result<Vec<Encoded>, Error> {
237		// A transposed frame is why this compares the shape rather than a byte
238		// count: 240x320 and 320x240 hold the same number of bytes.
239		let size = frame.size();
240		if size != self.size {
241			return Err(Error::Codec(anyhow::anyhow!(
242				"frame {size} does not match encoder {}",
243				self.size
244			)));
245		}
246		// The VUI is fixed when the session opens, so a frame in a different space
247		// gets encoded under the wrong label. Reached by resizing across the
248		// standard-definition boundary, where the pixels keep their space but the
249		// encoder was sized into another one; `Config::color` is the way to pin it.
250		//
251		// Warn rather than reject: a live gateway transcoding a source whose VUI
252		// disagrees with its resolution should keep serving a mislabeled stream
253		// rather than drop it, and this is no worse than the untagged stream that
254		// came before. Once, because it would otherwise fire every frame.
255		if let Some(color) = frame.surface.color()
256			&& color != self.color
257		{
258			static WARN_ONCE: std::sync::Once = std::sync::Once::new();
259			WARN_ONCE.call_once(|| {
260				tracing::warn!(
261					frame = ?color,
262					encoder = ?self.color,
263					"frame color space differs from the one written into the bitstream; set encode::Config::color"
264				);
265			});
266		}
267		let encoded = self.backend.encode(frame, self.pending_keyframe)?;
268		// Cleared only once the frame is through: a failed encode produced no
269		// picture, so the request still belongs to whatever comes next rather than
270		// being swallowed. The size check above returns early for the same reason.
271		self.pending_keyframe = false;
272		Ok(encoded)
273	}
274
275	/// Return every access unit the codec is still holding, leaving the encoder
276	/// ready for the frames that follow. Each keeps the timestamp of the raw frame
277	/// it was encoded from, so a drained tail stays in step with what came before
278	/// it.
279	///
280	/// Reach for this at a boundary the output has to respect, which for a live
281	/// broadcast is a group: a hardware codec that pipelines holds the last frames
282	/// of a group past its end, and they would otherwise be published into the next
283	/// group ahead of its keyframe, where a consumer joining there cannot decode
284	/// them. Publishing frame-by-frame with no group structure needs none of this.
285	///
286	/// Not free: emptying the pipeline gives up the overlap between one frame's
287	/// encode and the next frame's submission, so flush at boundaries rather than
288	/// per frame.
289	pub fn flush(&mut self) -> Result<Vec<Encoded>, Error> {
290		self.backend.flush()
291	}
292
293	/// Flush the encoder, returning any buffered frames. Each keeps the timestamp
294	/// of the raw frame it was encoded from, so a drained tail stays in step with
295	/// what was published before it.
296	///
297	/// Consumes the encoder: nothing can be encoded after a flush, so this is the
298	/// last call rather than one leaving a drained encoder in your hands.
299	pub fn finish(mut self) -> Result<Vec<Encoded>, Error> {
300		self.backend.finish()
301	}
302}
303
304#[cfg(test)]
305mod tests {
306	use super::*;
307
308	use crate::{I420, Surface};
309
310	/// A mid-gray RGBA buffer: encodable without a camera.
311	fn gray_rgba(width: u32, height: u32) -> Vec<u8> {
312		vec![0x80u8; width as usize * height as usize * 4]
313	}
314
315	/// The `index`th frame of a mid-gray 30fps stream, so a round-tripped
316	/// timestamp identifies the frame it came from.
317	fn gray_frame(width: u32, height: u32, index: u64) -> Frame {
318		let surface = Surface::rgba(&gray_rgba(width, height), Size::new(width, height)).unwrap();
319		Frame::new(surface, at(index))
320	}
321
322	/// The presentation time of frame `index` at 30fps.
323	fn at(index: u64) -> moq_net::Timestamp {
324		moq_net::Timestamp::from_micros(index * 33_333).unwrap()
325	}
326
327	/// The payloads of some encoded frames, for the Annex-B assertions.
328	fn payloads(frames: &[Encoded]) -> Vec<bytes::Bytes> {
329		frames.iter().map(|f| f.payload.clone()).collect()
330	}
331
332	#[test]
333	fn software_encoder_emits_annexb() {
334		let config = Config {
335			kind: Kind::Software,
336			..Config::new(320, 240, 30)
337		};
338		let mut encoder = Encoder::new(&config).expect("openh264 is vendored, always available");
339		assert_eq!(encoder.name(), "openh264");
340
341		let mut frames = Vec::new();
342		for i in 0..30 {
343			if i == 0 {
344				encoder.keyframe();
345			}
346			frames.extend(encoder.encode(&gray_frame(320, 240, i)).unwrap());
347		}
348		frames.extend(encoder.finish().unwrap());
349
350		assert!(!frames.is_empty(), "encoder produced no packets");
351
352		// Every encoded frame carries the timestamp of the raw frame it came from,
353		// so the stream stays in step even if a backend buffers.
354		let micros: Vec<u128> = frames.iter().map(|f| f.timestamp.as_micros()).collect();
355		assert!(
356			micros.windows(2).all(|w| w[0] < w[1]),
357			"encoded timestamps not strictly increasing: {micros:?}"
358		);
359		assert!(
360			micros.iter().all(|&t| t % 33_333 == 0 && t < 30 * 33_333),
361			"encoded timestamp outside the fed set: {micros:?}"
362		);
363
364		// The first packet must start with an Annex-B start code so the avc3
365		// importer can find the inline SPS/PPS.
366		let packets = payloads(&frames);
367		let first = &packets[0];
368		let has_start_code = first.starts_with(&[0, 0, 0, 1]) || first.starts_with(&[0, 0, 1]);
369		assert!(
370			has_start_code,
371			"first packet is not Annex-B: {:02x?}",
372			&first[..first.len().min(8)]
373		);
374	}
375
376	/// The bring-your-own-pixels path: RGBA in through `Surface::rgba`, Annex-B out.
377	#[test]
378	fn encode_rgba_surface_emits_annexb() {
379		let config = Config {
380			kind: Kind::Software,
381			..Config::new(320, 240, 30)
382		};
383		let mut encoder = Encoder::new(&config).unwrap();
384
385		let mut frames = encoder.encode(&gray_frame(320, 240, 0)).unwrap();
386		frames.extend(encoder.finish().unwrap());
387		assert!(!frames.is_empty());
388		let packets = payloads(&frames);
389		assert!(packets[0].starts_with(&[0, 0, 0, 1]) || packets[0].starts_with(&[0, 0, 1]));
390	}
391
392	/// The same path starting from planar I420 the caller already has.
393	#[test]
394	fn encode_i420_surface_emits_annexb() {
395		let config = Config {
396			kind: Kind::Software,
397			..Config::new(320, 240, 30)
398		};
399		let mut encoder = Encoder::new(&config).unwrap();
400
401		// A mid-gray I420 frame: flat 0x80 across all three planes.
402		let i420 = I420::new(320, 240, vec![0x80u8; I420::len(320, 240)]).unwrap();
403		let frame = Frame::new(Surface::I420(i420), at(0));
404		let mut frames = encoder.encode(&frame).unwrap();
405		frames.extend(encoder.finish().unwrap());
406		assert!(!frames.is_empty());
407		let packets = payloads(&frames);
408		assert!(packets[0].starts_with(&[0, 0, 0, 1]) || packets[0].starts_with(&[0, 0, 1]));
409	}
410
411	/// A frame that isn't the encoder's size must error rather than encode its
412	/// top-left corner.
413	#[test]
414	fn encode_rejects_dimension_mismatch() {
415		let Ok(mut encoder) = Encoder::new(&Config::new(320, 240, 30)) else {
416			return;
417		};
418		assert!(matches!(encoder.encode(&gray_frame(640, 480, 0)), Err(Error::Codec(_))));
419	}
420
421	/// A transposed frame is why the encoder compares the shape rather than the
422	/// byte count: 240x320 and 320x240 hold the same number of bytes, so a length
423	/// check alone would accept this and encode garbage.
424	#[test]
425	fn encode_rejects_transposed_frame() {
426		let Ok(mut encoder) = Encoder::new(&Config::new(320, 240, 30)) else {
427			return;
428		};
429
430		let transposed = gray_frame(240, 320, 0);
431		assert_eq!(
432			gray_rgba(240, 320).len(),
433			gray_rgba(320, 240).len(),
434			"the byte counts must collide"
435		);
436		assert!(matches!(encoder.encode(&transposed), Err(Error::Codec(_))));
437	}
438
439	#[test]
440	fn new_rejects_zero_framerate() {
441		// Framerate is validated before any backend opens, so this holds on every
442		// platform regardless of which encoders are compiled in.
443		let config = Config::new(320, 240, 0);
444		assert!(matches!(Encoder::new(&config), Err(Error::InvalidFramerate(0))));
445	}
446
447	#[test]
448	fn unknown_named_encoder_errors() {
449		let config = Config {
450			kind: Kind::Named("definitely_not_a_codec".into()),
451			..Config::new(320, 240, 30)
452		};
453		assert!(matches!(Encoder::new(&config), Err(Error::NoEncoder(_))));
454	}
455
456	/// Exercises the hand-rolled VideoToolbox backend end to end on macOS:
457	/// synthetic frames through the real `VTCompressionSession`, asserting the
458	/// AVCC -> Annex-B conversion produces a self-contained IDR (SPS+PPS+slice).
459	#[cfg(target_os = "macos")]
460	#[test]
461	fn videotoolbox_emits_annexb_keyframe() {
462		let config = Config {
463			kind: Kind::Named("videotoolbox".into()),
464			..Config::new(320, 240, 30)
465		};
466		let mut encoder = Encoder::new(&config).expect("videotoolbox is available on macOS");
467		assert_eq!(encoder.name(), "videotoolbox");
468
469		let mut frames = Vec::new();
470		for i in 0..10 {
471			if i == 0 {
472				encoder.keyframe();
473			}
474			frames.extend(encoder.encode(&gray_frame(320, 240, i)).unwrap());
475		}
476		frames.extend(encoder.finish().unwrap());
477
478		assert!(!frames.is_empty(), "encoder produced no packets");
479		// VideoToolbox completes each frame before returning, so the timestamps come
480		// back one per input frame, in order.
481		let micros: Vec<u128> = frames.iter().map(|f| f.timestamp.as_micros()).collect();
482		assert!(
483			micros.windows(2).all(|w| w[0] < w[1]),
484			"encoded timestamps not strictly increasing: {micros:?}"
485		);
486
487		let packets = payloads(&frames);
488		let first = &packets[0];
489		assert!(
490			first.starts_with(&[0, 0, 0, 1]) || first.starts_with(&[0, 0, 1]),
491			"first packet is not Annex-B"
492		);
493
494		// The first access unit must be a self-contained IDR: SPS (7), PPS (8),
495		// IDR slice (5), all spliced in-band by the AVCC -> Annex-B conversion.
496		let types = nal_types(first);
497		assert!(types.contains(&7), "no SPS in first packet: {types:?}");
498		assert!(types.contains(&8), "no PPS in first packet: {types:?}");
499		assert!(types.contains(&5), "first packet is not an IDR: {types:?}");
500	}
501
502	/// HEVC via VideoToolbox: synthetic frames through the real
503	/// `VTCompressionSession` with `kCMVideoCodecType_HEVC`, asserting the
504	/// HVCC -> Annex-B conversion produces a self-contained IRAP (VPS+SPS+PPS+IDR).
505	#[cfg(target_os = "macos")]
506	#[test]
507	fn videotoolbox_emits_annexb_keyframe_h265() {
508		let config = Config {
509			codec: Codec::H265,
510			kind: Kind::Named("videotoolbox".into()),
511			..Config::new(320, 240, 30)
512		};
513		let mut encoder = Encoder::new(&config).expect("videotoolbox HEVC is available on macOS");
514		assert_eq!(encoder.name(), "videotoolbox");
515		assert_eq!(encoder.codec(), Codec::H265);
516
517		let mut frames = Vec::new();
518		for i in 0..10 {
519			if i == 0 {
520				encoder.keyframe();
521			}
522			frames.extend(encoder.encode(&gray_frame(320, 240, i)).unwrap());
523		}
524		frames.extend(encoder.finish().unwrap());
525
526		assert!(!frames.is_empty(), "encoder produced no packets");
527		let packets = payloads(&frames);
528		let first = &packets[0];
529		assert!(
530			first.starts_with(&[0, 0, 0, 1]) || first.starts_with(&[0, 0, 1]),
531			"first packet is not Annex-B"
532		);
533
534		// The first access unit must be a self-contained IRAP: VPS (32), SPS (33),
535		// PPS (34), and an IDR slice (16..=23), spliced in-band by the conversion.
536		let types = hevc_nal_types(first);
537		assert!(types.contains(&32), "no VPS in first packet: {types:?}");
538		assert!(types.contains(&33), "no SPS in first packet: {types:?}");
539		assert!(types.contains(&34), "no PPS in first packet: {types:?}");
540		assert!(
541			types.iter().any(|t| (16..=23).contains(t)),
542			"first packet is not an IRAP: {types:?}"
543		);
544	}
545
546	/// HEVC NAL unit types in an Annex-B buffer (type = `(byte >> 1) & 0x3f`).
547	#[cfg(target_os = "macos")]
548	fn hevc_nal_types(annexb: &[u8]) -> Vec<u8> {
549		let mut types = Vec::new();
550		let mut i = 0;
551		while i + 3 < annexb.len() {
552			if annexb[i..i + 3] == [0, 0, 1] {
553				types.push((annexb[i + 3] >> 1) & 0x3f);
554				i += 3;
555			} else {
556				i += 1;
557			}
558		}
559		types
560	}
561
562	/// Feed a GPU surface (NV12 `CVPixelBuffer`) straight into VideoToolbox:
563	/// the zero-copy capture -> encode path, no I420 round-trip.
564	#[cfg(target_os = "macos")]
565	#[test]
566	fn videotoolbox_encodes_surface_zero_copy() {
567		let config = Config {
568			kind: Kind::Named("videotoolbox".into()),
569			..Config::new(320, 240, 30)
570		};
571		let mut encoder = Encoder::new(&config).unwrap();
572
573		let mut frames = Vec::new();
574		for i in 0..10 {
575			if i == 0 {
576				encoder.keyframe();
577			}
578			let frame = Frame::new(Surface::PixelBuffer(nv12_surface(320, 240)), at(i));
579			frames.extend(encoder.encode(&frame).unwrap());
580		}
581		frames.extend(encoder.finish().unwrap());
582
583		assert!(!frames.is_empty());
584		let packets = payloads(&frames);
585		let types = nal_types(&packets[0]);
586		assert!(
587			types.contains(&7) && types.contains(&8) && types.contains(&5),
588			"no IDR: {types:?}"
589		);
590	}
591
592	/// A software encoder must download a GPU surface to I420 first. Exercises
593	/// the NV12 -> I420 fallback path.
594	#[cfg(target_os = "macos")]
595	#[test]
596	fn openh264_downloads_surface() {
597		let config = Config {
598			kind: Kind::Software,
599			..Config::new(320, 240, 30)
600		};
601		let mut encoder = Encoder::new(&config).unwrap();
602
603		encoder.keyframe();
604		let frame = Frame::new(Surface::PixelBuffer(nv12_surface(320, 240)), at(0));
605		let mut frames = encoder.encode(&frame).unwrap();
606		frames.extend(encoder.finish().unwrap());
607
608		assert!(!frames.is_empty());
609		let packets = payloads(&frames);
610		assert!(packets[0].starts_with(&[0, 0, 0, 1]) || packets[0].starts_with(&[0, 0, 1]));
611	}
612
613	/// A mid-gray NV12 `CVPixelBuffer`, the format AVFoundation/ScreenCaptureKit
614	/// hand us. Y and interleaved UV planes filled with 128.
615	#[cfg(target_os = "macos")]
616	fn nv12_surface(width: u32, height: u32) -> crate::frame::macos::PixelBuffer {
617		use std::ptr::{self, NonNull};
618
619		use objc2_core_foundation::CFRetained;
620		use objc2_core_video::{
621			CVPixelBuffer, CVPixelBufferCreate, CVPixelBufferGetBaseAddressOfPlane, CVPixelBufferGetBytesPerRowOfPlane,
622			CVPixelBufferLockBaseAddress, CVPixelBufferLockFlags, CVPixelBufferUnlockBaseAddress,
623			kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange,
624		};
625
626		let mut raw: *mut CVPixelBuffer = ptr::null_mut();
627		let status = unsafe {
628			CVPixelBufferCreate(
629				None,
630				width as usize,
631				height as usize,
632				kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange,
633				None,
634				NonNull::new(&mut raw).unwrap(),
635			)
636		};
637		assert_eq!(status, 0, "CVPixelBufferCreate failed");
638		let buffer = unsafe { CFRetained::from_raw(NonNull::new(raw).unwrap()) };
639
640		let flags = CVPixelBufferLockFlags(0);
641		assert_eq!(unsafe { CVPixelBufferLockBaseAddress(&buffer, flags) }, 0);
642		for (plane, rows) in [(0usize, height as usize), (1usize, height as usize / 2)] {
643			let base = CVPixelBufferGetBaseAddressOfPlane(&buffer, plane) as *mut u8;
644			let stride = CVPixelBufferGetBytesPerRowOfPlane(&buffer, plane);
645			unsafe { ptr::write_bytes(base, 128, stride * rows) };
646		}
647		unsafe { CVPixelBufferUnlockBaseAddress(&buffer, flags) };
648
649		crate::frame::macos::PixelBuffer::new(buffer, width, height)
650	}
651
652	/// NAL unit types in an Annex-B buffer, found via 3-byte start codes (a
653	/// 4-byte `00 00 00 01` code contains `00 00 01` too, so this catches both).
654	fn nal_types(annexb: &[u8]) -> Vec<u8> {
655		let mut types = Vec::new();
656		let mut i = 0;
657		while i + 3 < annexb.len() {
658			if annexb[i..i + 3] == [0, 0, 1] {
659				types.push(annexb[i + 3] & 0x1f);
660				i += 3;
661			} else {
662				i += 1;
663			}
664		}
665		types
666	}
667
668	/// CPU path: synthetic RGBA through the Media Foundation hardware encoder
669	/// (I420 -> system-memory NV12 upload). Ignored: needs a hardware encoder MFT,
670	/// which GPU-less CI runners lack. Run with `--ignored`.
671	#[cfg(target_os = "windows")]
672	#[test]
673	#[ignore]
674	fn mediafoundation_cpu_rgba() {
675		let config = Config {
676			kind: Kind::Named("mediafoundation".into()),
677			..Config::new(640, 480, 30)
678		};
679		let mut encoder = Encoder::new(&config).expect("hardware H.264 encoder available");
680		assert_eq!(encoder.name(), "mediafoundation");
681
682		let mut frames = Vec::new();
683		for i in 0..30 {
684			if i == 0 {
685				encoder.keyframe();
686			}
687			frames.extend(encoder.encode(&gray_frame(640, 480, i)).unwrap());
688		}
689		frames.extend(encoder.finish().unwrap());
690
691		assert!(!frames.is_empty(), "encoder produced no packets");
692		// The MFT buffers, so packets come back stamped with the frame they were
693		// encoded from rather than whichever frame was going in at the time.
694		let micros: Vec<u128> = frames.iter().map(|f| f.timestamp.as_micros()).collect();
695		assert!(
696			micros.windows(2).all(|w| w[0] < w[1]),
697			"encoded timestamps not strictly increasing: {micros:?}"
698		);
699		assert!(
700			micros.iter().all(|&t| t % 33_333 == 0 && t < 30 * 33_333),
701			"encoded timestamp outside the fed set: {micros:?}"
702		);
703
704		let packets = payloads(&frames);
705		let types = nal_types(&packets[0]);
706		assert!(types.contains(&7), "no SPS in first packet: {types:?}");
707		assert!(types.contains(&8), "no PPS in first packet: {types:?}");
708		assert!(types.contains(&5), "first packet is not an IDR: {types:?}");
709	}
710
711	/// Full zero-copy path: real camera -> D3D11 NV12 texture -> hardware encoder
712	/// via the DXGI device manager, no CPU round-trip. Ignored: needs a camera and
713	/// a GPU. Run with `--ignored`.
714	#[cfg(target_os = "windows")]
715	#[tokio::test]
716	#[ignore]
717	async fn mediafoundation_camera_texture() {
718		let mut camera = crate::capture::open(&crate::capture::Config::default())
719			.await
720			.expect("open default camera");
721		let (w, h) = (camera.width(), camera.height());
722
723		let config = Config {
724			kind: Kind::Named("mediafoundation".into()),
725			..Config::new(w, h, camera.framerate().unwrap_or(30))
726		};
727		let mut encoder = Encoder::new(&config).expect("hardware H.264 encoder available");
728
729		let mut frames = Vec::new();
730		let mut textures = 0;
731		for i in 0..30 {
732			let surface = camera.read().await.expect("frame, not end of stream");
733			if matches!(surface, Surface::Texture(_)) {
734				textures += 1;
735			}
736			if i == 0 {
737				encoder.keyframe();
738			}
739			frames.extend(encoder.encode(&Frame::new(surface, at(i))).unwrap());
740		}
741		frames.extend(encoder.finish().unwrap());
742
743		// On a GPU this exercises the zero-copy texture path; the assert guards
744		// against silently testing only the CPU fallback.
745		assert!(textures > 0, "capture never produced a GPU texture");
746		assert!(!frames.is_empty(), "encoder produced no packets");
747		let packets = payloads(&frames);
748		let types = nal_types(&packets[0]);
749		assert!(
750			types.contains(&7) && types.contains(&8) && types.contains(&5),
751			"no IDR: {types:?}"
752		);
753	}
754
755	/// The openh264 retune goes through the raw `set_option` FFI, so this covers
756	/// both that the call is accepted and that the encoder keeps producing after
757	/// it. A wrong option id or a bad `SBitrateInfo` layout would fail here.
758	#[test]
759	fn set_bitrate_retunes_software_encoder() {
760		let config = Config {
761			kind: Kind::Software,
762			..Config::new(320, 240, 30)
763		};
764		let mut encoder = Encoder::new(&config).unwrap();
765
766		let opened = encoder.bitrate();
767		assert_eq!(opened, config.resolved_bitrate());
768
769		// Encode first: this is the live-retune path, once the encoder exists.
770		encoder.encode(&gray_frame(320, 240, 0)).unwrap();
771
772		let halved = opened / 2;
773		encoder.set_bitrate(halved).unwrap();
774		assert_eq!(encoder.bitrate(), halved);
775
776		// The retuned encoder must still emit a decodable keyframe, not wedge.
777		let frames = encoder.encode(&gray_frame(320, 240, 1)).unwrap();
778		assert!(!frames.is_empty(), "encoder produced nothing after a retune");
779		let packets = payloads(&frames);
780		assert!(packets[0].starts_with(&[0, 0, 0, 1]) || packets[0].starts_with(&[0, 0, 1]));
781	}
782
783	/// Regression: openh264 creates its encoder lazily on the first frame and
784	/// rejects `SetOption` with `cmInitExpected` until then. A retune before any
785	/// frame must be deferred to the first encode, not reported as a failure.
786	#[test]
787	fn set_bitrate_before_the_first_frame_is_deferred() {
788		let config = Config {
789			kind: Kind::Software,
790			..Config::new(320, 240, 30)
791		};
792		let mut encoder = Encoder::new(&config).unwrap();
793
794		let halved = encoder.bitrate() / 2;
795		encoder.set_bitrate(halved).expect("a retune before the first frame");
796		assert_eq!(encoder.bitrate(), halved);
797
798		// The deferred rate is applied during this encode, which must still work.
799		let frames = encoder.encode(&gray_frame(320, 240, 0)).unwrap();
800		assert!(!frames.is_empty());
801
802		// And the encoder is live now, so a further retune takes the direct path.
803		encoder.set_bitrate(halved / 2).unwrap();
804		assert!(encoder.encode(&gray_frame(320, 240, 1)).is_ok());
805	}
806
807	/// Setting the current rate must not reach the backend at all: the control
808	/// loop is allowed to be chatty, and the encoder shouldn't pay for it.
809	#[test]
810	fn set_bitrate_to_current_is_a_noop() {
811		let config = Config {
812			kind: Kind::Software,
813			..Config::new(320, 240, 30)
814		};
815		let mut encoder = Encoder::new(&config).unwrap();
816
817		let opened = encoder.bitrate();
818		encoder.set_bitrate(opened).unwrap();
819		assert_eq!(encoder.bitrate(), opened);
820	}
821
822	#[test]
823	fn default_bitrate_scales_with_resolution() {
824		let small = Config::new(320, 240, 30).resolved_bitrate();
825		let large = Config::new(1920, 1080, 30).resolved_bitrate();
826		assert!(large > small);
827		assert!(small > 0);
828	}
829
830	/// A backend that holds each frame back by one, like the Media Foundation MFT:
831	/// `encode` returns the *previous* frame's access unit and `finish` drains the
832	/// last. The payload is the frame's timestamp in microseconds, so a test can
833	/// tell which frame a packet came from independently of what it's stamped with.
834	struct Delayed {
835		pending: Option<Encoded>,
836	}
837
838	impl Backend for Delayed {
839		fn encode(&mut self, frame: &Frame, _keyframe: bool) -> Result<Vec<Encoded>, Error> {
840			let payload = bytes::Bytes::from(frame.timestamp.as_micros().to_string());
841			let previous = self.pending.replace(Encoded::new(payload, frame.timestamp));
842			Ok(previous.into_iter().collect())
843		}
844
845		fn flush(&mut self) -> Result<Vec<Encoded>, Error> {
846			Ok(self.pending.take().into_iter().collect())
847		}
848
849		fn finish(&mut self) -> Result<Vec<Encoded>, Error> {
850			self.flush()
851		}
852
853		fn set_bitrate(&mut self, _bitrate: u64) -> Result<(), Error> {
854			Ok(())
855		}
856
857		fn name(&self) -> &str {
858			"delayed"
859		}
860	}
861
862	/// An encoder over a hand-built backend, so a test can pick the buffering
863	/// behavior rather than take whatever this machine's hardware does.
864	fn encoder_with(backend: Box<dyn Backend>, config: &Config) -> Encoder {
865		Encoder {
866			backend,
867			codec: config.codec,
868			size: config.size(),
869			bitrate: config.resolved_bitrate(),
870			color: config.resolved_color(),
871			pending_keyframe: false,
872		}
873	}
874
875	/// Records the keyframe flag each frame reached the codec with, so a test can
876	/// check what the encoder actually asked for.
877	struct Recorder(std::sync::Arc<std::sync::Mutex<Vec<bool>>>);
878
879	impl Backend for Recorder {
880		fn encode(&mut self, _frame: &Frame, keyframe: bool) -> Result<Vec<Encoded>, Error> {
881			self.0.lock().unwrap().push(keyframe);
882			Ok(Vec::new())
883		}
884
885		fn flush(&mut self) -> Result<Vec<Encoded>, Error> {
886			Ok(Vec::new())
887		}
888
889		fn finish(&mut self) -> Result<Vec<Encoded>, Error> {
890			Ok(Vec::new())
891		}
892
893		fn set_bitrate(&mut self, _bitrate: u64) -> Result<(), Error> {
894			Ok(())
895		}
896
897		fn name(&self) -> &str {
898			"recorder"
899		}
900	}
901
902	/// Keyframes are automatic, so an untouched encoder forces none. A request is
903	/// held until a frame arrives (callers decide a group boundary before they have
904	/// the frame that opens it), collapses if made twice, and clears afterwards
905	/// rather than keying every frame from then on.
906	#[test]
907	fn a_keyframe_request_waits_for_the_next_frame_then_clears() {
908		let log = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
909		let config = Config::new(320, 240, 30);
910		let mut encoder = encoder_with(Box::new(Recorder(log.clone())), &config);
911
912		// Nothing asked for: `Config::gop` keys the stream on its own.
913		encoder.encode(&gray_frame(320, 240, 0)).unwrap();
914
915		// Asked twice before a frame exists: one keyframe, on the next frame.
916		encoder.keyframe();
917		encoder.keyframe();
918		encoder.encode(&gray_frame(320, 240, 1)).unwrap();
919
920		// And it does not carry into the frame after.
921		encoder.encode(&gray_frame(320, 240, 2)).unwrap();
922
923		assert_eq!(*log.lock().unwrap(), vec![false, true, false]);
924	}
925
926	/// `keyframe()` has to reach the codec on a *warm* encoder, which is the case
927	/// that matters: a fresh one emits an IDR on its first frame regardless, so only
928	/// a mid-stream request proves the plumbing works. Runs on openh264, so this
929	/// holds on every platform rather than only where hardware exists.
930	#[test]
931	fn a_mid_stream_keyframe_request_emits_an_idr() {
932		let config = Config {
933			kind: Kind::Software,
934			..Config::new(320, 240, 30)
935		};
936		// A GOP far longer than the run, so any IDR here was asked for rather than
937		// inserted on schedule.
938		let mut encoder = Encoder::new(&Config { gop: 1000, ..config }).unwrap();
939
940		let mut per_frame = Vec::new();
941		for i in 0..6 {
942			// Frame 0 opens the stream; ask again at frame 3, mid-stream.
943			if i == 3 {
944				encoder.keyframe();
945			}
946			let encoded = encoder.encode(&gray_frame(320, 240, i)).unwrap();
947			let joined: Vec<u8> = encoded.iter().flat_map(|f| f.payload.iter()).copied().collect();
948			per_frame.push(nal_types(&joined));
949		}
950
951		// The requested frame is a self-contained IDR: SPS (7) and PPS (8) inline
952		// ahead of an IDR slice (5), which is what avc3 promises a joining subscriber.
953		let asked = &per_frame[3];
954		assert!(asked.contains(&5), "the requested frame is not an IDR: {asked:?}");
955		assert!(asked.contains(&7), "no SPS with the requested IDR: {asked:?}");
956		assert!(asked.contains(&8), "no PPS with the requested IDR: {asked:?}");
957
958		// And the frames around it stay delta frames, so the request keyed exactly
959		// one: keying every frame would pass the assertions above while destroying
960		// the bitrate.
961		for i in [1, 2, 4, 5] {
962			assert!(
963				!per_frame[i].contains(&5),
964				"frame {i} was keyed without being asked: {:?}",
965				per_frame[i]
966			);
967		}
968	}
969
970	/// A backend whose every encode fails, to pin what survives one.
971	struct Failing;
972
973	impl Backend for Failing {
974		fn encode(&mut self, _frame: &Frame, _keyframe: bool) -> Result<Vec<Encoded>, Error> {
975			Err(Error::Codec(anyhow::anyhow!("no")))
976		}
977
978		fn flush(&mut self) -> Result<Vec<Encoded>, Error> {
979			Ok(Vec::new())
980		}
981
982		fn finish(&mut self) -> Result<Vec<Encoded>, Error> {
983			Ok(Vec::new())
984		}
985
986		fn set_bitrate(&mut self, _bitrate: u64) -> Result<(), Error> {
987			Ok(())
988		}
989
990		fn name(&self) -> &str {
991			"failing"
992		}
993	}
994
995	/// A request outlives an encode that produced no picture, whether it was
996	/// rejected up front (wrong size) or failed in the backend. Dropping it would
997	/// leave the next frame unkeyed, so a subscriber waits out a whole GOP for a
998	/// starting point the caller already asked for.
999	#[test]
1000	fn a_failed_encode_keeps_the_keyframe_request() {
1001		let config = Config::new(320, 240, 30);
1002
1003		let mut encoder = encoder_with(Box::new(Failing), &config);
1004		encoder.keyframe();
1005		assert!(encoder.encode(&gray_frame(320, 240, 0)).is_err());
1006		assert!(encoder.pending_keyframe, "the backend error swallowed the request");
1007
1008		// Rejected before the backend ever sees it, for the same reason.
1009		let mut encoder = encoder_with(Box::new(Delayed { pending: None }), &config);
1010		encoder.keyframe();
1011		assert!(encoder.encode(&gray_frame(640, 480, 0)).is_err());
1012		assert!(encoder.pending_keyframe, "the size check swallowed the request");
1013
1014		// ...and the next frame that does go through claims it.
1015		encoder.encode(&gray_frame(320, 240, 1)).unwrap();
1016		assert!(!encoder.pending_keyframe);
1017	}
1018
1019	/// Regression: a backend that buffers hands back an earlier frame's access
1020	/// unit, so the timestamp has to ride through the encoder with the picture.
1021	/// Stamping output with whatever frame is going in at the time (or the tail
1022	/// with one arbitrary time) shifts every packet by the encoder delay and
1023	/// collapses the drained tail onto a single instant.
1024	#[test]
1025	fn a_buffering_backend_keeps_each_frames_timestamp() {
1026		let config = Config::new(320, 240, 30);
1027		let mut encoder = encoder_with(Box::new(Delayed { pending: None }), &config);
1028
1029		// The packet handed back while frame `i` goes in belongs to frame `i - 1`, so
1030		// it has to be stamped one frame back. Stamping at the call site (the only
1031		// option when encode just returned bytes) would shift the whole stream.
1032		for i in 0..5 {
1033			let encoded = encoder.encode(&gray_frame(320, 240, i)).unwrap();
1034			if i == 0 {
1035				assert!(encoded.is_empty(), "the first frame is still buffered");
1036				continue;
1037			}
1038			assert_eq!(encoded.len(), 1);
1039			assert_eq!(encoded[0].timestamp, at(i - 1));
1040			// And the packet really is the earlier frame's, not a mis-stamped copy of
1041			// the one going in.
1042			assert_eq!(&encoded[0].payload[..], at(i - 1).as_micros().to_string().as_bytes());
1043		}
1044
1045		// The tail: frame 4 never came back from an encode call, so `finish` drains
1046		// it, still carrying its own time rather than one the caller picks.
1047		let tail = encoder.finish().unwrap();
1048		assert_eq!(tail.len(), 1);
1049		assert_eq!(tail[0].timestamp, at(4));
1050		assert!(
1051			encoder_with(Box::new(Delayed { pending: None }), &config)
1052				.finish()
1053				.unwrap()
1054				.is_empty()
1055		);
1056	}
1057
1058	/// A group has to contain its own frames. A codec that pipelines is still
1059	/// holding the last of them when the group ends, so the boundary flushes it;
1060	/// without that they surface in the *next* group, ahead of its keyframe, where
1061	/// a subscriber joining there cannot decode them.
1062	///
1063	/// The encoder stays usable afterwards, which is what separates this from
1064	/// `finish`: a live track flushes at every group and keeps going.
1065	#[test]
1066	fn a_flush_empties_a_pipelined_backend_and_leaves_it_running() {
1067		let config = Config::new(320, 240, 30);
1068		let mut encoder = encoder_with(Box::new(Delayed { pending: None }), &config);
1069
1070		let mut group = Vec::new();
1071		for i in 0..3 {
1072			group.extend(encoder.encode(&gray_frame(320, 240, i)).unwrap());
1073		}
1074		group.extend(encoder.flush().unwrap());
1075
1076		// All three frames land in the group they belong to, in order.
1077		let times: Vec<_> = group.iter().map(|packet| packet.timestamp).collect();
1078		assert_eq!(times, vec![at(0), at(1), at(2)], "the group lost or reordered frames");
1079
1080		// The next group starts clean: nothing carried over, and the encoder still
1081		// takes frames.
1082		let mut next = encoder.encode(&gray_frame(320, 240, 3)).unwrap();
1083		assert!(next.is_empty(), "frame 3 is buffered, so nothing comes back yet");
1084		next.extend(encoder.finish().unwrap());
1085		let times: Vec<_> = next.iter().map(|packet| packet.timestamp).collect();
1086		assert_eq!(times, vec![at(3)], "the flush left something behind");
1087	}
1088
1089	/// The hardware encoder states the color space too, and states the one the
1090	/// pixels were actually converted into. VideoToolbox takes the three
1091	/// properties as a request, so read the SPS back rather than trusting that it
1092	/// honored them.
1093	#[cfg(target_os = "macos")]
1094	#[test]
1095	fn videotoolbox_sps_declares_the_color_space() {
1096		use super::backend::test_util::{BT601_DESCRIBED, BT709_DESCRIBED, declared_color};
1097
1098		for (size, described) in [
1099			(Size::new(640, 480), BT601_DESCRIBED),
1100			(Size::new(1920, 1080), BT709_DESCRIBED),
1101		] {
1102			let config = Config {
1103				kind: Kind::Named("videotoolbox".into()),
1104				..Config::new(size.width, size.height, 30)
1105			};
1106			let mut encoder = Encoder::new(&config).expect("videotoolbox is available on macOS");
1107
1108			let rgba = [255u8, 0, 0, 255].repeat(size.pixels() as usize);
1109			let surface = crate::frame::Surface::rgba(&rgba, size).unwrap();
1110			encoder.keyframe();
1111			let frames = encoder
1112				.encode(&Frame::new(surface, moq_net::Timestamp::from_micros(0).unwrap()))
1113				.unwrap();
1114
1115			let keyframe = frames.first().expect("a keyframe");
1116			assert_eq!(declared_color(&keyframe.payload), Some(described), "{size} SPS");
1117		}
1118	}
1119
1120	/// Resizing across the standard-definition boundary keeps the pixels' color
1121	/// space but moves the encoder into another one, which would emit BT.709
1122	/// pixels under a BT.601 label. `Config::color` pins the real space, and the
1123	/// bitstream then says so.
1124	#[test]
1125	fn config_color_pins_the_space_a_resize_carried() {
1126		use crate::Color;
1127
1128		let big = Size::new(1280, 720);
1129		let small = Size::new(640, 480);
1130
1131		// Converted at 720p, so the samples are BT.709.
1132		let rgba = vec![0x80u8; big.pixels() as usize * 4];
1133		let frame = Frame::new(
1134			crate::frame::Surface::rgba(&rgba, big).unwrap(),
1135			moq_net::Timestamp::from_micros(0).unwrap(),
1136		);
1137		let scaled = frame.resize(small).unwrap();
1138		assert_eq!(
1139			scaled.surface.color(),
1140			Some(Color::Bt709Limited),
1141			"resize keeps the space"
1142		);
1143
1144		// Left to infer, a 480p encoder writes BT.601 over those BT.709 samples. It
1145		// still encodes (a live gateway keeps serving) but the label is wrong, which
1146		// is what `Config::color` exists to fix.
1147		let config = Config {
1148			kind: Kind::Software,
1149			..Config::new(small.width, small.height, 30)
1150		};
1151		let mut encoder = Encoder::new(&config).unwrap();
1152		encoder.keyframe();
1153		let frames = encoder.encode(&scaled).expect("a mismatch warns rather than fails");
1154		use super::backend::test_util::{BT601_DESCRIBED, BT709_DESCRIBED, declared_color};
1155		assert_eq!(
1156			declared_color(&frames.first().expect("a keyframe").payload),
1157			Some(BT601_DESCRIBED),
1158			"the inferred label is the wrong one, which is the case Config::color covers"
1159		);
1160
1161		// Declaring the real space fixes the label.
1162		let config = Config {
1163			kind: Kind::Software,
1164			color: Some(Color::Bt709Limited),
1165			..Config::new(small.width, small.height, 30)
1166		};
1167		let mut encoder = Encoder::new(&config).unwrap();
1168		encoder.keyframe();
1169		let frames = encoder.encode(&scaled).expect("a declared space encodes");
1170
1171		let keyframe = frames.first().expect("a keyframe");
1172		assert_eq!(declared_color(&keyframe.payload), Some(BT709_DESCRIBED));
1173	}
1174}