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