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	/// 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		assert!(matches!(Encoder::new(&config), Err(Error::NoEncoder(_))));
518	}
519
520	/// Exercises the hand-rolled VideoToolbox backend end to end on macOS:
521	/// synthetic frames through the real `VTCompressionSession`, asserting the
522	/// AVCC -> Annex-B conversion produces a self-contained IDR (SPS+PPS+slice).
523	#[cfg(target_os = "macos")]
524	#[test]
525	fn videotoolbox_emits_annexb_keyframe() {
526		let config = Config {
527			kind: Kind::Named("videotoolbox".into()),
528			..Config::new(320, 240, 30)
529		};
530		let mut encoder = Encoder::new(&config).expect("videotoolbox is available on macOS");
531		assert_eq!(encoder.name(), "videotoolbox");
532
533		let mut frames = Vec::new();
534		for i in 0..10 {
535			if i == 0 {
536				encoder.keyframe();
537			}
538			frames.extend(encoder.encode(&gray_frame(320, 240, i)).unwrap());
539		}
540		frames.extend(encoder.finish().unwrap());
541
542		assert!(!frames.is_empty(), "encoder produced no packets");
543		// VideoToolbox completes each frame before returning, so the timestamps come
544		// back one per input frame, in order.
545		let micros: Vec<u128> = frames.iter().map(|f| f.timestamp.as_micros()).collect();
546		assert!(
547			micros.windows(2).all(|w| w[0] < w[1]),
548			"encoded timestamps not strictly increasing: {micros:?}"
549		);
550
551		let packets = payloads(&frames);
552		let first = &packets[0];
553		assert!(
554			first.starts_with(&[0, 0, 0, 1]) || first.starts_with(&[0, 0, 1]),
555			"first packet is not Annex-B"
556		);
557
558		// The first access unit must be a self-contained IDR: SPS (7), PPS (8),
559		// IDR slice (5), all spliced in-band by the AVCC -> Annex-B conversion.
560		let types = nal_types(first);
561		assert!(types.contains(&7), "no SPS in first packet: {types:?}");
562		assert!(types.contains(&8), "no PPS in first packet: {types:?}");
563		assert!(types.contains(&5), "first packet is not an IDR: {types:?}");
564	}
565
566	/// HEVC via VideoToolbox: synthetic frames through the real
567	/// `VTCompressionSession` with `kCMVideoCodecType_HEVC`, asserting the
568	/// HVCC -> Annex-B conversion produces a self-contained IRAP (VPS+SPS+PPS+IDR).
569	#[cfg(target_os = "macos")]
570	#[test]
571	fn videotoolbox_emits_annexb_keyframe_h265() {
572		let config = Config {
573			codec: Codec::H265,
574			kind: Kind::Named("videotoolbox".into()),
575			..Config::new(320, 240, 30)
576		};
577		let mut encoder = Encoder::new(&config).expect("videotoolbox HEVC is available on macOS");
578		assert_eq!(encoder.name(), "videotoolbox");
579		assert_eq!(encoder.codec(), Codec::H265);
580
581		let mut frames = Vec::new();
582		for i in 0..10 {
583			if i == 0 {
584				encoder.keyframe();
585			}
586			frames.extend(encoder.encode(&gray_frame(320, 240, i)).unwrap());
587		}
588		frames.extend(encoder.finish().unwrap());
589
590		assert!(!frames.is_empty(), "encoder produced no packets");
591		let packets = payloads(&frames);
592		let first = &packets[0];
593		assert!(
594			first.starts_with(&[0, 0, 0, 1]) || first.starts_with(&[0, 0, 1]),
595			"first packet is not Annex-B"
596		);
597
598		// The first access unit must be a self-contained IRAP: VPS (32), SPS (33),
599		// PPS (34), and an IDR slice (16..=23), spliced in-band by the conversion.
600		let types = hevc_nal_types(first);
601		assert!(types.contains(&32), "no VPS in first packet: {types:?}");
602		assert!(types.contains(&33), "no SPS in first packet: {types:?}");
603		assert!(types.contains(&34), "no PPS in first packet: {types:?}");
604		assert!(
605			types.iter().any(|t| (16..=23).contains(t)),
606			"first packet is not an IRAP: {types:?}"
607		);
608	}
609
610	/// HEVC NAL unit types in an Annex-B buffer (type = `(byte >> 1) & 0x3f`).
611	#[cfg(target_os = "macos")]
612	fn hevc_nal_types(annexb: &[u8]) -> Vec<u8> {
613		let mut types = Vec::new();
614		let mut i = 0;
615		while i + 3 < annexb.len() {
616			if annexb[i..i + 3] == [0, 0, 1] {
617				types.push((annexb[i + 3] >> 1) & 0x3f);
618				i += 3;
619			} else {
620				i += 1;
621			}
622		}
623		types
624	}
625
626	/// Feed a GPU surface (NV12 `CVPixelBuffer`) straight into VideoToolbox:
627	/// the zero-copy capture -> encode path, no I420 round-trip.
628	#[cfg(target_os = "macos")]
629	#[test]
630	fn videotoolbox_encodes_surface_zero_copy() {
631		let config = Config {
632			kind: Kind::Named("videotoolbox".into()),
633			..Config::new(320, 240, 30)
634		};
635		let mut encoder = Encoder::new(&config).unwrap();
636
637		let mut frames = Vec::new();
638		for i in 0..10 {
639			if i == 0 {
640				encoder.keyframe();
641			}
642			let frame = Frame::new(Surface::PixelBuffer(nv12_surface(320, 240)), at(i));
643			frames.extend(encoder.encode(&frame).unwrap());
644		}
645		frames.extend(encoder.finish().unwrap());
646
647		assert!(!frames.is_empty());
648		let packets = payloads(&frames);
649		let types = nal_types(&packets[0]);
650		assert!(
651			types.contains(&7) && types.contains(&8) && types.contains(&5),
652			"no IDR: {types:?}"
653		);
654	}
655
656	/// A software encoder must download a GPU surface to I420 first. Exercises
657	/// the NV12 -> I420 fallback path.
658	#[cfg(target_os = "macos")]
659	#[test]
660	fn openh264_downloads_surface() {
661		let config = Config {
662			kind: Kind::Software,
663			..Config::new(320, 240, 30)
664		};
665		let mut encoder = Encoder::new(&config).unwrap();
666
667		encoder.keyframe();
668		let frame = Frame::new(Surface::PixelBuffer(nv12_surface(320, 240)), at(0));
669		let mut frames = encoder.encode(&frame).unwrap();
670		frames.extend(encoder.finish().unwrap());
671
672		assert!(!frames.is_empty());
673		let packets = payloads(&frames);
674		assert!(packets[0].starts_with(&[0, 0, 0, 1]) || packets[0].starts_with(&[0, 0, 1]));
675	}
676
677	/// A mid-gray NV12 `CVPixelBuffer`, the format AVFoundation/ScreenCaptureKit
678	/// hand us. Y and interleaved UV planes filled with 128.
679	#[cfg(target_os = "macos")]
680	fn nv12_surface(width: u32, height: u32) -> crate::frame::macos::PixelBuffer {
681		use std::ptr::{self, NonNull};
682
683		use objc2_core_foundation::CFRetained;
684		use objc2_core_video::{
685			CVPixelBuffer, CVPixelBufferCreate, CVPixelBufferGetBaseAddressOfPlane, CVPixelBufferGetBytesPerRowOfPlane,
686			CVPixelBufferLockBaseAddress, CVPixelBufferLockFlags, CVPixelBufferUnlockBaseAddress,
687			kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange,
688		};
689
690		let mut raw: *mut CVPixelBuffer = ptr::null_mut();
691		let status = unsafe {
692			CVPixelBufferCreate(
693				None,
694				width as usize,
695				height as usize,
696				kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange,
697				None,
698				NonNull::new(&mut raw).unwrap(),
699			)
700		};
701		assert_eq!(status, 0, "CVPixelBufferCreate failed");
702		let buffer = unsafe { CFRetained::from_raw(NonNull::new(raw).unwrap()) };
703
704		let flags = CVPixelBufferLockFlags(0);
705		assert_eq!(unsafe { CVPixelBufferLockBaseAddress(&buffer, flags) }, 0);
706		for (plane, rows) in [(0usize, height as usize), (1usize, height as usize / 2)] {
707			let base = CVPixelBufferGetBaseAddressOfPlane(&buffer, plane) as *mut u8;
708			let stride = CVPixelBufferGetBytesPerRowOfPlane(&buffer, plane);
709			unsafe { ptr::write_bytes(base, 128, stride * rows) };
710		}
711		unsafe { CVPixelBufferUnlockBaseAddress(&buffer, flags) };
712
713		crate::frame::macos::PixelBuffer::new(buffer, width, height)
714	}
715
716	/// NAL unit types in an Annex-B buffer, found via 3-byte start codes (a
717	/// 4-byte `00 00 00 01` code contains `00 00 01` too, so this catches both).
718	fn nal_types(annexb: &[u8]) -> Vec<u8> {
719		let mut types = Vec::new();
720		let mut i = 0;
721		while i + 3 < annexb.len() {
722			if annexb[i..i + 3] == [0, 0, 1] {
723				types.push(annexb[i + 3] & 0x1f);
724				i += 3;
725			} else {
726				i += 1;
727			}
728		}
729		types
730	}
731
732	/// CPU path: synthetic RGBA through the Media Foundation hardware encoder
733	/// (I420 -> system-memory NV12 upload). Ignored: needs a hardware encoder MFT,
734	/// which GPU-less CI runners lack. Run with `--ignored`.
735	#[cfg(target_os = "windows")]
736	#[test]
737	#[ignore]
738	fn mediafoundation_cpu_rgba() {
739		let config = Config {
740			kind: Kind::Named("mediafoundation".into()),
741			..Config::new(640, 480, 30)
742		};
743		let mut encoder = Encoder::new(&config).expect("hardware H.264 encoder available");
744		assert_eq!(encoder.name(), "mediafoundation");
745
746		let mut frames = Vec::new();
747		for i in 0..30 {
748			if i == 0 {
749				encoder.keyframe();
750			}
751			frames.extend(encoder.encode(&gray_frame(640, 480, i)).unwrap());
752		}
753		frames.extend(encoder.finish().unwrap());
754
755		assert!(!frames.is_empty(), "encoder produced no packets");
756		// The MFT buffers, so packets come back stamped with the frame they were
757		// encoded from rather than whichever frame was going in at the time.
758		let micros: Vec<u128> = frames.iter().map(|f| f.timestamp.as_micros()).collect();
759		assert!(
760			micros.windows(2).all(|w| w[0] < w[1]),
761			"encoded timestamps not strictly increasing: {micros:?}"
762		);
763		assert!(
764			micros.iter().all(|&t| t % 33_333 == 0 && t < 30 * 33_333),
765			"encoded timestamp outside the fed set: {micros:?}"
766		);
767
768		let packets = payloads(&frames);
769		let types = nal_types(&packets[0]);
770		assert!(types.contains(&7), "no SPS in first packet: {types:?}");
771		assert!(types.contains(&8), "no PPS in first packet: {types:?}");
772		assert!(types.contains(&5), "first packet is not an IDR: {types:?}");
773	}
774
775	/// Full zero-copy path: real camera -> D3D11 NV12 texture -> hardware encoder
776	/// via the DXGI device manager, no CPU round-trip. Ignored: needs a camera and
777	/// a GPU. Run with `--ignored`.
778	#[cfg(all(target_os = "windows", feature = "capture"))]
779	#[tokio::test]
780	#[ignore]
781	async fn mediafoundation_camera_texture() {
782		let mut camera = crate::capture::open(&crate::capture::Config::default())
783			.await
784			.expect("open default camera");
785		let (w, h) = (camera.width(), camera.height());
786
787		let config = Config {
788			kind: Kind::Named("mediafoundation".into()),
789			..Config::new(w, h, camera.framerate().unwrap_or(30))
790		};
791		let mut encoder = Encoder::new(&config).expect("hardware H.264 encoder available");
792
793		let mut frames = Vec::new();
794		let mut textures = 0;
795		for i in 0..30 {
796			let surface = camera.read().await.expect("frame, not end of stream");
797			if matches!(surface, Surface::Texture(_)) {
798				textures += 1;
799			}
800			if i == 0 {
801				encoder.keyframe();
802			}
803			frames.extend(encoder.encode(&Frame::new(surface, at(i))).unwrap());
804		}
805		frames.extend(encoder.finish().unwrap());
806
807		// On a GPU this exercises the zero-copy texture path; the assert guards
808		// against silently testing only the CPU fallback.
809		assert!(textures > 0, "capture never produced a GPU texture");
810		assert!(!frames.is_empty(), "encoder produced no packets");
811		let packets = payloads(&frames);
812		let types = nal_types(&packets[0]);
813		assert!(
814			types.contains(&7) && types.contains(&8) && types.contains(&5),
815			"no IDR: {types:?}"
816		);
817	}
818
819	/// The openh264 retune goes through the raw `set_option` FFI, so this covers
820	/// both that the call is accepted and that the encoder keeps producing after
821	/// it. A wrong option id or a bad `SBitrateInfo` layout would fail here.
822	#[test]
823	fn set_bitrate_retunes_software_encoder() {
824		let config = Config {
825			kind: Kind::Software,
826			..Config::new(320, 240, 30)
827		};
828		let mut encoder = Encoder::new(&config).unwrap();
829
830		let opened = encoder.bitrate();
831		assert_eq!(opened, config.resolved_bitrate());
832
833		// Encode first: this is the live-retune path, once the encoder exists.
834		encoder.encode(&gray_frame(320, 240, 0)).unwrap();
835
836		let halved = opened / 2;
837		encoder.set_bitrate(halved).unwrap();
838		assert_eq!(encoder.bitrate(), halved);
839
840		// The retuned encoder must still emit a decodable keyframe, not wedge.
841		let frames = encoder.encode(&gray_frame(320, 240, 1)).unwrap();
842		assert!(!frames.is_empty(), "encoder produced nothing after a retune");
843		let packets = payloads(&frames);
844		assert!(packets[0].starts_with(&[0, 0, 0, 1]) || packets[0].starts_with(&[0, 0, 1]));
845	}
846
847	/// Regression: openh264 creates its encoder lazily on the first frame and
848	/// rejects `SetOption` with `cmInitExpected` until then. A retune before any
849	/// frame must be deferred to the first encode, not reported as a failure.
850	#[test]
851	fn set_bitrate_before_the_first_frame_is_deferred() {
852		let config = Config {
853			kind: Kind::Software,
854			..Config::new(320, 240, 30)
855		};
856		let mut encoder = Encoder::new(&config).unwrap();
857
858		let halved = encoder.bitrate() / 2;
859		encoder.set_bitrate(halved).expect("a retune before the first frame");
860		assert_eq!(encoder.bitrate(), halved);
861
862		// The deferred rate is applied during this encode, which must still work.
863		let frames = encoder.encode(&gray_frame(320, 240, 0)).unwrap();
864		assert!(!frames.is_empty());
865
866		// And the encoder is live now, so a further retune takes the direct path.
867		encoder.set_bitrate(halved / 2).unwrap();
868		assert!(encoder.encode(&gray_frame(320, 240, 1)).is_ok());
869	}
870
871	/// Setting the current rate must not reach the backend at all: the control
872	/// loop is allowed to be chatty, and the encoder shouldn't pay for it.
873	#[test]
874	fn set_bitrate_to_current_is_a_noop() {
875		let config = Config {
876			kind: Kind::Software,
877			..Config::new(320, 240, 30)
878		};
879		let mut encoder = Encoder::new(&config).unwrap();
880
881		let opened = encoder.bitrate();
882		encoder.set_bitrate(opened).unwrap();
883		assert_eq!(encoder.bitrate(), opened);
884	}
885
886	#[test]
887	fn default_bitrate_scales_with_resolution() {
888		let small = Config::new(320, 240, 30).resolved_bitrate();
889		let large = Config::new(1920, 1080, 30).resolved_bitrate();
890		assert!(large > small);
891		assert!(small > 0);
892	}
893
894	/// A backend that holds each frame back by one, like the Media Foundation MFT:
895	/// `encode` returns the *previous* frame's access unit and `finish` drains the
896	/// last. The payload is the frame's timestamp in microseconds, so a test can
897	/// tell which frame a packet came from independently of what it's stamped with.
898	struct Delayed {
899		pending: Option<Encoded>,
900	}
901
902	impl Backend for Delayed {
903		fn encode(&mut self, frame: &Frame, _keyframe: bool) -> Result<Vec<Encoded>, Error> {
904			let payload = bytes::Bytes::from(frame.timestamp.as_micros().to_string());
905			let previous = self.pending.replace(Encoded::new(payload, frame.timestamp));
906			Ok(previous.into_iter().collect())
907		}
908
909		fn flush(&mut self) -> Result<Vec<Encoded>, Error> {
910			Ok(self.pending.take().into_iter().collect())
911		}
912
913		fn finish(&mut self) -> Result<Vec<Encoded>, Error> {
914			self.flush()
915		}
916
917		fn set_bitrate(&mut self, _bitrate: u64) -> Result<(), Error> {
918			Ok(())
919		}
920
921		fn name(&self) -> &str {
922			"delayed"
923		}
924	}
925
926	/// An encoder over a hand-built backend, so a test can pick the buffering
927	/// behavior rather than take whatever this machine's hardware does.
928	fn encoder_with(backend: Box<dyn Backend>, config: &Config) -> Encoder {
929		Encoder {
930			backend,
931			codec: config.codec,
932			size: config.size(),
933			bitrate: config.resolved_bitrate(),
934			color: config.resolved_color(),
935			pending_keyframe: false,
936		}
937	}
938
939	/// Records the keyframe flag each frame reached the codec with, so a test can
940	/// check what the encoder actually asked for.
941	struct Recorder(std::sync::Arc<std::sync::Mutex<Vec<bool>>>);
942
943	impl Backend for Recorder {
944		fn encode(&mut self, _frame: &Frame, keyframe: bool) -> Result<Vec<Encoded>, Error> {
945			self.0.lock().unwrap().push(keyframe);
946			Ok(Vec::new())
947		}
948
949		fn flush(&mut self) -> Result<Vec<Encoded>, Error> {
950			Ok(Vec::new())
951		}
952
953		fn finish(&mut self) -> Result<Vec<Encoded>, Error> {
954			Ok(Vec::new())
955		}
956
957		fn set_bitrate(&mut self, _bitrate: u64) -> Result<(), Error> {
958			Ok(())
959		}
960
961		fn name(&self) -> &str {
962			"recorder"
963		}
964	}
965
966	/// Keyframes are automatic, so an untouched encoder forces none. A request is
967	/// held until a frame arrives (callers decide a group boundary before they have
968	/// the frame that opens it), collapses if made twice, and clears afterwards
969	/// rather than keying every frame from then on.
970	#[test]
971	fn a_keyframe_request_waits_for_the_next_frame_then_clears() {
972		let log = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
973		let config = Config::new(320, 240, 30);
974		let mut encoder = encoder_with(Box::new(Recorder(log.clone())), &config);
975
976		// Nothing asked for: `Config::gop` keys the stream on its own.
977		encoder.encode(&gray_frame(320, 240, 0)).unwrap();
978
979		// Asked twice before a frame exists: one keyframe, on the next frame.
980		encoder.keyframe();
981		encoder.keyframe();
982		encoder.encode(&gray_frame(320, 240, 1)).unwrap();
983
984		// And it does not carry into the frame after.
985		encoder.encode(&gray_frame(320, 240, 2)).unwrap();
986
987		assert_eq!(*log.lock().unwrap(), vec![false, true, false]);
988	}
989
990	/// `keyframe()` has to reach the codec on a *warm* encoder, which is the case
991	/// that matters: a fresh one emits an IDR on its first frame regardless, so only
992	/// a mid-stream request proves the plumbing works. Runs on openh264, so this
993	/// holds on every platform rather than only where hardware exists.
994	#[test]
995	fn a_mid_stream_keyframe_request_emits_an_idr() {
996		let config = Config {
997			kind: Kind::Software,
998			..Config::new(320, 240, 30)
999		};
1000		// A GOP far longer than the run, so any IDR here was asked for rather than
1001		// inserted on schedule.
1002		let mut encoder = Encoder::new(&Config { gop: 1000, ..config }).unwrap();
1003
1004		let mut per_frame = Vec::new();
1005		for i in 0..6 {
1006			// Frame 0 opens the stream; ask again at frame 3, mid-stream.
1007			if i == 3 {
1008				encoder.keyframe();
1009			}
1010			let encoded = encoder.encode(&gray_frame(320, 240, i)).unwrap();
1011			let joined: Vec<u8> = encoded.iter().flat_map(|f| f.payload.iter()).copied().collect();
1012			per_frame.push(nal_types(&joined));
1013		}
1014
1015		// The requested frame is a self-contained IDR: SPS (7) and PPS (8) inline
1016		// ahead of an IDR slice (5), which is what avc3 promises a joining subscriber.
1017		let asked = &per_frame[3];
1018		assert!(asked.contains(&5), "the requested frame is not an IDR: {asked:?}");
1019		assert!(asked.contains(&7), "no SPS with the requested IDR: {asked:?}");
1020		assert!(asked.contains(&8), "no PPS with the requested IDR: {asked:?}");
1021
1022		// And the frames around it stay delta frames, so the request keyed exactly
1023		// one: keying every frame would pass the assertions above while destroying
1024		// the bitrate.
1025		for i in [1, 2, 4, 5] {
1026			assert!(
1027				!per_frame[i].contains(&5),
1028				"frame {i} was keyed without being asked: {:?}",
1029				per_frame[i]
1030			);
1031		}
1032	}
1033
1034	/// A backend whose every encode fails, to pin what survives one.
1035	struct Failing;
1036
1037	impl Backend for Failing {
1038		fn encode(&mut self, _frame: &Frame, _keyframe: bool) -> Result<Vec<Encoded>, Error> {
1039			Err(Error::Codec(anyhow::anyhow!("no")))
1040		}
1041
1042		fn flush(&mut self) -> Result<Vec<Encoded>, Error> {
1043			Ok(Vec::new())
1044		}
1045
1046		fn finish(&mut self) -> Result<Vec<Encoded>, Error> {
1047			Ok(Vec::new())
1048		}
1049
1050		fn set_bitrate(&mut self, _bitrate: u64) -> Result<(), Error> {
1051			Ok(())
1052		}
1053
1054		fn name(&self) -> &str {
1055			"failing"
1056		}
1057	}
1058
1059	/// A request outlives an encode that produced no picture, whether it was
1060	/// rejected up front (wrong size) or failed in the backend. Dropping it would
1061	/// leave the next frame unkeyed, so a subscriber waits out a whole GOP for a
1062	/// starting point the caller already asked for.
1063	#[test]
1064	fn a_failed_encode_keeps_the_keyframe_request() {
1065		let config = Config::new(320, 240, 30);
1066
1067		let mut encoder = encoder_with(Box::new(Failing), &config);
1068		encoder.keyframe();
1069		assert!(encoder.encode(&gray_frame(320, 240, 0)).is_err());
1070		assert!(encoder.pending_keyframe, "the backend error swallowed the request");
1071
1072		// Rejected before the backend ever sees it, for the same reason.
1073		let mut encoder = encoder_with(Box::new(Delayed { pending: None }), &config);
1074		encoder.keyframe();
1075		assert!(encoder.encode(&gray_frame(640, 480, 0)).is_err());
1076		assert!(encoder.pending_keyframe, "the size check swallowed the request");
1077
1078		// ...and the next frame that does go through claims it.
1079		encoder.encode(&gray_frame(320, 240, 1)).unwrap();
1080		assert!(!encoder.pending_keyframe);
1081	}
1082
1083	/// Regression: a backend that buffers hands back an earlier frame's access
1084	/// unit, so the timestamp has to ride through the encoder with the picture.
1085	/// Stamping output with whatever frame is going in at the time (or the tail
1086	/// with one arbitrary time) shifts every packet by the encoder delay and
1087	/// collapses the drained tail onto a single instant.
1088	#[test]
1089	fn a_buffering_backend_keeps_each_frames_timestamp() {
1090		let config = Config::new(320, 240, 30);
1091		let mut encoder = encoder_with(Box::new(Delayed { pending: None }), &config);
1092
1093		// The packet handed back while frame `i` goes in belongs to frame `i - 1`, so
1094		// it has to be stamped one frame back. Stamping at the call site (the only
1095		// option when encode just returned bytes) would shift the whole stream.
1096		for i in 0..5 {
1097			let encoded = encoder.encode(&gray_frame(320, 240, i)).unwrap();
1098			if i == 0 {
1099				assert!(encoded.is_empty(), "the first frame is still buffered");
1100				continue;
1101			}
1102			assert_eq!(encoded.len(), 1);
1103			assert_eq!(encoded[0].timestamp, at(i - 1));
1104			// And the packet really is the earlier frame's, not a mis-stamped copy of
1105			// the one going in.
1106			assert_eq!(&encoded[0].payload[..], at(i - 1).as_micros().to_string().as_bytes());
1107		}
1108
1109		// The tail: frame 4 never came back from an encode call, so `finish` drains
1110		// it, still carrying its own time rather than one the caller picks.
1111		let tail = encoder.finish().unwrap();
1112		assert_eq!(tail.len(), 1);
1113		assert_eq!(tail[0].timestamp, at(4));
1114		assert!(
1115			encoder_with(Box::new(Delayed { pending: None }), &config)
1116				.finish()
1117				.unwrap()
1118				.is_empty()
1119		);
1120	}
1121
1122	/// A group has to contain its own frames. A codec that pipelines is still
1123	/// holding the last of them when the group ends, so the boundary flushes it;
1124	/// without that they surface in the *next* group, ahead of its keyframe, where
1125	/// a subscriber joining there cannot decode them.
1126	///
1127	/// The encoder stays usable afterwards, which is what separates this from
1128	/// `finish`: a live track flushes at every group and keeps going.
1129	#[test]
1130	fn a_flush_empties_a_pipelined_backend_and_leaves_it_running() {
1131		let config = Config::new(320, 240, 30);
1132		let mut encoder = encoder_with(Box::new(Delayed { pending: None }), &config);
1133
1134		let mut group = Vec::new();
1135		for i in 0..3 {
1136			group.extend(encoder.encode(&gray_frame(320, 240, i)).unwrap());
1137		}
1138		group.extend(encoder.flush().unwrap());
1139
1140		// All three frames land in the group they belong to, in order.
1141		let times: Vec<_> = group.iter().map(|packet| packet.timestamp).collect();
1142		assert_eq!(times, vec![at(0), at(1), at(2)], "the group lost or reordered frames");
1143
1144		// The next group starts clean: nothing carried over, and the encoder still
1145		// takes frames.
1146		let mut next = encoder.encode(&gray_frame(320, 240, 3)).unwrap();
1147		assert!(next.is_empty(), "frame 3 is buffered, so nothing comes back yet");
1148		next.extend(encoder.finish().unwrap());
1149		let times: Vec<_> = next.iter().map(|packet| packet.timestamp).collect();
1150		assert_eq!(times, vec![at(3)], "the flush left something behind");
1151	}
1152
1153	/// The hardware encoder states the color space too, and states the one the
1154	/// pixels were actually converted into. VideoToolbox takes the three
1155	/// properties as a request, so read the SPS back rather than trusting that it
1156	/// honored them.
1157	#[cfg(target_os = "macos")]
1158	#[test]
1159	fn videotoolbox_sps_declares_the_color_space() {
1160		use super::backend::test_util::{BT601_DESCRIBED, BT709_DESCRIBED, declared_color};
1161
1162		for (size, described) in [
1163			(Size::new(640, 480), BT601_DESCRIBED),
1164			(Size::new(1920, 1080), BT709_DESCRIBED),
1165		] {
1166			let config = Config {
1167				kind: Kind::Named("videotoolbox".into()),
1168				..Config::new(size.width, size.height, 30)
1169			};
1170			let mut encoder = Encoder::new(&config).expect("videotoolbox is available on macOS");
1171
1172			let rgba = [255u8, 0, 0, 255].repeat(size.pixels() as usize);
1173			let surface = crate::frame::Surface::rgba(&rgba, size).unwrap();
1174			encoder.keyframe();
1175			let frames = encoder
1176				.encode(&Frame::new(surface, moq_net::Timestamp::from_micros(0).unwrap()))
1177				.unwrap();
1178
1179			let keyframe = frames.first().expect("a keyframe");
1180			assert_eq!(declared_color(&keyframe.payload), Some(described), "{size} SPS");
1181		}
1182	}
1183
1184	/// Resizing across the standard-definition boundary keeps the pixels' color
1185	/// space but moves the encoder into another one, which would emit BT.709
1186	/// pixels under a BT.601 label. `Config::color` pins the real space, and the
1187	/// bitstream then says so.
1188	#[test]
1189	fn config_color_pins_the_space_a_resize_carried() {
1190		use crate::Color;
1191
1192		let big = Size::new(1280, 720);
1193		let small = Size::new(640, 480);
1194
1195		// Converted at 720p, so the samples are BT.709.
1196		let rgba = vec![0x80u8; big.pixels() as usize * 4];
1197		let frame = Frame::new(
1198			crate::frame::Surface::rgba(&rgba, big).unwrap(),
1199			moq_net::Timestamp::from_micros(0).unwrap(),
1200		);
1201		let scaled = frame.resize(small).unwrap();
1202		assert_eq!(
1203			scaled.surface.color(),
1204			Some(Color::Bt709Limited),
1205			"resize keeps the space"
1206		);
1207
1208		// Left to infer, a 480p encoder writes BT.601 over those BT.709 samples. It
1209		// still encodes (a live gateway keeps serving) but the label is wrong, which
1210		// is what `Config::color` exists to fix.
1211		let config = Config {
1212			kind: Kind::Software,
1213			..Config::new(small.width, small.height, 30)
1214		};
1215		let mut encoder = Encoder::new(&config).unwrap();
1216		encoder.keyframe();
1217		let frames = encoder.encode(&scaled).expect("a mismatch warns rather than fails");
1218		use super::backend::test_util::{BT601_DESCRIBED, BT709_DESCRIBED, declared_color};
1219		assert_eq!(
1220			declared_color(&frames.first().expect("a keyframe").payload),
1221			Some(BT601_DESCRIBED),
1222			"the inferred label is the wrong one, which is the case Config::color covers"
1223		);
1224
1225		// Declaring the real space fixes the label.
1226		let config = Config {
1227			kind: Kind::Software,
1228			color: Some(Color::Bt709Limited),
1229			..Config::new(small.width, small.height, 30)
1230		};
1231		let mut encoder = Encoder::new(&config).unwrap();
1232		encoder.keyframe();
1233		let frames = encoder.encode(&scaled).expect("a declared space encodes");
1234
1235		let keyframe = frames.first().expect("a keyframe");
1236		assert_eq!(declared_color(&keyframe.payload), Some(BT709_DESCRIBED));
1237	}
1238}