Skip to main content

pfx_run/
encode.rs

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}