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