1use std::path::Path;
2use std::process::{Child, Command, Stdio};
3
4pub const COLOR: &str = "BT.709 primaries, transfer and matrix, limited range (SDR)";
5
6const TO_LIMITED: &str = "format=rgb48le,zscale=matrix=709:range=limited:filter=bicubic:chromal=left,format=yuv420p,setparams=color_primaries=bt709:color_trc=bt709:colorspace=bt709:range=tv";
7
8const TO_ALPHA: &str = "zscale=matrix=709:range=limited,format=yuva444p10le,setparams=color_primaries=bt709:color_trc=bt709:colorspace=bt709:range=tv";
9
10fn strings(values: &[&str]) -> Vec<String> {
11 values.iter().map(|value| (*value).to_string()).collect()
12}
13
14pub fn master_args(fps: u32) -> Vec<String> {
15 let mut args = strings(&[
16 "-vf",
17 TO_LIMITED,
18 "-c:v",
19 "libx264",
20 "-preset",
21 "slow",
22 "-profile:v",
23 "high",
24 "-pix_fmt",
25 "yuv420p",
26 "-crf",
27 "12",
28 "-g",
29 ]);
30 args.push(fps.to_string());
31 args.push("-keyint_min".into());
32 args.push(fps.to_string());
33 args.extend(strings(&[
34 "-sc_threshold",
35 "0",
36 "-threads",
37 "16",
38 "-filter_threads",
39 "1",
40 "-filter_complex_threads",
41 "1",
42 "-color_primaries",
43 "bt709",
44 "-color_trc",
45 "bt709",
46 "-colorspace",
47 "bt709",
48 "-color_range",
49 "tv",
50 "-movflags",
51 "+faststart+write_colr",
52 ]));
53 args
54}
55
56pub const AUDIO_RATE: u32 = 48_000;
57pub const AUDIO_BITRATE: &str = "320k";
58pub const AUDIO_CODEC: &str = "AAC-LC";
59
60pub fn audio_args(seconds: f64) -> Vec<String> {
61 let mut args = vec![
62 "-map".into(),
63 "0:v:0".into(),
64 "-map".into(),
65 "1:a:0".into(),
66 "-c:v".into(),
67 "copy".into(),
68 "-af".into(),
69 format!("apad=whole_dur={seconds:.6},atrim=end={seconds:.6}"),
70 ];
71 args.extend(strings(&[
72 "-c:a",
73 "aac",
74 "-profile:a",
75 "aac_low",
76 "-b:a",
77 AUDIO_BITRATE,
78 "-ar",
79 "48000",
80 "-ac",
81 "2",
82 "-fflags",
83 "+bitexact",
84 "-flags:a",
85 "+bitexact",
86 "-map_metadata",
87 "-1",
88 "-movflags",
89 "+faststart+write_colr",
90 ]));
91 args
92}
93
94pub fn mux_audio(
95 ffmpeg: &Path,
96 video: &Path,
97 pcm: &Path,
98 seconds: f64,
99 out: &Path,
100) -> Result<(), String> {
101 let status = Command::new(ffmpeg)
102 .args(["-hide_banner", "-v", "error", "-y", "-i"])
103 .arg(video)
104 .arg("-i")
105 .arg(pcm)
106 .args(audio_args(seconds))
107 .arg(out)
108 .status()
109 .map_err(|e| format!("ffmpeg: {e}"))?;
110 if status.success() {
111 Ok(())
112 } else {
113 Err(format!(
114 "ffmpeg could not mux the audio into {}",
115 out.display()
116 ))
117 }
118}
119
120pub fn alpha_args() -> Vec<String> {
121 strings(&[
122 "-vf",
123 TO_ALPHA,
124 "-c:v",
125 "prores_ks",
126 "-profile:v",
127 "4444",
128 "-pix_fmt",
129 "yuva444p10le",
130 "-filter_threads",
131 "1",
132 "-filter_complex_threads",
133 "1",
134 "-color_primaries",
135 "bt709",
136 "-color_trc",
137 "bt709",
138 "-colorspace",
139 "bt709",
140 "-color_range",
141 "tv",
142 "-movflags",
143 "+write_colr",
144 ])
145}
146
147pub fn raw(
148 ffmpeg: &Path,
149 width: u32,
150 height: u32,
151 fps: u32,
152 args: &[String],
153 out: &Path,
154) -> Result<Child, String> {
155 raw_command(ffmpeg, width, height, fps, args, out)
156 .spawn()
157 .map_err(|e| format!("ffmpeg: {e}"))
158}
159
160fn raw_command(
161 ffmpeg: &Path,
162 width: u32,
163 height: u32,
164 fps: u32,
165 args: &[String],
166 out: &Path,
167) -> Command {
168 let mut command = Command::new(ffmpeg);
169 command
170 .args([
171 "-hide_banner",
172 "-v",
173 "error",
174 "-y",
175 "-f",
176 "rawvideo",
177 "-pix_fmt",
178 "rgba64le",
179 "-color_primaries",
180 "bt709",
181 "-color_trc",
182 "iec61966-2-1",
183 "-colorspace",
184 "rgb",
185 "-color_range",
186 "pc",
187 "-s",
188 ])
189 .arg(format!("{width}x{height}"))
190 .args(["-r", &fps.to_string(), "-i", "-", "-an"])
191 .args(args)
192 .arg(out)
193 .stdin(Stdio::piped())
194 .stdout(Stdio::null());
195 command
196}
197
198pub fn file(ffmpeg: &Path, input: &Path, args: &[String], out: &Path) -> Result<(), String> {
199 let status = Command::new(ffmpeg)
200 .args(["-hide_banner", "-v", "error", "-y", "-i"])
201 .arg(input)
202 .arg("-an")
203 .args(args)
204 .arg(out)
205 .status()
206 .map_err(|e| format!("ffmpeg: {e}"))?;
207 if status.success() {
208 Ok(())
209 } else {
210 Err(format!("ffmpeg could not encode {}", out.display()))
211 }
212}
213
214pub fn alpha_sequence(ffmpeg: &Path, cutouts: &Path, fps: u32, out: &Path) -> Result<(), String> {
215 let input = cutouts.join("%05d.png");
216 let status = Command::new(ffmpeg)
217 .args(["-hide_banner", "-v", "error", "-y", "-framerate"])
218 .arg(fps.to_string())
219 .arg("-i")
220 .arg(input)
221 .arg("-an")
222 .args(alpha_args())
223 .arg(out)
224 .status()
225 .map_err(|e| format!("ffmpeg: {e}"))?;
226 if status.success() {
227 Ok(())
228 } else {
229 Err(format!("ffmpeg could not encode {}", out.display()))
230 }
231}
232
233#[cfg(test)]
234mod tests {
235 use super::*;
236 use std::io::Write;
237
238 #[test]
239 fn x264_settings_match_the_master_convention() {
240 let args = master_args(60).join(" ");
241 assert!(args.contains("-c:v libx264 -preset slow -profile:v high -pix_fmt yuv420p -crf 12 -g 60 -keyint_min 60 -sc_threshold 0"));
242 assert!(
243 args.contains(
244 "-color_primaries bt709 -color_trc bt709 -colorspace bt709 -color_range tv"
245 )
246 );
247 assert!(args.ends_with("-movflags +faststart+write_colr"));
248 assert!(args.contains("-threads 16 -filter_threads 1 -filter_complex_threads 1"));
249 assert!(args.contains(
250 "format=rgb48le,zscale=matrix=709:range=limited:filter=bicubic:chromal=left,format=yuv420p,"
251 ));
252 }
253
254 #[test]
255 fn audio_is_aac_lc_at_320k_and_48k_with_the_video_copied() {
256 let args = audio_args(1.5).join(" ");
257 assert!(args.contains("-c:v copy"));
258 assert!(args.contains("-c:a aac -profile:a aac_low -b:a 320k -ar 48000 -ac 2"));
259 assert!(args.contains("apad=whole_dur=1.500000,atrim=end=1.500000"));
260 assert!(args.contains("-fflags +bitexact -flags:a +bitexact"));
261 assert!(args.ends_with("-movflags +faststart+write_colr"));
262 }
263
264 #[test]
265 fn prores_settings_preserve_alpha_and_tag_bt709() {
266 let args = alpha_args().join(" ");
267 assert!(args.contains("-c:v prores_ks -profile:v 4444 -pix_fmt yuva444p10le"));
268 assert!(args.contains("zscale=matrix=709:range=limited,format=yuva444p10le,"));
269 assert!(args.contains("-filter_threads 1 -filter_complex_threads 1"));
270 assert!(
271 args.contains(
272 "-color_primaries bt709 -color_trc bt709 -colorspace bt709 -color_range tv"
273 )
274 );
275 }
276
277 #[test]
278 fn alpha_master_keeps_its_colour_through_a_bt709_reader() {
279 let system = crate::ffmpeg::system().unwrap();
280 let major: u32 = system
281 .version
282 .trim_start_matches('n')
283 .split(|c: char| !c.is_ascii_digit())
284 .next()
285 .and_then(|m| m.parse().ok())
286 .unwrap_or(0);
287 let ffmpeg = system.path;
288 let ffprobe = ffmpeg.with_file_name("ffprobe");
289 let dir = Path::new(env!("CARGO_MANIFEST_DIR"))
290 .join("../../tmp")
291 .join(format!("prores-colour-{}", std::process::id()));
292 std::fs::create_dir_all(&dir).unwrap();
293 let output = dir.join("swatch.alpha.master.mov");
294 let mut child = raw(&ffmpeg, 16, 16, 4, &alpha_args(), &output).unwrap();
295 let mut stdin = child.stdin.take().unwrap();
296 let swatch = [192u16, 64, 32].map(|value| value * 257);
297 for _ in 0..2 {
298 for _ in 0..16 * 16 {
299 for channel in [swatch[0], swatch[1], swatch[2], u16::MAX] {
300 stdin.write_all(&channel.to_le_bytes()).unwrap();
301 }
302 }
303 }
304 drop(stdin);
305 assert!(child.wait().unwrap().success());
306 let info = Command::new(&ffprobe)
307 .args([
308 "-v",
309 "error",
310 "-select_streams",
311 "v:0",
312 "-show_streams",
313 "-of",
314 "json",
315 ])
316 .arg(&output)
317 .output()
318 .unwrap();
319 assert!(info.status.success());
320 let json: serde_json::Value = serde_json::from_slice(&info.stdout).unwrap();
321 let stream = &json["streams"][0];
322 assert_eq!(stream["codec_name"], "prores");
323 assert_eq!(stream["color_primaries"], "bt709");
324 assert_eq!(stream["color_transfer"], "bt709");
325 assert_eq!(stream["color_space"], "bt709");
326 if major >= 9 {
327 assert_eq!(stream["color_range"], "tv");
328 }
329 let stored = Command::new(&ffmpeg)
330 .args(["-hide_banner", "-v", "error", "-i"])
331 .arg(&output)
332 .args([
333 "-frames:v",
334 "1",
335 "-f",
336 "rawvideo",
337 "-pix_fmt",
338 "yuva444p10le",
339 "-",
340 ])
341 .output()
342 .unwrap();
343 assert!(stored.status.success());
344 assert_eq!(
345 yuva444p10_centre(&stored.stdout),
346 [369, 404, 742, 1023],
347 "stored 10-bit {}",
348 stored.stdout.len()
349 );
350 let decoded = Command::new(&ffmpeg)
351 .args(["-hide_banner", "-v", "error", "-i"])
352 .arg(&output)
353 .args([
354 "-frames:v",
355 "1",
356 "-vf",
357 "scale=in_color_matrix=bt709:in_range=tv",
358 "-f",
359 "rawvideo",
360 "-pix_fmt",
361 "rgba",
362 "-",
363 ])
364 .output()
365 .unwrap();
366 assert!(decoded.status.success());
367 let centre = &decoded.stdout[(8 * 16 + 8) * 4..(8 * 16 + 8) * 4 + 4];
368 for (got, want) in centre.iter().zip([192u8, 64, 32, 255]) {
369 assert!(
370 (i16::from(*got) - i16::from(want)).abs() <= 1,
371 "decoded {centre:?} against (192, 64, 32, 255)"
372 );
373 }
374 std::fs::remove_dir_all(dir).unwrap();
375 }
376
377 #[test]
378 fn checker_master_decodes_with_tags_and_one_second_keyframes() {
379 let ffmpeg = crate::ffmpeg::system().unwrap().path;
380 let ffprobe = ffmpeg.with_file_name("ffprobe");
381 let dir = Path::new(env!("CARGO_MANIFEST_DIR"))
382 .join("../../tmp")
383 .join(format!("x264-checker-{}", std::process::id()));
384 std::fs::create_dir_all(&dir).unwrap();
385 let output = dir.join("checker.master.mp4");
386 let mut child = raw(&ffmpeg, 64, 64, 4, &master_args(4), &output).unwrap();
387 let mut stdin = child.stdin.take().unwrap();
388 for frame in 0..9 {
389 for y in 0..64 {
390 for x in 0..64 {
391 let light = (x / 8 + y / 8 + frame) % 2 == 0;
392 let value = if light { u16::MAX } else { 0u16 };
393 for channel in [value, value, value, u16::MAX] {
394 stdin.write_all(&channel.to_le_bytes()).unwrap();
395 }
396 }
397 }
398 }
399 drop(stdin);
400 assert!(child.wait().unwrap().success());
401 let info = Command::new(ffprobe)
402 .args([
403 "-v",
404 "error",
405 "-select_streams",
406 "v:0",
407 "-show_streams",
408 "-show_frames",
409 "-of",
410 "json",
411 ])
412 .arg(&output)
413 .output()
414 .unwrap();
415 assert!(info.status.success());
416 let json: serde_json::Value = serde_json::from_slice(&info.stdout).unwrap();
417 let stream = &json["streams"][0];
418 assert_eq!(stream["codec_name"], "h264");
419 assert_eq!(stream["profile"], "High");
420 assert_eq!(stream["pix_fmt"], "yuv420p");
421 assert_eq!(stream["color_primaries"], "bt709");
422 assert_eq!(stream["color_transfer"], "bt709");
423 assert_eq!(stream["color_space"], "bt709");
424 assert_eq!(stream["color_range"], "tv");
425 let keys: Vec<usize> = json["frames"]
426 .as_array()
427 .unwrap()
428 .iter()
429 .enumerate()
430 .filter_map(|(i, frame)| (frame["key_frame"] == 1).then_some(i))
431 .collect();
432 assert_eq!(keys, [0, 4, 8]);
433 std::fs::remove_dir_all(dir).unwrap();
434 }
435
436 #[test]
437 fn x264_master_keeps_its_colour_through_a_bt709_reader() {
438 let ffmpeg = crate::ffmpeg::system().unwrap().path;
439 let dir = Path::new(env!("CARGO_MANIFEST_DIR"))
440 .join("../../tmp")
441 .join(format!("x264-swatch-{}", std::process::id()));
442 std::fs::create_dir_all(&dir).unwrap();
443 let output = dir.join("swatch.master.mp4");
444 let mut child = raw(&ffmpeg, 16, 16, 4, &master_args(4), &output).unwrap();
445 let mut stdin = child.stdin.take().unwrap();
446 let swatch = [192u16, 64, 32].map(|value| value * 257);
447 for _ in 0..2 {
448 for _ in 0..16 * 16 {
449 for channel in [swatch[0], swatch[1], swatch[2], u16::MAX] {
450 stdin.write_all(&channel.to_le_bytes()).unwrap();
451 }
452 }
453 }
454 drop(stdin);
455 assert!(child.wait().unwrap().success());
456 let stored = Command::new(&ffmpeg)
457 .args(["-hide_banner", "-v", "error", "-i"])
458 .arg(&output)
459 .args([
460 "-frames:v",
461 "1",
462 "-f",
463 "rawvideo",
464 "-pix_fmt",
465 "yuv420p",
466 "-",
467 ])
468 .output()
469 .unwrap();
470 assert!(stored.status.success());
471 assert_eq!(yuv420p_centre(&stored.stdout), [92, 101, 186]);
472 let vf = master_args(4).into_iter().nth(1).unwrap();
473 let ten = vf.replacen("format=yuv420p,", "format=yuv420p10le,", 1);
474 let yuv = dir.join("swatch.10.yuv");
475 let mut wide = raw(
476 &ffmpeg,
477 16,
478 16,
479 4,
480 &[
481 "-vf".into(),
482 ten,
483 "-frames:v".into(),
484 "1".into(),
485 "-f".into(),
486 "rawvideo".into(),
487 "-pix_fmt".into(),
488 "yuv420p10le".into(),
489 ],
490 &yuv,
491 )
492 .unwrap();
493 let mut stdin = wide.stdin.take().unwrap();
494 let swatch = [192u16, 64, 32].map(|value| value * 257);
495 for _ in 0..16 * 16 {
496 for channel in [swatch[0], swatch[1], swatch[2], u16::MAX] {
497 stdin.write_all(&channel.to_le_bytes()).unwrap();
498 }
499 }
500 drop(stdin);
501 assert!(wide.wait().unwrap().success());
502 assert_eq!(
503 yuv420p10_centre(&std::fs::read(yuv).unwrap()),
504 [369, 404, 742]
505 );
506 std::fs::remove_dir_all(dir).unwrap();
507 }
508
509 #[cfg(target_os = "linux")]
510 fn frames_rgba64(width: usize, height: usize, count: usize) -> Vec<u8> {
511 let mut state = 0x2545_f491_4f6c_dd1du64;
512 let mut out = Vec::with_capacity(width * height * count * 8);
513 for frame in 0..count {
514 for y in 0..height {
515 for x in 0..width {
516 state = state
517 .wrapping_mul(6364136223846793005)
518 .wrapping_add(1442695040888963407);
519 let noise = (state >> 48) as u16 & 0x03ff;
520 let r = ((x * 65535 / width) as u16).wrapping_add(noise);
521 let g = ((y * 65535 / height) as u16).wrapping_add((frame * 2111) as u16);
522 let b = (((x + y + frame * 5) * 997) as u16) ^ noise;
523 let a = ((x * 65535 / width) as u16) | 0x4000;
524 for channel in [r, g, b, a] {
525 out.extend_from_slice(&channel.to_le_bytes());
526 }
527 }
528 }
529 }
530 out
531 }
532
533 #[cfg(target_os = "linux")]
534 fn encode_on(
535 cpus: Option<&str>,
536 ffmpeg: &Path,
537 args: &[String],
538 frames: &[u8],
539 out: &Path,
540 ) -> Vec<u8> {
541 let mut command = raw_command(ffmpeg, 320, 180, 6, args, out);
542 if let Some(cpus) = cpus {
543 let mut pinned = Command::new("taskset");
544 pinned
545 .arg("-c")
546 .arg(cpus)
547 .arg(command.get_program())
548 .args(command.get_args())
549 .stdin(Stdio::piped())
550 .stdout(Stdio::null());
551 command = pinned;
552 }
553 let mut child = command.spawn().unwrap();
554 let mut stdin = child.stdin.take().unwrap();
555 stdin.write_all(frames).unwrap();
556 drop(stdin);
557 assert!(child.wait().unwrap().success());
558 std::fs::read(out).unwrap()
559 }
560
561 #[test]
562 #[cfg(target_os = "linux")]
563 fn masters_are_byte_identical_on_any_cpu_count() {
564 let ffmpeg = crate::ffmpeg::system().unwrap().path;
565 let dir = Path::new(env!("CARGO_MANIFEST_DIR"))
566 .join("../../tmp")
567 .join(format!("master-threads-{}", std::process::id()));
568 std::fs::create_dir_all(&dir).unwrap();
569 let frames = frames_rgba64(320, 180, 12);
570 for (name, args) in [
571 ("a.master.mp4", master_args(6)),
572 ("a.alpha.master.mov", alpha_args()),
573 ] {
574 let one = encode_on(Some("0"), &ffmpeg, &args, &frames, &dir.join(name));
575 let all = encode_on(None, &ffmpeg, &args, &frames, &dir.join(name));
576 assert!(!one.is_empty());
577 assert!(one == all, "{name} differs between 1 CPU and all CPUs");
578 }
579 std::fs::remove_dir_all(dir).unwrap();
580 }
581
582 fn sample16(buf: &[u8], start: usize, stride: usize, x: usize, y: usize) -> u16 {
583 let i = (start + y * stride + x) * 2;
584 u16::from_le_bytes([buf[i], buf[i + 1]])
585 }
586
587 fn yuva444p10_centre(buf: &[u8]) -> [u16; 4] {
588 let n = 16 * 16;
589 [0, 1, 2, 3].map(|plane| sample16(buf, plane * n, 16, 8, 8))
590 }
591
592 fn yuv420p_centre(buf: &[u8]) -> [u8; 3] {
593 let n = 16 * 16;
594 let cn = 8 * 8;
595 [buf[8 * 16 + 8], buf[n + 4 * 8 + 4], buf[n + cn + 4 * 8 + 4]]
596 }
597
598 fn yuv420p10_centre(buf: &[u8]) -> [u16; 3] {
599 let n = 16 * 16;
600 let cn = 8 * 8;
601 [
602 sample16(buf, 0, 16, 8, 8),
603 sample16(buf, n, 8, 4, 4),
604 sample16(buf, n + cn, 8, 4, 4),
605 ]
606 }
607}