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