moq-video 0.0.20

Native video capture/encoding/decoding for Media over QUIC
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
//! Pluggable video encoder backends.
//!
//! [`Backend`] is the seam between frame input prep (capture + color conversion,
//! owned by [`Encoder`](super::Encoder)) and the codec itself. Every backend
//! takes a raw [`Frame`] and emits Annex-B with in-band parameter sets (SPS/PPS,
//! plus VPS for H.265), the framing the matching catalog importer expects. Each
//! backend produces exactly one codec, so the producer can route its packets to
//! the right importer.
//!
//! Output is stamped with the timestamp of the frame it was encoded from, not
//! whichever frame happened to be going in. A backend that flushes each frame
//! before returning just echoes the input timestamp; one that buffers (Media
//! Foundation) correlates through the codec's own sample clock.
//!
//! [`open`] picks the best backend for a [`Codec`](super::Codec) +
//! [`Kind`](super::Kind): only candidates that support the requested codec are
//! considered, hardware (platform-gated) before the always-available openh264
//! software fallback.

use super::encoder::{Codec, Config, Kind};
use crate::encode::Encoded;
use crate::{Error, Frame};

mod openh264;

#[cfg(test)]
pub(crate) mod probe;

#[cfg(target_os = "macos")]
mod videotoolbox;

#[cfg(target_os = "windows")]
mod mediafoundation;

#[cfg(all(target_os = "linux", feature = "nvidia"))]
mod nvenc;

#[cfg(all(target_os = "linux", feature = "vaapi"))]
mod vaapi;

/// An opened video encoder. Feed it frames at the configured resolution; get
/// back zero or more access units in the codec's wire framing, each stamped with
/// the timestamp of the frame it came from.
pub(crate) trait Backend: Send {
	/// Encode one frame, forcing an IDR when `keyframe` is set. Backends key frames
	/// automatically per [`Config::gop`], so this is only the caller's extra
	/// request, arriving via [`Encoder::keyframe`](super::Encoder::keyframe).
	fn encode(&mut self, frame: &Frame, keyframe: bool) -> Result<Vec<Encoded>, Error>;

	/// Return every access unit the codec is still holding, leaving the encoder
	/// usable for the frames that follow.
	///
	/// The caller reaches for this at a boundary the output has to respect, which
	/// on a live track is a group: a codec that pipelines would otherwise carry the
	/// last frames of one group into the next, ahead of its keyframe, where a
	/// consumer joining there cannot decode them.
	///
	/// No default, even though most backends have nothing to hold: a pipelined one
	/// that inherited an empty implementation would drop frames at every boundary
	/// and look like it worked.
	fn flush(&mut self) -> Result<Vec<Encoded>, Error>;

	/// Flush the encoder for the last time, returning any buffered access units.
	fn finish(&mut self) -> Result<Vec<Encoded>, Error>;

	/// Retune the live encoder to `bitrate` bits per second, taking effect from
	/// roughly the next frame. Called as the congestion controller's estimate
	/// moves, so it must not force an IDR or rebuild the session: a keyframe on
	/// every bandwidth change is exactly the burst a closing uplink can't take.
	///
	/// No default: a backend that can't retune has to say so with
	/// [`Error::BitrateUnsupported`](crate::Error::BitrateUnsupported) rather
	/// than inherit a silent no-op and quietly ignore congestion.
	fn set_bitrate(&mut self, bitrate: u64) -> Result<(), Error>;

	/// The encoder name in use, e.g. `"videotoolbox"` (for logging).
	fn name(&self) -> &str;
}

/// A backend constructor: name, the codecs it can emit, and an opener.
struct Candidate {
	name: &'static str,
	codecs: &'static [Codec],
	open: fn(&Config) -> Result<Box<dyn Backend>, Error>,
}

/// Hardware backends, in priority order. Platform-gated so only the ones that
/// could plausibly work on this target are even listed.
const HARDWARE: &[Candidate] = &[
	#[cfg(target_os = "macos")]
	Candidate {
		name: videotoolbox::NAME,
		codecs: &[Codec::H264, Codec::H265],
		open: videotoolbox::VideoToolbox::open,
	},
	#[cfg(target_os = "windows")]
	Candidate {
		name: mediafoundation::NAME,
		codecs: &[Codec::H264, Codec::H265],
		open: mediafoundation::MediaFoundation::open,
	},
	#[cfg(all(target_os = "linux", feature = "nvidia"))]
	Candidate {
		name: nvenc::NAME,
		codecs: &[Codec::H264, Codec::H265],
		open: nvenc::Nvenc::open,
	},
	#[cfg(all(target_os = "linux", feature = "vaapi"))]
	Candidate {
		name: vaapi::NAME,
		codecs: &[Codec::H264],
		open: vaapi::Vaapi::open,
	},
];

/// Software fallbacks, all platforms, always available so a box with no usable
/// hardware encoder can still encode. Only H.264 (openh264) has one; H.265 is
/// hardware-only. A slice so future software codecs slot in.
const SOFTWARE: &[Candidate] = &[Candidate {
	name: openh264::NAME,
	codecs: &[Codec::H264],
	open: openh264::Openh264::open,
}];

/// Test-only backends. Deliberately in neither list above, so `Auto` /
/// `Hardware` / `Software` can never select one: they exist to be asked for by
/// name.
#[cfg(test)]
const NAMED_ONLY: &[Candidate] = &[Candidate {
	name: probe::NAME,
	codecs: &[Codec::H264],
	open: probe::Probe::open,
}];

#[cfg(not(test))]
const NAMED_ONLY: &[Candidate] = &[];

/// A candidate paired with the tier it came from, so [`select`] can tell a
/// software encoder that was asked for from one reached by falling past
/// hardware that refused to open.
struct Attempt<'a> {
	candidate: &'a Candidate,
	hardware: bool,
}

impl<'a> Attempt<'a> {
	fn hardware(candidate: &'a Candidate) -> Self {
		Self {
			candidate,
			hardware: true,
		}
	}

	fn software(candidate: &'a Candidate) -> Self {
		Self {
			candidate,
			hardware: false,
		}
	}
}

/// Open the best encoder for `config.codec` + `config.kind`, trying candidates
/// in priority order and falling back until one succeeds.
pub(crate) fn open(config: &Config) -> Result<Box<dyn Backend>, Error> {
	let codec = config.codec;
	let supports = move |c: &Candidate| c.codecs.contains(&codec);
	let hardware = HARDWARE.iter().filter(|c| supports(c)).map(Attempt::hardware);
	let software = SOFTWARE.iter().filter(|c| supports(c)).map(Attempt::software);

	let attempts: Vec<Attempt> = match &config.kind {
		Kind::Auto => hardware.chain(software).collect(),
		Kind::Hardware => hardware.collect(),
		Kind::Software => software.collect(),
		Kind::Named(name) => HARDWARE
			.iter()
			.map(Attempt::hardware)
			.chain(SOFTWARE.iter().chain(NAMED_ONLY.iter()).map(Attempt::software))
			.filter(|a| supports(a.candidate) && a.candidate.name == name)
			.collect(),
	};

	select(attempts, config)
}

/// Try `attempts` in order and return the first encoder that opens, warning when
/// that means falling past hardware.
///
/// Split out from [`open`] because the candidate lists are platform-gated
/// consts: what `Auto` has to fall back *from* depends on the machine, so a test
/// supplies its own attempts instead of hoping the host has the right GPU.
fn select(attempts: Vec<Attempt>, config: &Config) -> Result<Box<dyn Backend>, Error> {
	let mut tried = Vec::new();
	let mut refused = Vec::new();

	for attempt in attempts {
		let name = attempt.candidate.name;
		tried.push(name);

		match (attempt.candidate.open)(config) {
			Ok(backend) => {
				// `Auto` returning a software encoder is otherwise invisible except for
				// its CPU cost. Include runtime failures when hardware candidates existed.
				if !attempt.hardware && matches!(&config.kind, Kind::Auto) {
					if refused.is_empty() {
						tracing::warn!(
							encoder = name,
							"no hardware encoder available, falling back to software"
						);
					} else {
						tracing::warn!(
							encoder = name,
							refused = %refused.join(", "),
							"no hardware encoder available, falling back to software"
						);
					}
				}
				return Ok(backend);
			}
			Err(e) => {
				tracing::debug!(encoder = name, error = %e, "encoder unavailable, trying next");
				if attempt.hardware {
					refused.push(format!("{name}: {e}"));
				}
			}
		}
	}

	Err(Error::NoEncoder(tried.join(", ")))
}

#[cfg(test)]
pub(crate) mod test_util {
	use h264_reader::nal::sps::SeqParameterSet;
	use h264_reader::nal::{Nal, RefNal, UnitType};

	/// A stream's VUI color description, as the raw code points ISO/IEC 23091-2
	/// assigns them plus the range flag.
	///
	/// Deliberately not mapped onto [`Color`](crate::Color): the mapping is lossy
	/// (several code points share a matrix, and BT.709 and SMPTE 170M define the
	/// same transfer curve under different numbers), so a test that compared
	/// `Color`s could not see a backend drift on the fields `Color` folds away.
	#[derive(Debug, PartialEq, Eq)]
	pub(crate) struct Described {
		pub primaries: u8,
		pub transfer: u8,
		pub matrix: u8,
		pub full_range: bool,
	}

	/// The description we emit for BT.601 and BT.709 limited range, shared by the
	/// backend tests so a backend drifting from the others fails rather than
	/// quietly encoding its own dialect.
	///
	/// BT.601 goes out as SMPTE 170M primaries and matrix (code point 6) with the
	/// BT.709 transfer curve (1). The two curves are defined identically, and
	/// CoreVideo's SMPTE 170M transfer constant is deprecated while Media
	/// Foundation has none at all, so 1 is the only value all four backends can
	/// actually emit.
	pub(crate) const BT601_DESCRIBED: Described = Described {
		primaries: 6,
		transfer: 1,
		matrix: 6,
		full_range: false,
	};

	pub(crate) const BT709_DESCRIBED: Described = Described {
		primaries: 1,
		transfer: 1,
		matrix: 1,
		full_range: false,
	};

	/// The color description an H.264 Annex-B stream carries in its SPS, or `None`
	/// if it carries none and a decoder would have to guess.
	///
	/// Reads the bitstream rather than the encoder's config, so a backend that
	/// quietly drops the VUI (or a driver that ignores it) fails the test instead
	/// of passing on our own bookkeeping.
	pub(crate) fn declared_color(annexb: &[u8]) -> Option<Described> {
		// Every 4-byte start code contains a 3-byte one at offset 1, so scanning
		// for the short form finds both.
		let starts: Vec<usize> = (0..annexb.len().saturating_sub(2))
			.filter(|&i| annexb[i..i + 3] == [0, 0, 1])
			.map(|i| i + 3)
			.collect();

		let sps = starts.iter().enumerate().find_map(|(n, &start)| {
			// Bound the NAL at the next start code: a trailing slice would
			// leave the SPS parser reading into the following NAL.
			let end = starts.get(n + 1).map_or(annexb.len(), |&next| next - 3);
			let nal = RefNal::new(&annexb[start..end], &[], true);
			match nal.header().ok()?.nal_unit_type() {
				UnitType::SeqParameterSet => SeqParameterSet::from_bits(nal.rbsp_bits()).ok(),
				_ => None,
			}
		})?;

		let signal = sps.vui_parameters.as_ref()?.video_signal_type.as_ref()?;
		let description = signal.colour_description.as_ref()?;
		Some(Described {
			primaries: description.colour_primaries,
			transfer: description.transfer_characteristics,
			matrix: description.matrix_coefficients,
			full_range: signal.video_full_range_flag,
		})
	}
}

#[cfg(test)]
mod tests {
	use super::*;

	/// A backend that opens and encodes nothing. Stands in for a real candidate so
	/// a selection test doesn't disturb [`probe`]'s process-wide log.
	struct Stub;

	impl Stub {
		fn open(_config: &Config) -> Result<Box<dyn Backend>, Error> {
			Ok(Box::new(Self))
		}
	}

	impl Backend for Stub {
		fn encode(&mut self, _frame: &Frame, _keyframe: bool) -> Result<Vec<Encoded>, Error> {
			Ok(Vec::new())
		}

		fn flush(&mut self) -> Result<Vec<Encoded>, Error> {
			Ok(Vec::new())
		}

		fn finish(&mut self) -> Result<Vec<Encoded>, Error> {
			Ok(Vec::new())
		}

		fn set_bitrate(&mut self, _bitrate: u64) -> Result<(), Error> {
			Ok(())
		}

		fn name(&self) -> &str {
			"stub"
		}
	}

	const WORKING: Candidate = Candidate {
		name: "stub",
		codecs: &[Codec::H264],
		open: Stub::open,
	};

	/// Compiled in but refusing at runtime, the way NVENC does on a host whose
	/// driver libraries aren't on the loader path.
	const REFUSING: Candidate = Candidate {
		name: "driverless",
		codecs: &[Codec::H264],
		open: |_| Err(Error::Codec(anyhow::anyhow!("driver libraries not found"))),
	};

	fn config() -> Config {
		Config::new(320, 240, 30)
	}

	#[tracing_test::traced_test]
	#[test]
	fn falling_past_hardware_warns() {
		let backend = select(
			vec![Attempt::hardware(&REFUSING), Attempt::software(&WORKING)],
			&config(),
		)
		.unwrap();
		assert_eq!(backend.name(), "stub");

		// The warning has to name what refused and why, or it says no more than the
		// DEBUG line a user already has to know to go looking for.
		logs_assert(
			|lines: &[&str]| match lines.iter().find(|line| line.contains("falling back to software")) {
				Some(warning) if warning.contains("driverless") && warning.contains("driver libraries not found") => {
					Ok(())
				}
				Some(warning) => Err(format!("warning does not name the refusal: {warning}")),
				None => Err("no fallback warning".to_owned()),
			},
		);
	}

	#[tracing_test::traced_test]
	#[test]
	fn auto_without_hardware_warns() {
		let backend = select(vec![Attempt::software(&WORKING)], &config()).unwrap();
		assert_eq!(backend.name(), "stub");
		assert!(logs_contain("falling back to software"));
	}

	#[tracing_test::traced_test]
	#[test]
	fn asking_for_software_is_not_a_fallback() {
		let mut config = config();
		config.kind = Kind::Software;
		select(vec![Attempt::software(&WORKING)], &config).unwrap();
		assert!(!logs_contain("falling back to software"));
	}

	#[tracing_test::traced_test]
	#[test]
	fn hardware_that_opens_is_not_a_fallback() {
		select(
			vec![Attempt::hardware(&WORKING), Attempt::software(&WORKING)],
			&config(),
		)
		.unwrap();
		assert!(!logs_contain("falling back to software"));
	}
}