Skip to main content

ffcine_assembly/
exec.rs

1//! Runs a [`Plan`]: renders segments missing from the cache, joins them by stream copy, mixes
2//! the audio, and muxes the master.
3//!
4//! Cache layout (`<cache>/`): `seg/<key>.mp4` (one per segment), `audio/<key>-aac<bitrate>.m4a`
5//! (the mix, encoded), `tmp/` (intermediates). Every entry is written to a `.partial-<pid>` file and
6//! renamed when complete, so a killed render resumes: the next run reuses what finished. Nothing is pruned, and old masters are never touched.
7//!
8//! Shots go through the GPU [`Pipeline`] (decode, grade, scale, encode). The audio is
9//! `ffcine_audio::render_audio`'s mix, encoded once. The joins, retime prep, dissolve blends, AAC
10//! encode and mux use the `ffmpeg` CLI.
11
12use std::ffi::OsString;
13use std::fmt::Write as _;
14use std::path::{Path, PathBuf};
15use std::process::Command;
16use std::time::{Duration, Instant};
17
18use std::sync::Arc;
19
20use ffcine_comp::Base;
21use ffcine_render::{
22    Backend, Codec, Decode, Device, EncoderSettings, Grade, Hook, Layer, LayerSource, Lut,
23    LutStage, LutType, Overlay, ParamTrack, Pipeline, Rational, Size, shaders,
24};
25use serde_json::json;
26
27use crate::comp::Finish;
28use crate::plan::{AudioPlan, Plan, Segment, Shot, TextLayer};
29use crate::recipe::{Fingerprinter as _, Key};
30use ffcine_render::Warp;
31use ffcine_stabilize::{Detection, SourceRange};
32
33/// Errors from [`Renderer::render`].
34#[derive(Debug, thiserror::Error)]
35pub enum ExecError {
36    #[error("{0}")]
37    Render(#[from] ffcine_core::Error),
38    #[error("{what}: {source}")]
39    Io {
40        what: String,
41        source: std::io::Error,
42    },
43    #[error("grade of {shot:?}: {msg}")]
44    Grade { shot: String, msg: String },
45    #[error("stabilizing {shot:?}: {msg}")]
46    Stabilize { shot: String, msg: String },
47    #[error("ffmpeg ({what}) failed: {log}")]
48    Ffmpeg { what: String, log: String },
49}
50
51pub(crate) type Result<T> = std::result::Result<T, ExecError>;
52
53fn io(what: impl Into<String>) -> impl FnOnce(std::io::Error) -> ExecError {
54    let what = what.into();
55    move |source| ExecError::Io { what, source }
56}
57
58/// How progress is reported.
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
60pub enum Progress {
61    /// Lines on stderr.
62    #[default]
63    Human,
64    /// One JSON object per event on stdout.
65    Json,
66    Quiet,
67}
68
69/// What a render did.
70#[derive(Debug, Clone, Default, PartialEq)]
71pub struct Report {
72    pub segments: usize,
73    pub rendered: usize,
74    pub rendered_frames: i64,
75    /// Time spent rendering segments.
76    pub render: Duration,
77    /// Mixing audio (`None`: cached or no audio).
78    pub audio: Option<Duration>,
79    /// Muxing the master (`None`: already up to date).
80    pub mux: Option<Duration>,
81    pub total: Duration,
82}
83
84/// Renders plans against a cache directory.
85pub struct Renderer<'a> {
86    pub backend: &'a dyn Backend,
87    pub cache: PathBuf,
88    pub ffmpeg: OsString,
89    pub device: Device,
90    pub decode: Decode,
91    pub progress: Progress,
92    /// Builds the GPU grade for a shot's resolved params (LUT, look hook, sharpen); the size is
93    /// set by the renderer. `None`: the LUT and sharpen only.
94    pub grader: Option<&'a Grader<'a>>,
95    /// Stabilizer motion detect on the GPU (NVOF, cached on disk): a source file and range to
96    /// per-frame motions. Required by stabilized clips; there is no CPU fallback.
97    pub detect: Option<&'a Detector<'a>>,
98}
99
100/// See [`Renderer::grader`].
101pub type Grader<'g> = dyn Fn(&Shot) -> std::result::Result<Grade, String> + 'g;
102
103/// See [`Renderer::detect`].
104pub type Detector<'d> = dyn Fn(&Path, &SourceRange) -> std::result::Result<Detection, String> + 'd;
105
106/// The timeline rate as a rational (`23.976...` -> `24000/1001`).
107#[allow(clippy::cast_possible_truncation)]
108pub fn rational(rate: f64) -> Rational {
109    for den in [1, 1001, 1000, 100] {
110        let num = rate * f64::from(den);
111        if (num - num.round()).abs() < 1e-3 * f64::from(den) / 1000.0_f64.max(1.0) + 1e-6 {
112            return Rational {
113                num: num.round() as i32,
114                den,
115            };
116        }
117    }
118    Rational {
119        num: (rate * 1000.0).round() as i32,
120        den: 1000,
121    }
122}
123
124#[allow(clippy::cast_precision_loss)]
125pub(crate) fn secs(frames: i64, rate: f64) -> f64 {
126    frames as f64 / rate
127}
128
129/// The prototype's `tmix=frames=n` on CUDA frames at rate `fr`, as `blend_vulkan` steps (a
130/// running mean over `n` copies of the stream, copy `j` starting `j` frames later). The input
131/// starts `hist` frames before the piece (`hist < n - 1` near the source's start: the first
132/// frame is held for the rest), so the output's first frame averages the `n` frames ending at
133/// the piece's first. Ends on CUDA frames for NVENC.
134fn blend_filter(n: u32, hist: i64, fr: &str) -> String {
135    let pad = i64::from(n) - 1 - hist;
136    let mut f = String::new();
137    if pad > 0 {
138        let _ = write!(f, "tpad=start={pad}:start_mode=clone,");
139    }
140    let _ = write!(f, "settb=1/({fr}),setpts=N,hwupload,split={n}");
141    for j in 0..n {
142        let _ = write!(f, "[b{j}]");
143    }
144    f.push(';');
145    for j in 1..n {
146        let _ = write!(f, "[b{j}]trim=start_frame={j},setpts=PTS-STARTPTS[t{j}];");
147    }
148    let mut prev = "b0".to_owned();
149    for j in 1..n {
150        let _ = write!(
151            f,
152            "[t{j}][{prev}]blend_vulkan=all_mode=normal:all_opacity={:.7}",
153            1.0 / f64::from(j + 1)
154        );
155        if j + 1 < n {
156            prev = format!("m{j}");
157            let _ = write!(f, "[{prev}];");
158        }
159    }
160    f.push_str(",hwupload=derive_device=cuda");
161    f
162}
163
164/// A ramped piece's retime: where to seek (source seconds after the clip's start), how much to
165/// read, and the filter giving output frames `from..from + n` of the whole clip.
166#[derive(Debug, Clone, PartialEq)]
167struct RampCut {
168    seek: f64,
169    duration: f64,
170    filter: String,
171}
172
173/// See [`RampCut`]. Source time is read on the source's own clock (`-copyts -start_at_zero`)
174/// from the clip's start, so a source frame lands on the same output time whichever piece
175/// decodes it, and `fps` numbers output frames from the clip's start: a piece seeks to just
176/// before its first source frame and gets the frames the whole clip would have there.
177#[allow(clippy::cast_precision_loss)]
178fn ramp_cut(ramp: &crate::plan::Ramp, clip_start: f64, from: i64, n: i64, rate: f64) -> RampCut {
179    let r = rational(rate);
180    // Half a second covers any source frame interval before the first one needed.
181    let seek = (ramp.source_time(secs(from, rate)) - 0.5).max(0.0);
182    RampCut {
183        seek,
184        duration: ramp.source_duration - seek + secs(2, rate),
185        filter: format!(
186            "{},fps={}/{},trim=start_pts={from}:end_pts={},setpts=PTS-STARTPTS",
187            ramp.setpts_of(&format!("(PTS*TB-{clip_start})")),
188            r.num,
189            r.den,
190            from + n
191        ),
192    }
193}
194
195impl Renderer<'_> {
196    fn dir(&self, sub: &str) -> Result<PathBuf> {
197        let d = self.cache.join(sub);
198        std::fs::create_dir_all(&d).map_err(io(format!("creating {}", d.display())))?;
199        Ok(d)
200    }
201
202    pub(crate) fn event(&self, v: &serde_json::Value, human: impl FnOnce() -> String) {
203        match self.progress {
204            Progress::Human => eprintln!("{}", human()),
205            Progress::Json => println!("{v}"),
206            Progress::Quiet => {}
207        }
208    }
209
210    fn ffmpeg(&self, what: &str, args: &[OsString]) -> Result<()> {
211        let out = Command::new(&self.ffmpeg)
212            .args(["-hide_banner", "-nostdin", "-loglevel", "error", "-y"])
213            .args(args)
214            .output()
215            .map_err(io(format!("running {}", self.ffmpeg.to_string_lossy())))?;
216        if out.status.success() {
217            Ok(())
218        } else {
219            let log = String::from_utf8_lossy(&out.stderr);
220            let tail: Vec<&str> = log.lines().rev().take(8).collect();
221            Err(ExecError::Ffmpeg {
222                what: what.into(),
223                log: tail.into_iter().rev().collect::<Vec<_>>().join("\n"),
224            })
225        }
226    }
227
228    /// Runs `make(partial)` unless `path` exists, then renames the partial into place.
229    fn atomic(path: &Path, make: impl FnOnce(&Path) -> Result<()>) -> Result<bool> {
230        if path.exists() {
231            return Ok(false);
232        }
233        let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("mkv");
234        // Per process, so two renders making the same file don't write one partial.
235        let partial = path.with_extension(format!("partial-{}.{ext}", std::process::id()));
236        if let Err(e) = make(&partial) {
237            let _ = std::fs::remove_file(&partial);
238            return Err(e);
239        }
240        std::fs::rename(&partial, path).map_err(io(format!("renaming {}", partial.display())))?;
241        Ok(true)
242    }
243
244    /// Renders `plan` to `output` (an `.mp4`); writes `<output>.ffcine.json` next to it.
245    #[allow(clippy::too_many_lines)]
246    pub fn render(
247        &self,
248        plan: &Plan,
249        mut text: Option<&mut TextLayer>,
250        output: &Path,
251    ) -> Result<Report> {
252        let t0 = Instant::now();
253        if self.cache.is_relative() {
254            let cwd = std::env::current_dir().map_err(io("current directory"))?;
255            return Renderer {
256                cache: cwd.join(&self.cache),
257                ffmpeg: self.ffmpeg.clone(),
258                ..*self
259            }
260            .render(plan, text, output);
261        }
262        let mut report = Report {
263            segments: plan.segments.len(),
264            ..Report::default()
265        };
266        if let Some(t) = text.as_deref_mut() {
267            let a = &plan.profile.aspect;
268            let safe = a.default_safe_area();
269            #[allow(clippy::cast_possible_truncation)]
270            t.set_profile(
271                &a.name,
272                [safe.top, safe.right, safe.bottom, safe.left].map(|v| v as f32),
273            );
274        }
275        let manifest_path = manifest_path(output);
276        let master_key = plan.master_key();
277        if output.exists() && read_manifest_key(&manifest_path).as_ref() == Some(&master_key) {
278            self.event(&json!({"event": "up_to_date"}), || {
279                format!("{}: up to date", output.display())
280            });
281            report.total = t0.elapsed();
282            return Ok(report);
283        }
284
285        let seg_dir = self.dir("seg")?;
286        let mut files = Vec::new();
287        let n = plan.segments.len();
288        for (i, s) in plan.segments.iter().enumerate() {
289            let path = seg_dir.join(format!("{}.mp4", s.key));
290            let t = Instant::now();
291            let made = Self::atomic(&path, |tmp| {
292                #[allow(clippy::cast_precision_loss)]
293                let overlays = match text.as_deref_mut() {
294                    Some(t) => t.overlays(
295                        s.start as f64 / plan.rate,
296                        u64::try_from(s.frames).unwrap_or(0),
297                        plan.rate,
298                        plan.profile.size,
299                    )?,
300                    None => Vec::new(),
301                };
302                self.segment(plan, s, overlays, tmp)
303            })?;
304            let el = t.elapsed();
305            if made {
306                report.rendered += 1;
307                report.rendered_frames += s.frames;
308                report.render += el;
309            }
310            self.event(
311                &json!({"event": "segment", "i": i + 1, "n": n, "name": s.name, "key": s.key,
312                        "frames": s.frames, "status": if made { "rendered" } else { "cached" },
313                        "secs": el.as_secs_f64()}),
314                || {
315                    format!(
316                        "[{}/{n}] {} {} ({}f){}",
317                        i + 1,
318                        if made { "rendered" } else { "cached  " },
319                        s.name,
320                        s.frames,
321                        if made {
322                            format!(" in {:.1} s", el.as_secs_f64())
323                        } else {
324                            String::new()
325                        }
326                    )
327                },
328            );
329            files.push(path);
330        }
331
332        let audio = match &plan.audio {
333            Some(a) => {
334                let path = self
335                    .dir("audio")?
336                    .join(format!("{}-aac{}.m4a", a.key, plan.profile.audio_bitrate));
337                let t = Instant::now();
338                let mut level = None;
339                if Self::atomic(&path, |tmp| {
340                    level = Some(self.mix(plan, a, tmp)?);
341                    Ok(())
342                })? {
343                    report.audio = Some(t.elapsed());
344                }
345                if let (Some((lufs, tp)), Some(el)) = (level, report.audio) {
346                    self.event(
347                        &json!({"event": "audio", "key": a.key, "clips": a.clips, "lufs": lufs,
348                                "true_peak": tp, "secs": el.as_secs_f64()}),
349                        || {
350                            format!(
351                                "mixed {} audio clips in {:.1} s: {lufs:.1} LUFS, {tp:.1} dBTP",
352                                a.clips,
353                                el.as_secs_f64()
354                            )
355                        },
356                    );
357                }
358                Some(path)
359            }
360            None => None,
361        };
362
363        let t = Instant::now();
364        self.mux(plan, &files, audio.as_deref(), output)?;
365        report.mux = Some(t.elapsed());
366        let manifest = json!({
367            "master": master_key,
368            "program": plan.program_key(),
369            "audio": plan.audio.as_ref().map(|a| &a.key),
370            "mix": plan.audio.as_ref().map(|a| &a.options),
371            "profile": plan.profile.name,
372            "segments": plan.segments.iter().map(|s| &s.key).collect::<Vec<_>>(),
373        });
374        std::fs::write(&manifest_path, format!("{manifest:#}\n"))
375            .map_err(io(format!("writing {}", manifest_path.display())))?;
376        report.total = t0.elapsed();
377        self.event(
378            &json!({"event": "done", "rendered": report.rendered, "segments": n,
379                    "secs": report.total.as_secs_f64()}),
380            || {
381                format!(
382                    "{}: {} of {n} segments rendered, {:.1} s",
383                    output.display(),
384                    report.rendered,
385                    report.total.as_secs_f64()
386                )
387            },
388        );
389        Ok(report)
390    }
391
392    fn pipeline(&self, input: &Path, out: &Path, start: f64, frames: i64, rate: f64) -> Pipeline {
393        Pipeline::new(input, out)
394            .range(
395                Duration::from_secs_f64(start.max(0.0)),
396                Some(u64::try_from(frames).unwrap_or(0)),
397            )
398            .frame_rate(rational(rate))
399            .device(self.device)
400            .decode(self.decode)
401    }
402
403    /// Renders one segment to `out` with the profile's encoder: one GPU pass over the piece the
404    /// segment lowers to (`lower.rs`), its base as the pass input and its layers over it.
405    fn segment(
406        &self,
407        plan: &Plan,
408        seg: &Segment,
409        mut overlays: Vec<Overlay>,
410        out: &Path,
411    ) -> Result<()> {
412        let mut temps = Vec::new();
413        let r = self.lower(plan, seg, &mut temps).and_then(|(p, ctx)| {
414            let (base, start) = match &p.base {
415                Base::Black => (self.black(plan, seg.frames, &seg.key, &mut temps)?, 0.0),
416                Base::Video { input, start } => (input.clone(), start.as_secs_f64()),
417            };
418            overlays.extend(p.overlays);
419            let pipe = self
420                .pipeline(&base, out, start, seg.frames, plan.rate)
421                .context(
422                    u64::try_from(ctx.0).unwrap_or(0),
423                    u64::try_from(ctx.1).unwrap_or(0),
424                )
425                .grade(p.grade)
426                .layers(p.layers)
427                .overlays(overlays)
428                .encoder(plan.profile.encoder.clone());
429            self.run_dv_retry(&pipe, &seg.key)
430        });
431        for t in temps {
432            let _ = std::fs::remove_file(t);
433        }
434        r
435    }
436
437    /// Runs filter nodes (`docs/compositing.md#libavfilter-escape-hatch`) into lossless
438    /// intermediates, cached by the node's key (a whole-clip node's is shared by every piece), and
439    /// points their layers at them. Nested comps' layers too.
440    pub(crate) fn filter_preps(&self, layers: &mut [Layer]) -> Result<()> {
441        for l in layers {
442            match &l.source {
443                LayerSource::Filtered(p) => {
444                    let p = Arc::clone(p);
445                    let out = self.dir("prep")?.join(format!(
446                        "{}.filter.mkv",
447                        Key::of(&json!({"prep": crate::filter_prep::FILTER_PREP, "node": p.key}))
448                    ));
449                    let what = format!("filter node `{}`", p.graph);
450                    Self::atomic(&out, |partial| {
451                        let info = self.backend.probe(&p.input)?;
452                        let (_, v) = info.video().ok_or_else(|| ExecError::Ffmpeg {
453                            what: what.clone(),
454                            log: format!("{}: no video stream", p.input.display()),
455                        })?;
456                        crate::filter_prep::run(
457                            &self.ffmpeg,
458                            &p,
459                            &v.pixel_format,
460                            v.color,
461                            partial,
462                        )
463                        .map_err(|log| ExecError::Ffmpeg {
464                            what: what.clone(),
465                            log,
466                        })?;
467                        let out = self.backend.probe(partial)?;
468                        let got = out.video().map(|(_, v)| (v.width, v.height));
469                        crate::filter_prep::check_dims(got, p.dims).map_err(|log| {
470                            ExecError::Ffmpeg {
471                                what: what.clone(),
472                                log,
473                            }
474                        })?;
475                        if !p.free_timing {
476                            self.check_frames(partial, p.store.len(), &what)?;
477                        }
478                        Ok(())
479                    })?;
480                    let rate = p.rate;
481                    l.source = if p.free_timing {
482                        LayerSource::Video {
483                            input: out,
484                            start: Duration::from_secs_f64(secs(
485                                i64::try_from(p.show.start).unwrap_or(0),
486                                rate,
487                            )),
488                            map: None,
489                        }
490                    } else {
491                        #[allow(clippy::cast_precision_loss)]
492                        let at = (p.show.start as f64 - 0.5) / rate;
493                        LayerSource::Video {
494                            input: out,
495                            start: Duration::from_secs_f64(at.max(0.0)),
496                            map: Some((0..i64::try_from(p.show.len()).unwrap_or(0)).collect()),
497                        }
498                    };
499                }
500                LayerSource::Scene(s) => {
501                    let mut inner = s.layers.clone();
502                    self.filter_preps(&mut inner)?;
503                    if inner != s.layers {
504                        l.source = LayerSource::Scene(Arc::new(ffcine_render::Scene {
505                            layers: inner,
506                            ..(**s).clone()
507                        }));
508                    }
509                }
510                _ => {}
511            }
512        }
513        Ok(())
514    }
515
516    /// Fails unless `path`'s video has `want` frames: a graph that drops or adds frames needs
517    /// `timing: free`.
518    fn check_frames(&self, path: &Path, want: usize, what: &str) -> Result<()> {
519        let out = Command::new(&self.ffmpeg)
520            .args(["-hide_banner", "-nostdin", "-i"])
521            .arg(path)
522            .args(["-map", "0:v:0", "-c", "copy", "-f", "null", "-"])
523            .output()
524            .map_err(io(format!("running {}", self.ffmpeg.to_string_lossy())))?;
525        let log = String::from_utf8_lossy(&out.stderr);
526        let got = log
527            .rsplit("frame=")
528            .next()
529            .and_then(|s| s.split_whitespace().next())
530            .and_then(|n| n.parse::<usize>().ok());
531        if got == Some(want) {
532            return Ok(());
533        }
534        Err(ExecError::Ffmpeg {
535            what: what.into(),
536            log: format!(
537                "the graph gave {} frames for {want}: a graph that changes the frame count or \
538                 timing needs \"timing\": \"free\" in the layer's filter (the layer then takes \
539                 whatever frames it outputs)",
540                got.map_or_else(|| "an unknown number of".into(), |n| n.to_string())
541            ),
542        })
543    }
544
545    /// Points remapped video layers whose frame map steps back (reverse, boomerang) at an
546    /// all-intra lossless (FFV1) intermediate of the span the map covers, so each step back is one
547    /// seek to one frame (`docs/compositing.md#time-remapping`). Nested comps' layers too.
548    pub(crate) fn intra_preps(&self, layers: &mut [Layer]) -> Result<()> {
549        for l in layers {
550            match &mut l.source {
551                LayerSource::Video {
552                    input,
553                    start,
554                    map: Some(map),
555                } if map.windows(2).any(|w| w[1] < w[0]) => {
556                    let frames = map.iter().copied().max().unwrap_or(0) + 1;
557                    let at = start.as_secs_f64();
558                    let id = json!({
559                        // The prep's format: change it when the ffmpeg arguments below change.
560                        "prep": INTRA_PREP,
561                        "intra": crate::recipe::FsFingerprinter.fingerprint(input),
562                        "start": at,
563                        "frames": frames,
564                    });
565                    let prep = self
566                        .dir("prep")?
567                        .join(format!("{}.intra.mkv", Key::of(&id)));
568                    Self::atomic(&prep, |out| {
569                        self.ffmpeg("all-intra prep", &intra_prep_args(input, at, frames, out))
570                    })?;
571                    *input = prep;
572                    *start = Duration::ZERO;
573                }
574                LayerSource::Scene(s) => {
575                    let mut inner = s.layers.clone();
576                    self.intra_preps(&mut inner)?;
577                    if inner != s.layers {
578                        *s = Arc::new(ffcine_render::Scene {
579                            layers: inner,
580                            ..(**s).clone()
581                        });
582                    }
583                }
584                _ => {}
585            }
586        }
587        Ok(())
588    }
589
590    /// Runs `p`; if it fails, retries once with Dolby Vision RPUs stripped from every source
591    /// file (a stream copy): the grade pass can't take them (iPhone HLG).
592    fn run_dv_retry(&self, p: &Pipeline, key: &Key) -> Result<()> {
593        let Err(e) = p.run(self.backend) else {
594            return Ok(());
595        };
596        let tmp = self.dir("tmp")?;
597        let mut stripped = Vec::new();
598        let mut strip = |input: &Path, i: usize| -> Option<PathBuf> {
599            if input.starts_with(&tmp) {
600                return Some(input.to_owned());
601            }
602            let nodv = tmp.join(format!("{key}.{i}.nodv.mov"));
603            let mut v = vec![OsString::from("-i"), input.into()];
604            v.extend(args(&[
605                "-map",
606                "0:v:0",
607                "-c:v",
608                "copy",
609                "-bsf:v",
610                "dovi_rpu=strip=1",
611            ]));
612            v.push(nodv.clone().into());
613            self.ffmpeg("strip Dolby Vision", &v).ok()?;
614            stripped.push(nodv.clone());
615            Some(nodv)
616        };
617        let mut q = p.clone();
618        let mut ok = strip(&p.input, 0).map(|i| q.input = i).is_some();
619        for (i, l) in q.layers.iter_mut().enumerate() {
620            if let LayerSource::Video { input, .. } = &mut l.source {
621                match strip(input, i + 1) {
622                    Some(n) => *input = n,
623                    None => ok = false,
624                }
625            }
626        }
627        let retry = ok && q.run(self.backend).is_ok();
628        for f in stripped {
629            let _ = std::fs::remove_file(f);
630        }
631        if retry { Ok(()) } else { Err(e.into()) }
632    }
633
634    /// Renders one lowered comp piece to `out` with `encoder` (`ffcine still` of a comp: one
635    /// frame, a PNG); `output` overrides the pass's output colour space.
636    pub fn render_piece(
637        &self,
638        piece: &ffcine_comp::Piece,
639        rate: f64,
640        out: &Path,
641        encoder: EncoderSettings,
642        output: Option<ffcine_core::ColorSpace>,
643    ) -> Result<()> {
644        let key = Key::of(&piece.recipe);
645        let mut temps = Vec::new();
646        let (base, start) = match &piece.base {
647            Base::Black => (self.black_at(rate, piece.frames, &key, &mut temps)?, 0.0),
648            Base::Video { input, start } => (input.clone(), start.as_secs_f64()),
649        };
650        let mut layers = piece.layers.clone();
651        self.filter_preps(&mut layers)?;
652        self.intra_preps(&mut layers)?;
653        let mut grade = piece.grade.clone();
654        if let Some(c) = output {
655            grade.output = c;
656        }
657        let pipe = self
658            .pipeline(&base, out, start, piece.frames, rate)
659            .grade(grade)
660            .layers(layers)
661            .overlays(piece.overlays.clone())
662            .encoder(encoder);
663        let r = self.run_dv_retry(&pipe, &key);
664        for t in temps {
665            let _ = std::fs::remove_file(t);
666        }
667        r
668    }
669
670    /// Black, `frames` long, as a base for units without one (gaps, split-screens, whips).
671    fn black(
672        &self,
673        plan: &Plan,
674        frames: i64,
675        key: &Key,
676        temps: &mut Vec<PathBuf>,
677    ) -> Result<PathBuf> {
678        self.black_at(plan.rate, frames, key, temps)
679    }
680
681    /// [`Renderer::black`] at `rate`.
682    fn black_at(
683        &self,
684        rate: f64,
685        frames: i64,
686        key: &Key,
687        temps: &mut Vec<PathBuf>,
688    ) -> Result<PathBuf> {
689        let black = self.dir("tmp")?.join(format!("{key}.black.mkv"));
690        let r = rational(rate);
691        self.ffmpeg(
692            "black",
693            &args(&[
694                "-f",
695                "lavfi",
696                "-i",
697                // Tiny: the pass scales it to the frame (backdrops replace it).
698                &format!("color=c=black:s=64x36:r={}/{}", r.num, r.den),
699                "-frames:v",
700                &frames.to_string(),
701                "-pix_fmt",
702                "yuv420p10le",
703                "-color_primaries",
704                "bt709",
705                "-color_trc",
706                "bt709",
707                "-colorspace",
708                "bt709",
709                "-color_range",
710                "tv",
711                "-c:v",
712                "ffv1",
713            ])
714            .chain([black.clone().into()])
715            .collect::<Vec<_>>(),
716        )?;
717        temps.push(black.clone());
718        Ok(black)
719    }
720
721    /// A shot's source for the GPU pass: its media at its start, or, with a speed change, a
722    /// freeze or a temporal blend, a lossless retimed intermediate (ffmpeg on NVDEC/NVENC) from 0.
723    pub(crate) fn input(
724        &self,
725        plan: &Plan,
726        s: &Shot,
727        key: &Key,
728        tag: &str,
729        temps: &mut Vec<PathBuf>,
730    ) -> Result<(PathBuf, f64)> {
731        self.input_ctx(plan, s, (0, 0), key, tag, temps)
732    }
733
734    /// [`Renderer::input`] with `ctx` (before, after) frames of the clip around the piece for
735    /// the denoiser ([`Pipeline::context`]): a retimed intermediate holds them too.
736    #[allow(clippy::too_many_lines, clippy::many_single_char_names)]
737    pub(crate) fn input_ctx(
738        &self,
739        plan: &Plan,
740        s: &Shot,
741        ctx: (i64, i64),
742        key: &Key,
743        tag: &str,
744        temps: &mut Vec<PathBuf>,
745    ) -> Result<(PathBuf, f64)> {
746        // A freeze blends identical frames.
747        let blend = s.filters.blend.filter(|_| s.speed.abs() > 1e-9);
748        if (s.speed - 1.0).abs() < 1e-9 && s.filters.ramp.is_none() && blend.is_none() {
749            return Ok((s.media.clone(), s.start));
750        }
751        let r = rational(plan.rate);
752        let fr = format!("{}/{}", r.num, r.den);
753        let prep = self
754            .dir("tmp")?
755            .join(format!("{key}.{tag}.{}.retime.mkv", s.frames));
756        let (before, after) = if s.speed.abs() < 1e-9 { (0, 0) } else { ctx };
757        let total = before + s.frames + after;
758        let n = total.to_string();
759        let mut v = Vec::new();
760        if blend.is_some() {
761            v.extend(args(&[
762                "-init_hw_device",
763                "cuda=cu",
764                "-init_hw_device",
765                "vulkan=vk@cu",
766                "-filter_hw_device",
767                "vk",
768                "-hwaccel_device",
769                "cu",
770            ]));
771        }
772        // Frames before the piece a blend averages in.
773        let want = blend.map_or(0, |b| i64::from(b) - 1);
774        let mut hist = 0;
775        let filter = if let Some(ramp) = &s.filters.ramp {
776            let from = s.clip_at.max(0) - before;
777            hist = want.min(from);
778            let cut = ramp_cut(ramp, s.clip_start, from - hist, hist + total, plan.rate);
779            v.extend(args(&[
780                "-hwaccel",
781                "cuda",
782                "-hwaccel_output_format",
783                "cuda",
784                "-copyts",
785                "-start_at_zero",
786                "-ss",
787                &(s.clip_start + cut.seek).to_string(),
788                "-t",
789                &cut.duration.to_string(),
790                "-i",
791            ]));
792            v.push(s.media.clone().into());
793            format!("{},scale_cuda=format=p010le", cut.filter)
794        } else if s.speed.abs() < 1e-9 {
795            v.extend(args(&["-ss", &s.start.to_string(), "-i"]));
796            v.push(s.media.clone().into());
797            format!("trim=end_frame=1,loop=loop=-1:size=1:start=0,setpts=N/({fr})/TB,format=p010le")
798        } else {
799            #[allow(clippy::cast_possible_truncation)]
800            let room =
801                (s.shifted(-before, plan.rate).start * plan.rate / s.speed + 1e-6).floor() as i64;
802            hist = want.min(room.max(0));
803            let src = secs(hist + before + s.frames + after + 2, plan.rate) * s.speed;
804            // Frames stay in VRAM from NVDEC to the lossless NVENC intermediate: a timelapse
805            // reads 20x its length of source.
806            v.extend(args(&[
807                "-hwaccel",
808                "cuda",
809                "-hwaccel_output_format",
810                "cuda",
811                "-ss",
812                &s.shifted(-before - hist, plan.rate).start.to_string(),
813                "-t",
814                &src.to_string(),
815                "-i",
816            ]));
817            v.push(s.media.clone().into());
818            format!(
819                "setpts=(PTS-STARTPTS)/{},fps={fr},scale_cuda=format=p010le",
820                s.speed
821            )
822        };
823        let filter = match blend {
824            Some(b) => format!("{filter},{}", blend_filter(b, hist, &fr)),
825            None => filter,
826        };
827        v.extend(args(&[
828            "-map",
829            "0:v:0",
830            "-vf",
831            &filter,
832            "-frames:v",
833            &n,
834            "-c:v",
835            "hevc_nvenc",
836            "-tune",
837            "lossless",
838        ]));
839        v.push(prep.clone().into());
840        self.ffmpeg("retime", &v)?;
841        temps.push(prep.clone());
842        Ok((prep, secs(before, plan.rate)))
843    }
844
845    /// A shot's grade at `size`: the grader's (LUT, look, sharpen), its geometry as `pic`
846    /// (reframe, stabilizer, and on the main picture the NLE crop and transform), then its finish
847    /// with a transition's `mix` folded in.
848    pub(crate) fn shot_grade(
849        &self,
850        s: &Shot,
851        plan: &Plan,
852        size: Size,
853        mix: Option<&Mix>,
854        pic: Pic,
855    ) -> Result<Grade> {
856        let base = match self.grader {
857            Some(g) => g(s).map_err(|msg| ExecError::Grade {
858                shot: s.name.clone(),
859                msg,
860            })?,
861            None => Self::lut_grade(s)?,
862        };
863        let mut g = Grade {
864            size: Some(size),
865            denoise: s.filters.denoise,
866            ..base
867        };
868        let rate = plan.rate;
869        let out = if size == plan.profile.size {
870            plan.profile.aspect.clone()
871        } else {
872            ffcine_reframe::Profile {
873                width: size.width,
874                height: size.height,
875                safe_area: None,
876                ..plan.profile.aspect.clone()
877            }
878        };
879        self.geometry(plan, s, &mut g, &out, pic)?;
880        if let Some(h) = finish_hook(&s.finish, s.clip_at, s.frames, s.clip_frames, rate, mix) {
881            g.hooks.push(h);
882        }
883        Ok(g)
884    }
885
886    /// A video file's picture size.
887    pub(crate) fn dims(&self, path: &Path) -> Result<(u32, u32)> {
888        let info = self.backend.probe(path)?;
889        info.video()
890            .map(|(_, v)| (v.width, v.height))
891            .filter(|(w, h)| *w > 0 && *h > 0)
892            .ok_or_else(|| ExecError::Grade {
893                shot: path.display().to_string(),
894                msg: "no video stream".into(),
895            })
896    }
897
898    /// The LUT and sharpen of a shot's grade.
899    fn lut_grade(s: &Shot) -> Result<Grade> {
900        Ok(Grade {
901            lut: s
902                .lut
903                .as_ref()
904                .map(|p| {
905                    Lut::from_cube_file(p).map(|lut| LutStage {
906                        lut,
907                        kind: LutType::Normalized,
908                    })
909                })
910                .transpose()?,
911            #[allow(clippy::cast_possible_truncation)]
912            sharpen: s.grade.sharpen.unwrap_or(0.0) as f32,
913            ..Grade::default()
914        })
915    }
916
917    /// Reframe windows and the stabilizer warp of `s` into `grade`, rendering at `out`'s size;
918    /// on the main picture also its NLE crop and transform ([`ffcine_reframe::geo`]; an overlay's
919    /// transform places its layer instead). A panel's reframe `focus` sets its window.
920    ///
921    /// Windows come from the clip's reframe for the profile's aspect, evaluated at the piece's
922    /// frames of the whole clip (so punch-ins and pushes run across cuts); without a reframe they
923    /// only crop a source of another aspect to the output's. The stabilizer detects over the
924    /// source frames the clip's [`Shot::span`] covers (one cached detect for every piece, also
925    /// for retimed and ramped clips, whose output frames map to source frames through their
926    /// speed), smooths, and keeps this piece's warps.
927    #[allow(
928        clippy::many_single_char_names,
929        clippy::cast_possible_truncation,
930        clippy::cast_precision_loss,
931        clippy::cast_sign_loss
932    )]
933    fn geometry(
934        &self,
935        plan: &Plan,
936        s: &Shot,
937        grade: &mut Grade,
938        out: &ffcine_reframe::Profile,
939        pic: Pic,
940    ) -> Result<()> {
941        let stab = s.filters.stabilize.filter(|_| s.speed.abs() > 1e-9);
942        let no_geo = ffcine_reframe::Geo::default();
943        let geo = if pic == Pic::Main {
944            &s.filters.geo
945        } else {
946            &no_geo
947        };
948        let (w, h) = self.dims(&s.media)?;
949        if s.filters.reframe.is_none()
950            && stab.is_none()
951            && geo.is_empty()
952            && (f64::from(w) / f64::from(h) - out.aspect()).abs() < 0.01
953        {
954            return Ok(());
955        }
956        let mut reframe = s.filters.reframe.clone().unwrap_or_default();
957        if pic == Pic::Panel {
958            reframe = reframe.focused();
959        }
960        let shot = ffcine_reframe::Shot {
961            width: w,
962            height: h,
963            frames: s.clip_frames.max(s.clip_at + s.frames).max(1) as u64,
964            rate: plan.rate,
965            start: s.clip_start,
966        };
967        let at = |i: i64| (s.clip_at + i).max(0) as u64;
968        let mapped: Vec<_> = if geo.is_empty() {
969            let windows: Vec<_> = (0..s.frames)
970                .map(|i| reframe.frame(at(i), &shot, out))
971                .collect();
972            ffcine_reframe::pass::apply(grade, &windows, &[], (w, h), out);
973            Vec::new()
974        } else {
975            (0..s.frames)
976                .map(|i| geo.frame(&reframe, at(i), &shot, out).1)
977                .collect()
978        };
979        let Some(params) = stab else {
980            if !mapped.is_empty() {
981                ffcine_reframe::pass::apply_mapped(grade, &mapped, &[], (w, h), out);
982            }
983            return Ok(());
984        };
985        let detect = self.detect.ok_or_else(|| ExecError::Stabilize {
986            shot: s.name.clone(),
987            msg: "needs the GPU motion detect (NVOF)".into(),
988        })?;
989        // Source seconds since the clip's first frame for clip frame `k` (timeline frames).
990        let src = |k: i64| -> f64 {
991            let t = secs(k, plan.rate);
992            match &s.filters.ramp {
993                Some(r) => r.source_time(t),
994                None => t * s.speed,
995            }
996        };
997        let (lo, hi) = s.span;
998        let first = src(lo.max(0));
999        let last = src(hi) + secs(1, plan.rate);
1000        let range = SourceRange {
1001            start: (s.clip_start + first).max(0.0),
1002            frames: ((last - first) * plan.rate).ceil().max(1.0) as usize,
1003            rate: rational(plan.rate),
1004        };
1005        let det = detect(&s.media, &range).map_err(|msg| ExecError::Stabilize {
1006            shot: s.name.clone(),
1007            msg,
1008        })?;
1009        let st = ffcine_stabilize::stabilize(&det, &params);
1010        let n = st.transforms.len();
1011        let transforms = (0..s.frames)
1012            .filter_map(|i| {
1013                let k = ((src((s.clip_at + i).max(0)) - first) * plan.rate).round();
1014                st.transforms
1015                    .get((k.max(0.0) as usize).min(n.saturating_sub(1)))
1016                    .copied()
1017            })
1018            .collect();
1019        if mapped.is_empty() {
1020            grade.warp = Some(Warp { transforms });
1021        } else {
1022            ffcine_reframe::pass::apply_mapped(grade, &mapped, &transforms, (w, h), out);
1023        }
1024        Ok(())
1025    }
1026
1027    /// Mixes the audio (`ffcine_audio::stream_audio`, the picture's length) and encodes it to
1028    /// AAC. Returns the master's loudness and true peak.
1029    fn mix(&self, plan: &Plan, a: &AudioPlan, out: &Path) -> Result<(f64, f64)> {
1030        let tmp = self.dir("tmp")?;
1031        let mut options = a.options.clone();
1032        options.scratch = Some(tmp.clone());
1033        let mut mix = ffcine_audio::stream_audio(&a.timeline, &options, false)?;
1034        let wav = tmp.join(format!("{}.wav", a.key));
1035        mix.write_master(&wav)?;
1036        let mut v = vec![OsString::from("-i"), wav.clone().into()];
1037        v.extend(args(&[
1038            "-c:a",
1039            "aac",
1040            "-b:a",
1041            &plan.profile.audio_bitrate.to_string(),
1042        ]));
1043        v.push(out.into());
1044        let r = self.ffmpeg("audio encode", &v);
1045        let _ = std::fs::remove_file(wav);
1046        r.map(|()| (mix.lufs, mix.true_peak))
1047    }
1048
1049    /// The segments joined by stream copy (concat demuxer) plus the audio, to `output`
1050    /// (atomic): one pass over the picture.
1051    fn mux(
1052        &self,
1053        plan: &Plan,
1054        files: &[PathBuf],
1055        audio: Option<&Path>,
1056        output: &Path,
1057    ) -> Result<()> {
1058        let ext = output.extension().and_then(|e| e.to_str()).unwrap_or("mp4");
1059        let partial = output.with_extension(format!("partial.{ext}"));
1060        let list = output.with_extension("partial.txt");
1061        let body = files.iter().fold(String::new(), |mut s, f| {
1062            let _ = writeln!(
1063                s,
1064                "file '{}'",
1065                f.display().to_string().replace('\'', r"'\''")
1066            );
1067            s
1068        });
1069        std::fs::write(&list, body).map_err(io("writing concat list"))?;
1070        let mut v = args(&["-f", "concat", "-safe", "0", "-i"]).collect::<Vec<_>>();
1071        v.push(list.clone().into());
1072        if let Some(a) = audio {
1073            v.push("-i".into());
1074            v.push(a.into());
1075        }
1076        v.extend(args(&["-map", "0:v", "-c:v", "copy"]));
1077        if matches!(
1078            plan.profile.encoder.codec,
1079            Codec::HevcNvenc | Codec::HevcVulkan | Codec::HevcVaapi | Codec::Libx265
1080        ) {
1081            v.extend(args(&["-tag:v", "hvc1"]));
1082        }
1083        if audio.is_some() {
1084            v.extend(args(&["-map", "1:a", "-c:a", "copy"]));
1085        }
1086        v.extend(args(&["-movflags", "+faststart"]));
1087        v.push(partial.clone().into());
1088        let r = self.ffmpeg("mux", &v);
1089        let _ = std::fs::remove_file(list);
1090        r?;
1091        std::fs::rename(&partial, output).map_err(io(format!("writing {}", output.display())))
1092    }
1093}
1094
1095/// A transition's contribution to a side's finish.
1096pub(crate) enum Mix {
1097    /// Per-frame fade towards the colour (1 = the picture).
1098    Fade(Vec<f32>, [f64; 3]),
1099    /// Per-frame lift towards white.
1100    Lift(Vec<f32>),
1101    /// A split-screen's own `(fade, lift)` tracks and fade colour.
1102    Both((Vec<f32>, Vec<f32>), [f64; 3]),
1103}
1104
1105/// Which picture a shot's grade is for: what its geometry takes ([`Renderer::geometry`]).
1106#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1107pub(crate) enum Pic {
1108    /// The main picture (or a transition's side): reframe, stabilizer, NLE crop and transform.
1109    Main,
1110    /// A split-screen panel: reframe (its `focus`) and stabilizer, in the panel.
1111    Panel,
1112    /// Picture-in-picture: reframe and stabilizer in the layer; its transform places the layer.
1113    Overlay,
1114}
1115
1116/// No grade but the output size (gaps, backdrops).
1117pub(crate) fn identity(size: Size) -> Grade {
1118    Grade {
1119        size: Some(size),
1120        ..Grade::default()
1121    }
1122}
1123
1124/// [`shaders::alpha`] with per-frame `opacity` and a fixed corner `radius` in layer pixels.
1125pub(crate) fn alpha_hook(opacity: Vec<f32>, radius: f64) -> Hook {
1126    Hook {
1127        name: "alpha".into(),
1128        source: shaders::alpha().into(),
1129        params: vec![("radius".into(), radius)],
1130        tracks: vec![ParamTrack {
1131            name: "opacity".into(),
1132            values: opacity,
1133        }],
1134    }
1135}
1136
1137/// [`shaders::finish`] for frames `at..at + n` of a clip `len` frames long, with a transition's
1138/// `mix` folded in; `None` when it would do nothing.
1139#[allow(clippy::many_single_char_names)]
1140pub(crate) fn finish_hook(
1141    f: &Finish,
1142    at: i64,
1143    n: i64,
1144    len: i64,
1145    rate: f64,
1146    mix: Option<&Mix>,
1147) -> Option<Hook> {
1148    if f.is_identity() && mix.is_none() {
1149        return None;
1150    }
1151    let (mut fade, mut lift) = f.tracks(at, n, len, rate);
1152    let mut colour = f.fade_colour;
1153    let mut fold = |k: &[f32], l: &[f32], c: Option<[f64; 3]>| {
1154        for (i, v) in k.iter().enumerate() {
1155            if let Some(x) = fade.get_mut(i) {
1156                *x = x.min(*v);
1157            }
1158        }
1159        for (i, v) in l.iter().enumerate() {
1160            if let Some(x) = lift.get_mut(i) {
1161                *x = x.max(*v);
1162            }
1163        }
1164        if let Some(c) = c {
1165            colour = c;
1166        }
1167    };
1168    match mix {
1169        Some(Mix::Fade(k, c)) => fold(k, &[], Some(*c)),
1170        Some(Mix::Both((k, l), c)) if k.iter().any(|v| *v < 1.0) => fold(k, l, Some(*c)),
1171        Some(Mix::Lift(l) | Mix::Both((_, l), _)) => fold(&[], l, None),
1172        None => {}
1173    }
1174    Some(Hook {
1175        name: "finish".into(),
1176        source: shaders::finish().into(),
1177        params: vec![
1178            ("darken".into(), f.darken),
1179            ("vignette".into(), f.vignette_angle()),
1180            ("fade_r".into(), colour[0]),
1181            ("fade_g".into(), colour[1]),
1182            ("fade_b".into(), colour[2]),
1183        ],
1184        tracks: vec![
1185            ParamTrack {
1186                name: "fade".into(),
1187                values: fade,
1188            },
1189            ParamTrack {
1190                name: "lift".into(),
1191                values: lift,
1192            },
1193        ],
1194    })
1195}
1196
1197fn args<'a>(a: &'a [&'a str]) -> impl Iterator<Item = OsString> + 'a {
1198    a.iter().map(OsString::from)
1199}
1200
1201/// `<output>.ffcine.json`: keys of what the master was made from.
1202pub fn manifest_path(output: &Path) -> PathBuf {
1203    let mut s = output.as_os_str().to_owned();
1204    s.push(".ffcine.json");
1205    PathBuf::from(s)
1206}
1207
1208fn read_manifest_key(path: &Path) -> Option<Key> {
1209    let v: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(path).ok()?).ok()?;
1210    Some(Key(v.get("master")?.as_str()?.to_owned()))
1211}
1212
1213/// The all-intra prep's format, in its cache key ([`Renderer::intra_preps`]).
1214const INTRA_PREP: &str = "ffv1-l3-g1";
1215
1216/// ffmpeg arguments for an all-intra lossless (FFV1) copy of `frames` frames of `input` from
1217/// `at` s into `out`. The pixels stay as stored (`-noautorotate`: no transpose on the CPU) and the
1218/// display matrix, colour tags, bit depth and HDR metadata are carried over, so the decoder
1219/// turns and tags the prep as it does the source.
1220fn intra_prep_args(input: &Path, at: f64, frames: i64, out: &Path) -> Vec<OsString> {
1221    let mut v: Vec<OsString> = args(&["-ss", &at.to_string(), "-noautorotate", "-i"]).collect();
1222    v.push(input.into());
1223    v.extend(args(&[
1224        "-map",
1225        "0:v:0",
1226        "-frames:v",
1227        &frames.to_string(),
1228        "-fps_mode",
1229        "passthrough",
1230        // FFV1: lossless in any pixel format, every frame a keyframe.
1231        "-c:v",
1232        "ffv1",
1233        "-level",
1234        "3",
1235        "-g",
1236        "1",
1237        "-slices",
1238        "16",
1239        "-an",
1240    ]));
1241    v.push(out.into());
1242    v
1243}
1244
1245#[cfg(test)]
1246mod tests {
1247    use super::*;
1248    use crate::plan::Ramp;
1249
1250    fn ramp() -> Ramp {
1251        Ramp {
1252            points: vec![(0.0, 1.0), (0.5, 3.0), (1.0, 1.0)],
1253            source_duration: 8.0,
1254        }
1255    }
1256
1257    #[test]
1258    fn ramp_pieces_seek_to_their_own_source() {
1259        let rate = 24000.0 / 1001.0;
1260        let r = ramp();
1261        let first = ramp_cut(&r, 2.5, 0, 30, rate);
1262        assert!(first.seek.abs() < 1e-12);
1263        assert!((first.duration - (8.0 + 2.0 / rate)).abs() < 1e-9);
1264        assert!(first.filter.contains("(PTS*TB-2.5)"));
1265        assert!(
1266            first
1267                .filter
1268                .ends_with("fps=24000/1001,trim=start_pts=0:end_pts=30,setpts=PTS-STARTPTS")
1269        );
1270        let late = ramp_cut(&r, 2.5, 70, 30, rate);
1271        let t = r.source_time(70.0 / rate);
1272        assert!(late.seek > 1.0 && late.seek < t && t - late.seek <= 0.5 + 1e-9);
1273        assert!((late.duration - (8.0 - late.seek + 2.0 / rate)).abs() < 1e-9);
1274        assert!(late.filter.contains("trim=start_pts=70:end_pts=100,"));
1275    }
1276
1277    fn ffmpeg(args: &[&str]) -> Vec<u8> {
1278        let out = Command::new("ffmpeg")
1279            .args(["-v", "error", "-y"])
1280            .args(args)
1281            .output()
1282            .expect("ffmpeg on PATH");
1283        assert!(
1284            out.status.success(),
1285            "{}",
1286            String::from_utf8_lossy(&out.stderr)
1287        );
1288        out.stdout
1289    }
1290
1291    /// Per-frame hashes of a ramped piece, the way `input` cuts it (on the CPU).
1292    fn ramp_piece(src: &Path, from: i64, n: i64) -> Vec<String> {
1293        let rate = 24000.0 / 1001.0;
1294        let clip_start = 1.0;
1295        let c = ramp_cut(&ramp(), clip_start, from, n, rate);
1296        let out = ffmpeg(&[
1297            "-copyts",
1298            "-start_at_zero",
1299            "-ss",
1300            &(clip_start + c.seek).to_string(),
1301            "-t",
1302            &c.duration.to_string(),
1303            "-i",
1304            src.to_str().expect("utf-8"),
1305            "-vf",
1306            &c.filter,
1307            "-frames:v",
1308            &n.to_string(),
1309            "-f",
1310            "framemd5",
1311            "-",
1312        ]);
1313        String::from_utf8_lossy(&out)
1314            .lines()
1315            .filter(|l| !l.starts_with('#'))
1316            .map(|l| l.rsplit(',').next().unwrap_or("").trim().to_owned())
1317            .collect()
1318    }
1319
1320    #[test]
1321    #[ignore = "needs ffmpeg"]
1322    fn ramp_pieces_match_the_whole_clip() {
1323        let d = std::env::temp_dir().join(format!("ffcine-ramp-{}", std::process::id()));
1324        std::fs::create_dir_all(&d).expect("temp dir");
1325        let src = d.join("src.mp4");
1326        ffmpeg(&[
1327            "-f",
1328            "lavfi",
1329            "-i",
1330            "testsrc2=size=160x90:rate=30000/1001",
1331            "-t",
1332            "10",
1333            "-g",
1334            "15",
1335            "-c:v",
1336            "libx264",
1337            "-qp",
1338            "0",
1339            src.to_str().expect("utf-8"),
1340        ]);
1341        let whole = ramp_piece(&src, 0, 100);
1342        assert_eq!(whole.len(), 100);
1343        let distinct: std::collections::HashSet<_> = whole.iter().collect();
1344        assert!(distinct.len() > 90, "the source's frames all differ");
1345        for (from, n) in [(0, 17), (17, 40), (57, 3), (60, 40)] {
1346            let piece = ramp_piece(&src, from, n);
1347            let (a, b) = (
1348                usize::try_from(from).unwrap(),
1349                usize::try_from(from + n).unwrap(),
1350            );
1351            assert_eq!(piece, whole[a..b], "piece {from}+{n}");
1352        }
1353        let _ = std::fs::remove_dir_all(&d);
1354    }
1355
1356    /// `ffprobe` of `p`'s first video stream: size, pixel format, colour tags, rotation.
1357    fn probe(p: &Path) -> String {
1358        let out = Command::new("ffprobe")
1359            .args(["-v", "error", "-select_streams", "v:0", "-show_entries"])
1360            .args([
1361                "stream=width,height,pix_fmt,color_range,color_space,color_transfer,\
1362                 color_primaries:stream_side_data=rotation,max_content,max_luminance",
1363                "-of",
1364                "compact",
1365            ])
1366            .arg(p)
1367            .output()
1368            .expect("ffprobe on PATH");
1369        String::from_utf8_lossy(&out.stdout).trim().to_owned()
1370    }
1371
1372    #[test]
1373    #[ignore = "needs ffmpeg"]
1374    fn intra_prep_keeps_pixels_rotation_and_colour() {
1375        let d = std::env::temp_dir().join(format!("ffcine-intra-{}", std::process::id()));
1376        std::fs::create_dir_all(&d).expect("temp dir");
1377        let (hdr, src, prep) = (d.join("hdr.mkv"), d.join("rot.mkv"), d.join("prep.mkv"));
1378        let s = |p: &Path| p.to_str().expect("utf-8").to_owned();
1379        // A 10-bit PQ / BT.2020 source with HDR10 metadata and a 90° display matrix.
1380        ffmpeg(&[
1381            "-f",
1382            "lavfi",
1383            "-i",
1384            "testsrc2=s=320x180:r=30:d=1",
1385            "-vf",
1386            "format=yuv420p10le,setparams=color_primaries=bt2020:color_trc=smpte2084:\
1387             colorspace=bt2020nc:range=tv",
1388            "-c:v",
1389            "libx265",
1390            "-x265-params",
1391            "log-level=none:keyint=15:hdr10=1:max-cll=1000,400:\
1392             master-display=G(13250,34500)B(7500,3000)R(34000,16000)WP(15635,16450)L(10000000,1)",
1393            &s(&hdr),
1394        ]);
1395        ffmpeg(&[
1396            "-display_rotation",
1397            "90",
1398            "-i",
1399            &s(&hdr),
1400            "-c",
1401            "copy",
1402            &s(&src),
1403        ]);
1404        let at = 10.0 / 30.0;
1405        let mut argv = vec![OsString::from("-v"), "error".into(), "-y".into()];
1406        argv.extend(intra_prep_args(&src, at, 12, &prep));
1407        let st = Command::new("ffmpeg").args(&argv).status().expect("ffmpeg");
1408        assert!(st.success());
1409        let (was, got) = (probe(&src), probe(&prep));
1410        for want in [
1411            "width=320",
1412            "height=180",
1413            "pix_fmt=yuv420p10le",
1414            "color_range=tv",
1415            "color_space=bt2020nc",
1416            "color_transfer=smpte2084",
1417            "color_primaries=bt2020",
1418            "rotation=90",
1419        ] {
1420            assert!(was.contains(want), "source {want}: {was}");
1421            assert!(got.contains(want), "prep {want}: {got}");
1422        }
1423        // HEVC carries HDR10 metadata per frame (SEI); the prep has it on the stream.
1424        assert!(
1425            got.contains("max_content=1000"),
1426            "prep HDR10 metadata: {got}"
1427        );
1428        // The stored (unrotated) pixels of frames 10..22, bit for bit.
1429        let md5 = |p: &Path, ss: Option<f64>, n: &str| -> Vec<String> {
1430            let mut argv: Vec<String> = Vec::new();
1431            if let Some(secs) = ss {
1432                argv.extend(["-ss".into(), secs.to_string()]);
1433            }
1434            argv.extend(["-noautorotate", "-i"].map(String::from));
1435            argv.push(s(p));
1436            argv.extend(["-frames:v", n, "-f", "framemd5", "-"].map(String::from));
1437            let refs: Vec<&str> = argv.iter().map(String::as_str).collect();
1438            String::from_utf8_lossy(&ffmpeg(&refs))
1439                .lines()
1440                .filter(|l| !l.starts_with('#'))
1441                .map(|l| l.rsplit(',').next().unwrap_or("").trim().to_owned())
1442                .collect()
1443        };
1444        let want = md5(&src, Some(at), "12");
1445        assert_eq!(want.len(), 12);
1446        assert_eq!(md5(&prep, None, "12"), want);
1447        let _ = std::fs::remove_dir_all(&d);
1448    }
1449
1450    #[test]
1451    fn blend_chain() {
1452        assert_eq!(
1453            blend_filter(3, 2, "24000/1001"),
1454            "settb=1/(24000/1001),setpts=N,hwupload,split=3[b0][b1][b2];\
1455             [b1]trim=start_frame=1,setpts=PTS-STARTPTS[t1];\
1456             [b2]trim=start_frame=2,setpts=PTS-STARTPTS[t2];\
1457             [t1][b0]blend_vulkan=all_mode=normal:all_opacity=0.5000000[m1];\
1458             [t2][m1]blend_vulkan=all_mode=normal:all_opacity=0.3333333,\
1459             hwupload=derive_device=cuda"
1460        );
1461        // Near the source's start the first frame stands in for the missing history.
1462        let f = blend_filter(2, 0, "24");
1463        assert!(f.starts_with("tpad=start=1:start_mode=clone,settb=1/(24),"));
1464        assert!(f.contains("[t1][b0]blend_vulkan=all_mode=normal:all_opacity=0.5000000,hwupload"));
1465    }
1466
1467    #[test]
1468    fn rates() {
1469        assert_eq!(
1470            rational(24000.0 / 1001.0),
1471            Rational {
1472                num: 24000,
1473                den: 1001
1474            }
1475        );
1476        assert_eq!(
1477            rational(23.976_023_976_023_978),
1478            Rational {
1479                num: 24000,
1480                den: 1001
1481            }
1482        );
1483        assert_eq!(rational(25.0), Rational { num: 25, den: 1 });
1484        assert_eq!(
1485            rational(60000.0 / 1001.0),
1486            Rational {
1487                num: 60000,
1488                den: 1001
1489            }
1490        );
1491    }
1492}