Skip to main content

ffcine_comp/
lower.rs

1//! Lowering (`docs/compositing.md#rendering`): a piece of a comp becomes a base input and grade
2//! plus GPU [`Layer`]s, every track sampled per frame on the CPU, and a key recipe of those
3//! sampled values.
4
5use std::fmt::Write as _;
6use std::ops::Range;
7use std::path::{Path, PathBuf};
8use std::sync::Arc;
9use std::time::Duration;
10
11use ffcine_core::anim::{Animatable, Clock, Env, Track};
12use ffcine_core::camera::{self, CameraLut, Profile, ProfileChoice};
13use ffcine_core::{Lut, LutType, Result};
14use ffcine_hooks::ShaderEffect;
15use ffcine_hooks::grade::{Luts, grade_for, look_params};
16use ffcine_render::extra::{Filter, filter_hook, strict_hooks};
17use ffcine_render::filter_node::{Clock as FClock, FILTER_TABLE_REVISION, Node, Prep, Reach};
18use ffcine_render::{
19    BlendMode, Crop, Deband, Grade, Hook, Image, Layer, LayerSource, LutStage, Overlay, ParamTrack,
20    Scene, Size, shaders,
21};
22use ffcine_text::{TextClip, TextLayer};
23use ffcine_timeline::{ClipGrade, SourceKind};
24use serde_json::{Value, json};
25use sha2::{Digest, Sha256};
26
27use crate::model::{
28    Comp, CompLayer, CropSpec, FILTER_HINT, FilterOpt, FilterSpec, Fit, LayerGrade, MaskShape,
29    Scale, Source, err, limits,
30};
31use crate::time;
32
33/// [`FILTER_TABLE_REVISION`], which tests can bump.
34fn table_revision() -> u32 {
35    #[cfg(test)]
36    return TABLE.get();
37    #[cfg(not(test))]
38    FILTER_TABLE_REVISION
39}
40
41#[cfg(test)]
42thread_local! {
43    pub(crate) static TABLE: std::cell::Cell<u32> =
44        const { std::cell::Cell::new(FILTER_TABLE_REVISION) };
45}
46
47/// What lowering needs to know about media files.
48pub trait Media {
49    /// A video's or still's display size, px (rotation and HEIF grids applied).
50    fn dims(&self, path: &Path) -> Result<(u32, u32)>;
51    /// The file's identity in keys (a fingerprint: path, size, modification time).
52    fn identity(&self, path: &Path) -> Value;
53    /// A video's or still's camera profile for `camera: auto` ([`Profile::detect_file`]).
54    fn profile(&self, _path: &Path) -> Result<Profile> {
55        Ok(Profile::Rec709)
56    }
57    /// A video's average frame rate and frame count, for time remapping.
58    fn video_rate(&self, path: &Path) -> Result<(f64, u64)> {
59        Err(err(
60            path.display().to_string(),
61            "the frame rate is unknown (needed for a time remap)",
62        ))
63    }
64    /// A small file's contents in keys (a filter node's LUTs, fonts, text files): by default its
65    /// [`identity`](Self::identity).
66    fn content_hash(&self, path: &Path) -> Value {
67        self.identity(path)
68    }
69    /// The ffmpeg build that runs filter nodes (in their keys).
70    fn ffmpeg_build(&self) -> String {
71        String::from("unknown")
72    }
73    /// `profile`'s camera LUT (`None` for display-referred profiles).
74    fn camera_lut(&self, profile: Profile) -> Result<Option<CameraLut>> {
75        profile.camera_lut(&camera::lut_dirs())
76    }
77}
78
79/// The pass's input.
80#[derive(Debug, Clone, PartialEq)]
81pub enum Base {
82    /// No promoted layer: a black clip, replaced by the canvas background hook.
83    Black,
84    /// The bottom layer promoted to the pass input (`docs/compositing.md#rendering`, step 2).
85    Video { input: PathBuf, start: Duration },
86}
87
88/// A lowered piece: comp frames `from..from + frames`.
89#[derive(Debug, Clone, PartialEq)]
90pub struct Piece {
91    pub from: i64,
92    pub frames: i64,
93    pub base: Base,
94    /// The pass's grade (size: the render size).
95    pub grade: Grade,
96    /// Bottom first, frame ranges relative to `from`.
97    pub layers: Vec<Layer>,
98    /// Indices of the comp layers in `layers` (the promoted one excluded).
99    pub shown: Vec<usize>,
100    /// Text layers' masks, frame ranges relative to `from`, each under the layer it is below.
101    pub overlays: Vec<Overlay>,
102    /// Canonical JSON of what the piece renders: sampled values, not track definitions.
103    pub recipe: Value,
104}
105
106/// The first frame at or after `t` s.
107#[allow(clippy::cast_possible_truncation)]
108fn frame_at(t: f64, fps: f64) -> i64 {
109    (t * fps - 1e-6).ceil() as i64
110}
111
112/// A frame map in a key: its first frame and a digest of all of them.
113fn map_digest(picks: &[i64]) -> Value {
114    let mut h = Sha256::new();
115    for k in picks {
116        h.update(k.to_le_bytes());
117    }
118    let d = h.finalize();
119    json!({
120        "first": picks.first(),
121        "frames": picks.len(),
122        "digest": d[..16].iter().fold(String::new(), |mut s, b| {
123            let _ = write!(s, "{b:02x}");
124            s
125        }),
126    })
127}
128
129/// `values` in a key: the value if they're all equal, else a digest of their bits.
130fn digest(values: &[f32]) -> Value {
131    match values.first() {
132        None => Value::Null,
133        Some(v) if values.iter().all(|x| x.to_bits() == v.to_bits()) => json!(v),
134        Some(_) => {
135            let mut h = Sha256::new();
136            for v in values {
137                h.update(v.to_bits().to_le_bytes());
138            }
139            let d = h.finalize();
140            Value::String(d[..16].iter().fold(String::new(), |mut s, b| {
141                let _ = write!(s, "{b:02x}");
142                s
143            }))
144        }
145    }
146}
147
148/// A hook param: a constant (`params`) or a per-frame track.
149fn param(h: &mut Hook, name: &str, values: Vec<f32>) {
150    match values.first() {
151        Some(v) if values.iter().all(|x| x.to_bits() == v.to_bits()) => {
152            h.params.push((name.into(), f64::from(*v)));
153        }
154        Some(_) => h.tracks.push(ParamTrack {
155            name: name.into(),
156            values,
157        }),
158        None => {}
159    }
160}
161
162fn hook(name: &str, source: &str) -> Hook {
163    Hook {
164        name: name.into(),
165        source: source.into(),
166        params: Vec::new(),
167        tracks: Vec::new(),
168    }
169}
170
171#[allow(clippy::cast_possible_truncation)]
172fn f32s(v: impl IntoIterator<Item = f64>) -> Vec<f32> {
173    v.into_iter().map(|x| x as f32).collect()
174}
175
176/// [`shaders::finish`] as a fade towards a colour, `None` when it never fades.
177fn fade_hook(name: &str, fade: &[f64], colour: &[[f64; 4]]) -> Option<Hook> {
178    if fade.iter().all(|f| *f >= 1.0) {
179        return None;
180    }
181    let mut h = hook(name, shaders::finish());
182    param(&mut h, "fade", f32s(fade.iter().map(|f| f.clamp(0.0, 1.0))));
183    for (i, c) in ["fade_r", "fade_g", "fade_b"].into_iter().enumerate() {
184        param(&mut h, c, f32s(colour.iter().map(|v| v[i])));
185    }
186    Some(h)
187}
188
189fn grade_recipe(g: &Grade) -> Value {
190    let mut v = grade_json(g);
191    // Only when set, so step 2's keys stay as they were.
192    if !g.crops.is_empty() {
193        v["crops"] = digest(
194            &g.crops
195                .iter()
196                .flat_map(|c| [c.x, c.y, c.width, c.height])
197                .collect::<Vec<_>>(),
198        );
199    }
200    v
201}
202
203fn grade_json(g: &Grade) -> Value {
204    json!({
205        "size": g.size.map(|s| [s.width, s.height]),
206        "crop": g.crop.map(|c| [c.x, c.y, c.width, c.height]),
207        "hooks": g.hooks.iter().map(|h| json!({
208            "name": h.name,
209            "params": h.params,
210            "tracks": h.tracks.iter().map(|t| json!([t.name, digest(&t.values)])).collect::<Vec<_>>(),
211        })).collect::<Vec<_>>(),
212    })
213}
214
215/// A digest of `bytes`, hex.
216fn sha(bytes: impl IntoIterator<Item = impl AsRef<[u8]>>) -> String {
217    let mut h = Sha256::new();
218    for b in bytes {
219        h.update(b.as_ref());
220    }
221    h.finalize()[..16].iter().fold(String::new(), |mut s, b| {
222        let _ = write!(s, "{b:02x}");
223        s
224    })
225}
226
227/// A LUT in keys: its contents, not its file's identity.
228fn lut_digest(l: &Lut) -> String {
229    let head = [
230        u32::try_from(l.size)
231            .unwrap_or(u32::MAX)
232            .to_le_bytes()
233            .to_vec(),
234        l.domain_min
235            .iter()
236            .chain(&l.domain_max)
237            .flat_map(|v| v.to_bits().to_le_bytes())
238            .collect(),
239    ];
240    sha(head.into_iter().chain(
241        l.data
242            .iter()
243            .map(|p| p.iter().flat_map(|v| v.to_bits().to_le_bytes()).collect()),
244    ))
245}
246
247/// A layer's resolved grade (without hooks) and its hooks, plus what keys need beyond
248/// [`grade_json`].
249struct Graded {
250    grade: Grade,
251    hooks: Vec<Hook>,
252    recipe: Value,
253}
254
255/// A solid layer's picture: one white pixel, coloured by [`shaders::solid`].
256fn solid_image() -> LayerSource {
257    LayerSource::Image(Arc::new(Image {
258        width: 1,
259        height: 1,
260        data: vec![255; 4],
261    }))
262}
263
264/// A layer's source windows (`content`, `crop`).
265struct Framing {
266    crop: Option<Crop>,
267    /// Per visible frame, when they move (else empty and `crop` holds).
268    crops: Vec<Crop>,
269    /// The picture in box px.
270    pic: [f64; 2],
271    /// The source after `crop` (its first frame), px: `sw`, `sh` in expressions.
272    cropped: [f64; 2],
273    /// `crop.feather` per visible frame, box px.
274    feather: Vec<f64>,
275}
276
277/// A `w x h` window in `r` (`[x, y, w, h]`), placed by `pos` (0..1) in the slack.
278fn place(r: [f64; 4], w: f64, h: f64, [px, py]: [f64; 2]) -> [f64; 4] {
279    [
280        r[0] + (r[2] - w) * px.clamp(0.0, 1.0),
281        r[1] + (r[3] - h) * py.clamp(0.0, 1.0),
282        w,
283        h,
284    ]
285}
286
287/// `r` zoomed in by `zoom` (below 1 counts as 1, so the window stays in the picture) about
288/// `pos`, as reframe's.
289fn zoomed(r: [f64; 4], zoom: f64, pos: [f64; 2]) -> [f64; 4] {
290    let z = if zoom.is_finite() { zoom.max(1.0) } else { 1.0 };
291    place(r, r[2] / z, r[3] / z, pos)
292}
293
294/// A sampled layer over its visible frames.
295struct Sampled {
296    rects: Vec<Crop>,
297    rotation: Vec<f64>,
298    opacity: Vec<f64>,
299}
300
301impl Comp {
302    /// Frames in the comp.
303    pub fn frames(&self) -> i64 {
304        frame_at(self.canvas.duration, self.canvas.fps).max(1)
305    }
306
307    /// Comp frames where `layer` shows.
308    pub fn span(&self, layer: &CompLayer) -> Range<i64> {
309        let fps = self.canvas.fps;
310        let n = self.frames();
311        frame_at(layer.in_, fps).clamp(0, n)..frame_at(layer.out, fps).clamp(0, n)
312    }
313
314    /// Pieces of the comp, cut at layer in/out points, none shorter than a second (unless the
315    /// comp is).
316    pub fn pieces(&self) -> Vec<Range<i64>> {
317        let n = self.frames();
318        #[allow(clippy::cast_possible_truncation)]
319        let min = (self.canvas.fps.round() as i64).max(1);
320        let mut cuts: Vec<i64> = self
321            .layers
322            .iter()
323            .flat_map(|l| {
324                let s = self.span(l);
325                [s.start, s.end]
326            })
327            .filter(|c| *c > 0 && *c < n)
328            .collect();
329        cuts.sort_unstable();
330        cuts.dedup();
331        let mut out = Vec::new();
332        let mut start = 0;
333        for c in cuts {
334            if c - start >= min && n - c >= min {
335                out.push(start..c);
336                start = c;
337            }
338        }
339        out.push(start..n);
340        out
341    }
342
343    fn env<'a>(&self, l: &'a CompLayer, source: [f64; 2]) -> Env<'a> {
344        Env {
345            fps: self.canvas.fps,
346            canvas: self.canvas.size.map(f64::from),
347            size: l.size,
348            source,
349            in_: l.in_,
350            out: l.out,
351            total: self.canvas.duration,
352            source_in: l.source_in,
353            vars: Some(&l.vars),
354        }
355    }
356
357    /// The source windows of a layer with `content` or `crop` over its visible frames `vis`.
358    #[allow(
359        clippy::cast_precision_loss,
360        clippy::cast_possible_wrap,
361        clippy::float_cmp,
362        clippy::many_single_char_names,
363        clippy::too_many_lines
364    )]
365    fn framing(
366        &self,
367        l: &CompLayer,
368        [sw, sh]: [f64; 2],
369        span: &Range<i64>,
370        vis: Range<i64>,
371        env: &Env,
372        path: &str,
373    ) -> Result<Framing> {
374        let at = |name: &str| format!("{path}.{name}");
375        let n = usize::try_from(vis.end - vis.start).unwrap_or(0);
376        // The cropped source, per frame.
377        let regions: Vec<[f64; 4]> = match &l.crop {
378            None => vec![[0.0, 0.0, sw, sh]; n],
379            Some(CropSpec {
380                left,
381                right,
382                top,
383                bottom,
384                ..
385            }) => {
386                let side = |t: &Track<f64>, name: &str| -> Result<Vec<f64>> {
387                    let v = sample(t, &at(&format!("crop.{name}")), vis.clone(), env)?;
388                    match v.iter().find(|f| !(0.0..1.0).contains(*f)) {
389                        Some(f) => Err(err(
390                            at(&format!("crop.{name}")),
391                            format!("{f}: expected a fraction 0 to 1"),
392                        )),
393                        None => Ok(v),
394                    }
395                };
396                let (l_, r) = (side(left, "left")?, side(right, "right")?);
397                let (t, b) = (side(top, "top")?, side(bottom, "bottom")?);
398                (0..n)
399                    .map(|j| {
400                        if l_[j] + r[j] >= 1.0 || t[j] + b[j] >= 1.0 {
401                            return Err(err(
402                                at("crop"),
403                                "left + right and top + bottom must each be under 1",
404                            ));
405                        }
406                        Ok([
407                            l_[j] * sw,
408                            t[j] * sh,
409                            (1.0 - l_[j] - r[j]) * sw,
410                            (1.0 - t[j] - b[j]) * sh,
411                        ])
412                    })
413                    .collect::<Result<_>>()?
414            }
415        };
416        let first = regions.first().copied().unwrap_or([0.0, 0.0, sw, sh]);
417        let cropped = [first[2], first[3]];
418        let [bw, bh] = l.size;
419        let pic = match l.fit {
420            Fit::Cover | Fit::Fill => l.size,
421            Fit::Contain => {
422                let s = (bw / cropped[0]).min(bh / cropped[1]);
423                [cropped[0] * s, cropped[1] * s]
424            }
425            Fit::None => [cropped[0].min(bw), cropped[1].min(bh)],
426        };
427        let half = Track::Const(0.5);
428        let one = Track::Const(1.0);
429        let centre = Track::Const([0.5, 0.5]);
430        let (zoom, zc, cx, cy) = match &l.content {
431            Some(c) => (&c.zoom, &c.zoom_center, &c.crop_x, &c.crop_y),
432            None => (&one, &centre, &half, &half),
433        };
434        let zoom = sample(zoom, &at("content.zoom"), vis.clone(), env)?;
435        if let Some(z) = zoom
436            .iter()
437            .find(|z| !(z.is_finite() && **z > 0.0 && **z <= limits::ZOOM))
438        {
439            return Err(err(
440                at("content.zoom"),
441                format!("{z}: expected a zoom > 0, at most {}", limits::ZOOM),
442            ));
443        }
444        let zc = sample(zc, &at("content.zoom_center"), vis.clone(), env)?;
445        let cx = sample(cx, &at("content.crop_x"), vis.clone(), env)?;
446        let cy = sample(cy, &at("content.crop_y"), vis.clone(), env)?;
447        finite(&at("content"), zc.iter().flatten().chain(&cx).chain(&cy))?;
448        let fps = self.canvas.fps;
449        let life = (span.end - span.start - 1).max(1) as f64;
450        let windows: Vec<[f64; 4]> = (0..n)
451            .map(|j| {
452                let r = regions[j];
453                let base = match l.fit {
454                    Fit::Cover => {
455                        let ar = bw / bh;
456                        let (w, h) = if r[2] / r[3] > ar {
457                            (r[3] * ar, r[3])
458                        } else {
459                            (r[2], r[2] / ar)
460                        };
461                        place(r, w, h, [cx[j], cy[j]])
462                    }
463                    Fit::Fill | Fit::Contain => r,
464                    Fit::None => place(r, r[2].min(bw), r[3].min(bh), [cx[j], cy[j]]),
465                };
466                // Layer frame `i`: punches and the push run on the layer's clock.
467                let i = (vis.start + j as i64 - span.start) as f64;
468                let punch = l
469                    .content
470                    .iter()
471                    .flat_map(|c| c.punch.iter())
472                    .rev()
473                    .find(|p| {
474                        let to = p.to.map_or(f64::INFINITY, |t| (t * fps).round());
475                        ((p.at * fps).round()..to).contains(&i)
476                    });
477                let (z, c) = punch.map_or((zoom[j], zc[j]), |p| {
478                    (p.zoom * zoom[j], p.center.unwrap_or(zc[j]))
479                });
480                let w = zoomed(base, z, c);
481                match l.content.as_ref().and_then(|c| c.push.map(|p| (c, p))) {
482                    Some((c, [z0, z1])) => {
483                        let u = (i / life).clamp(0.0, 1.0);
484                        let e = 0.5 * u + 0.5 * (0.5 - 0.5 * (std::f64::consts::PI * u).cos());
485                        let centre = match c.pan {
486                            Some([a, b]) => [a[0] + (b[0] - a[0]) * e, a[1] + (b[1] - a[1]) * e],
487                            None => c.push_center.unwrap_or(zc[j]),
488                        };
489                        zoomed(w, z0 + (z1 - z0) * e, centre)
490                    }
491                    None => w,
492                }
493            })
494            .collect();
495        // Zooms and crops multiply: keep the window a usable size.
496        if windows
497            .iter()
498            .any(|w| !(w[2] >= sw / limits::ZOOM && w[3] >= sh / limits::ZOOM))
499        {
500            return Err(err(
501                at("content"),
502                format!(
503                    "zoom and crop together go in over {}x (the source window would be under \
504                     1/{} of the source a side)",
505                    limits::ZOOM,
506                    limits::ZOOM
507                ),
508            ));
509        }
510        #[allow(clippy::cast_possible_truncation)]
511        let to_crop = |w: &[f64; 4]| Crop {
512            x: w[0] as f32,
513            y: w[1] as f32,
514            width: w[2] as f32,
515            height: w[3] as f32,
516        };
517        let full = [0.0, 0.0, sw, sh];
518        let (crop, crops) = match windows.first() {
519            None => (None, Vec::new()),
520            Some(w) if windows.iter().all(|v| v == w) => {
521                ((*w != full).then(|| to_crop(w)), Vec::new())
522            }
523            Some(_) => (None, windows.iter().map(to_crop).collect()),
524        };
525        let feather = match &l.crop {
526            Some(c) => sample(&c.feather, &at("crop.feather"), vis, env)?
527                .into_iter()
528                .zip(&windows)
529                // A fraction of the source width, in box px.
530                .map(|(f, w)| f * sw * pic[0] / w[2].max(1e-6))
531                .collect(),
532            None => Vec::new(),
533        };
534        Ok(Framing {
535            crop,
536            crops,
537            pic,
538            cropped,
539            feather,
540        })
541    }
542
543    fn canvas_env(&self) -> Env<'_> {
544        let [cw, ch] = self.canvas.size.map(f64::from);
545        Env {
546            fps: self.canvas.fps,
547            canvas: [cw, ch],
548            size: [cw, ch],
549            source: [cw, ch],
550            in_: 0.0,
551            out: self.canvas.duration,
552            total: self.canvas.duration,
553            source_in: 0.0,
554            vars: Some(&self.vars),
555        }
556    }
557
558    /// Lowers comp frames `range` for a render of `out` px (lengths scale by out/canvas).
559    #[allow(
560        clippy::too_many_lines,
561        clippy::many_single_char_names,
562        clippy::cast_possible_truncation,
563        clippy::cast_sign_loss,
564        clippy::cast_precision_loss
565    )]
566    pub fn lower(&self, range: Range<i64>, out: Size, media: &dyn Media) -> Result<Piece> {
567        self.lower_in(range, out, media, true)
568    }
569
570    /// A filtered video layer's prep over its visible frames `vis` of `span`, and its key: the
571    /// node's recipe (`docs/compositing.md#libavfilter-escape-hatch`) and the frames shown.
572    #[allow(
573        clippy::too_many_arguments,
574        clippy::cast_precision_loss,
575        clippy::cast_possible_truncation,
576        clippy::cast_sign_loss
577    )]
578    fn filter_prep(
579        &self,
580        l: &CompLayer,
581        p: &Path,
582        node: &Node,
583        reach: Reach,
584        span: &Range<i64>,
585        vis: &Range<i64>,
586        env: &Env<'_>,
587        path: &str,
588        media: &dyn Media,
589    ) -> Result<(Prep, Value)> {
590        let at = |k: &str| format!("{path}.{k}");
591        let (rate, n) = media.video_rate(p)?;
592        if !(rate.is_finite() && rate > 0.0 && n > 0) {
593            return Err(err(
594                at("filter"),
595                format!("{}: no frames or frame rate to filter", p.display()),
596            ));
597        }
598        // The frames fed: the piece's, with pre-roll and post-roll inside the layer, or the
599        // whole layer.
600        let ext = if reach.whole_clip {
601            span.clone()
602        } else {
603            (vis.start - i64::from(reach.preroll)).max(span.start)
604                ..(vis.end + i64::from(reach.postroll)).min(span.end)
605        };
606        let fps = self.canvas.fps;
607        let picks: Vec<i64> = match &l.time {
608            Some(t) => time::source_times(t, env, ext.clone(), &at("time"))?
609                .into_iter()
610                .map(|(s, mid)| time::pick(s, mid, rate, n))
611                .collect(),
612            None => ext
613                .clone()
614                .map(|f| {
615                    let s = l.source_in + f as f64 / fps - l.in_;
616                    time::pick(s, s + 0.5 / fps, rate, n)
617                })
618                .collect(),
619        };
620        let lo = picks.iter().copied().min().unwrap_or(0);
621        let pts = match node.clock {
622            FClock::Layer => ext.start - span.start,
623            FClock::Comp => ext.start,
624        };
625        let shown = (vis.start - ext.start) as usize..(vis.end - ext.start) as usize;
626        let (store, show) = if reach.whole_clip {
627            (0..picks.len(), shown)
628        } else {
629            (shown.clone(), 0..shown.len())
630        };
631        let mut rec = json!({
632            "graph": node.graph,
633            "hw": node.hw.as_str(),
634            "clock": node.clock.as_str(),
635            "preroll": reach.preroll,
636            "postroll": reach.postroll,
637            "whole_clip": reach.whole_clip,
638            "timing": if node.free_timing { "free" } else { "keep" },
639            "fps": fps,
640            "files": node.files.iter().map(|f| media.content_hash(f)).collect::<Vec<_>>(),
641            "ffmpeg": media.ffmpeg_build(),
642            "video": media.identity(p),
643            "map": map_digest(&picks),
644            "pts": pts,
645        });
646        if reach.stateful() {
647            rec["table"] = json!(table_revision());
648        }
649        if node.clock == FClock::Comp {
650            rec["in"] = json!(l.in_);
651        }
652        let id = json!({"node": rec, "store": [store.start, store.end]});
653        let key = Sha256::digest(id.to_string().as_bytes())[..16].iter().fold(
654            String::new(),
655            |mut s, b| {
656                let _ = write!(s, "{b:02x}");
657                s
658            },
659        );
660        let prep = Prep {
661            graph: node.run.clone(),
662            labelled: node.labelled,
663            dims: media.dims(p)?,
664            hw: node.hw,
665            free_timing: node.free_timing,
666            input: p.to_owned(),
667            // Half a frame early: the decoder keeps the first frame at or after it.
668            start: Duration::from_secs_f64(((lo as f64 - 0.5) / rate).max(0.0)),
669            map: picks.iter().map(|k| k - lo).collect(),
670            rate: fps,
671            pts,
672            store: store.clone(),
673            show: show.clone(),
674            key,
675        };
676        Ok((prep, json!({"filter": id, "show": [show.start, show.end]})))
677    }
678
679    /// Layer `l`'s grade over visible frames `vis`
680    /// (`docs/compositing.md#per-layer-grade-and-filters`): `grade`, or `{}` when the comp has
681    /// a `project`, resolved like a timeline clip's and built by [`grade_for`]. `None`: ungraded.
682    #[allow(
683        clippy::too_many_lines,
684        clippy::many_single_char_names,
685        clippy::cast_possible_truncation
686    )]
687    fn layer_grade(
688        &self,
689        l: &CompLayer,
690        path: &str,
691        vis: &Range<i64>,
692        env: &Env,
693        media: &dyn Media,
694    ) -> Result<Option<Graded>> {
695        let cp = &*self.project;
696        let empty = LayerGrade::default();
697        let lg = match (&l.grade, &l.source) {
698            (_, Source::Solid(_) | Source::Text { .. }) => return Ok(None),
699            (Some(g), _) => g,
700            (None, _) if cp.given => &empty,
701            (None, _) => return Ok(None),
702        };
703        let gp = format!("{path}.grade");
704        let at = |k: &str| format!("{gp}.{k}");
705        let gerr = |e: &dyn std::fmt::Display| err(&gp, e.to_string());
706        let resolve = |c: &ClipGrade, kind| {
707            cp.project
708                .resolve_grade(Some(c), kind)
709                .map_err(|e| gerr(&e))
710        };
711        // The camera: the layer's, the look's, then the project's; `auto` probes the source.
712        let merged = resolve(&lg.params, SourceKind::Display)?;
713        let choice = match merged.camera.as_deref() {
714            None => ProfileChoice::Auto,
715            Some(c) => c
716                .parse::<ProfileChoice>()
717                .map_err(|e| err(at("camera"), e.to_string()))?,
718        };
719        let profile = match (&l.source, choice) {
720            (Source::Video(_), ProfileChoice::Fixed(p)) => p,
721            (Source::Video(p) | Source::Still(p), ProfileChoice::Auto) => media.profile(p)?,
722            // A still with a project camera, or a comp: display-referred.
723            _ => Profile::Rec709,
724        };
725        let log = !matches!(profile, Profile::Rec709 | Profile::Hdr);
726        let kind = if log {
727            SourceKind::Log
728        } else {
729            SourceKind::Display
730        };
731        let mut params = resolve(&lg.params, kind)?;
732        // Per-layer denoise is not built yet: a project's is the main picture's only.
733        params.denoise = None;
734        let (mut g, warn) =
735            grade_for(&cp.project, &cp.dir, &params, &mut Luts::default()).map_err(|e| gerr(&e))?;
736        if let Some(w) = warn.first() {
737            return Err(err(&gp, w));
738        }
739        // The camera LUT, as `ffcine grade --camera`: ahead of the grade LUT; with `auto` only
740        // when the grade has no LUT of its own (a project's log LUT is the camera transform).
741        if matches!(l.source, Source::Video(_))
742            && log
743            && (matches!(choice, ProfileChoice::Fixed(_))
744                || cp.project.lut_path(params.lut.as_deref()).is_none())
745            && let Some(cam) = media.camera_lut(profile)?
746        {
747            if profile.as_str().starts_with("dlog") {
748                g.deband.get_or_insert_with(Deband::default);
749            }
750            g.input_color.primaries.get_or_insert(cam.primaries);
751            g.input_color.transfer.get_or_insert(cam.transfer);
752            g.input_lut = Some(LutStage {
753                lut: cam.lut,
754                kind: LutType::Normalized,
755            });
756        }
757        let mut hooks = std::mem::take(&mut g.hooks);
758
759        // Animated look params: resolved per frame (so the light rule still scales them).
760        if !lg.tracks.is_empty() {
761            let mut cols: Vec<(&str, Vec<f64>)> = Vec::new();
762            for (k, t) in &lg.tracks {
763                let v = sample(t, &at(k), vis.clone(), env)?;
764                finite(&at(k), &v)?;
765                if *k == "gamma"
766                    && let Some(x) = v.iter().find(|x| **x < 0.01)
767                {
768                    return Err(err(at(k), format!("{x}: at least 0.01")));
769                }
770                cols.push((k, v));
771            }
772            let n = cols[0].1.len();
773            let mut frames: Vec<Vec<(String, f64)>> = Vec::with_capacity(n);
774            for j in 0..n {
775                let mut c = lg.params.clone();
776                for (k, v) in &cols {
777                    let x = Some(v[j]);
778                    let p = &mut c.params;
779                    match *k {
780                        "exposure" => p.exposure = x,
781                        "warmth" => p.warmth = x,
782                        "tint" => p.tint = x,
783                        "contrast" => p.contrast = x,
784                        "gamma" => p.gamma = x,
785                        "lift" => p.lift = x,
786                        "gain" => p.gain = x,
787                        "saturation" => p.saturation = x,
788                        _ => unreachable!("not a look param: {k}"),
789                    }
790                }
791                frames.push(look_params(&resolve(&c, kind)?).params());
792            }
793            let mut h = hook("look", shaders::look());
794            for (i, (name, _)) in frames[0].iter().enumerate() {
795                param(&mut h, name, f32s(frames.iter().map(|f| f[i].1)));
796            }
797            match hooks.first_mut() {
798                Some(first) if first.name == "look" => *first = h,
799                _ => hooks.insert(0, h),
800            }
801        }
802
803        // `filters`, after the resolved grade's `extra`.
804        for (fi, f) in lg.filters.iter().enumerate() {
805            let fp = at(&format!("filters[{fi}]"));
806            match f {
807                FilterSpec::Chain(c) => {
808                    hooks.extend(
809                        strict_hooks(std::slice::from_ref(c))
810                            .map_err(|e| err(&fp, format!("{e} {FILTER_HINT}")))?,
811                    );
812                }
813                FilterSpec::Object { name, opts } => {
814                    let fp = format!("{fp}.{name}");
815                    let mut cols = Vec::new();
816                    for (k, o) in opts {
817                        cols.push(match o {
818                            FilterOpt::Num(t) => {
819                                let v = sample(t, &format!("{fp}.{k}"), vis.clone(), env)?;
820                                finite(&format!("{fp}.{k}"), &v)?;
821                                Err(v)
822                            }
823                            FilterOpt::Text(s) => Ok(s.clone()),
824                        });
825                    }
826                    let moving = cols.iter().any(
827                        |c| matches!(c, Err(v) if v.iter().any(|x| x.to_bits() != v[0].to_bits())),
828                    );
829                    let n = if moving { vis.end - vis.start } else { 1 };
830                    let mut per = Vec::new();
831                    for j in 0..usize::try_from(n).unwrap_or(0) {
832                        let f = Filter {
833                            name: name.clone(),
834                            args: opts
835                                .iter()
836                                .zip(&cols)
837                                .map(|((k, _), c)| {
838                                    (
839                                        Some(k.clone()),
840                                        match c {
841                                            Ok(s) => s.clone(),
842                                            Err(v) => format!("{}", v[j]),
843                                        },
844                                    )
845                                })
846                                .collect(),
847                        };
848                        per.push(
849                            filter_hook(&f).map_err(|e| err(&fp, format!("{e} {FILTER_HINT}")))?,
850                        );
851                    }
852                    let first = &per[0];
853                    if per.iter().any(|h| {
854                        h.source != first.source
855                            || h.params.len() != first.params.len()
856                            || h.params.iter().zip(&first.params).any(|(a, b)| a.0 != b.0)
857                    }) {
858                        return Err(err(
859                            &fp,
860                            "an animated option changes the filter's shader (only its numbers can \
861                             move)",
862                        ));
863                    }
864                    let mut h = hook(&first.name, &first.source);
865                    for (i, (pn, _)) in first.params.iter().enumerate() {
866                        param(&mut h, pn, f32s(per.iter().map(|x| x.params[i].1)));
867                    }
868                    hooks.push(h);
869                }
870            }
871        }
872
873        // `shaders`, as `ffcine.fx.shader/1`.
874        for (si, sh) in lg.shaders.iter().enumerate() {
875            let sp = at(&format!("shaders[{si}]"));
876            let (name, source) = ShaderEffect {
877                shader: sh.shader.clone(),
878                params: Vec::new(),
879            }
880            .load(&cp.project, &cp.dir)
881            .map_err(|e| err(&sp, e.to_string()))?;
882            let info = ffcine_hooks::parse(&source).map_err(|e| err(&sp, e.to_string()))?;
883            let mut h = hook(&name, &source);
884            for (k, t) in &sh.params {
885                let pp = format!("{sp}.params.{k}");
886                let p = info.param(k).ok_or_else(|| {
887                    err(
888                        &pp,
889                        format!(
890                            "not a //!PARAM of {} (has: {})",
891                            sh.shader,
892                            info.params
893                                .iter()
894                                .map(|p| p.name.as_str())
895                                .collect::<Vec<_>>()
896                                .join(", ")
897                        ),
898                    )
899                })?;
900                let v = sample(t, &pp, vis.clone(), env)?
901                    .into_iter()
902                    .map(|x| p.check(x))
903                    .collect::<Result<Vec<_>>>()
904                    .map_err(|e| err(&pp, e.to_string()))?;
905                param(&mut h, k, f32s(v));
906            }
907            hooks.push(h);
908        }
909
910        let recipe = json!({
911            "profile": profile.as_str(),
912            "input_lut": g.input_lut.as_ref().map(|s| lut_digest(&s.lut)),
913            "input_color": format!("{:?}", g.input_color),
914            "lut": g.lut.as_ref().map(|s| lut_digest(&s.lut)),
915            "rgb_gain": g.rgb_gain,
916            "sharpen": g.sharpen,
917            "deband": g.deband.is_some(),
918            "shaders": hooks.iter().map(|h| sha([&h.source])).collect::<Vec<_>>(),
919        });
920        Ok(Some(Graded {
921            grade: g,
922            hooks,
923            recipe,
924        }))
925    }
926
927    /// [`Self::lower`]; `top` is false for a nested comp (no base promotion: its layers go into
928    /// a [`Scene`]).
929    #[allow(
930        clippy::too_many_lines,
931        clippy::many_single_char_names,
932        clippy::cast_possible_truncation,
933        clippy::cast_sign_loss,
934        clippy::cast_precision_loss
935    )]
936    fn lower_in(
937        &self,
938        range: Range<i64>,
939        out: Size,
940        media: &dyn Media,
941        top: bool,
942    ) -> Result<Piece> {
943        let [cw, ch] = self.canvas.size.map(f64::from);
944        let k = [f64::from(out.width) / cw, f64::from(out.height) / ch];
945        let p0 = range.start;
946        let frames = range.end - range.start;
947        let cenv = self.canvas_env();
948        let canvas_fade = sample(&self.canvas.fade, "canvas.fade", range.clone(), &cenv)?;
949        let canvas_colour = sample(
950            &self.canvas.fade_colour,
951            "canvas.fade_colour",
952            range.clone(),
953            &cenv,
954        )?;
955        let mut base = Base::Black;
956        let mut base_grade = None;
957        let mut layers = Vec::new();
958        let mut shown = Vec::new();
959        let mut overlays = Vec::new();
960        let mut recipes = Vec::new();
961
962        for (i, l) in self.layers.iter().enumerate() {
963            let span = self.span(l);
964            let vis = span.start.max(range.start)..span.end.min(range.end);
965            if vis.is_empty() {
966                continue;
967            }
968            let path = format!("layers[{i}]");
969            let at = |name: &str| format!("{path}.{name}");
970            // Text: masks drawn under whatever layer comes next (above all if none does).
971            if let Source::Text { params, dir } = &l.source {
972                let clip = TextClip {
973                    id: l.id.clone(),
974                    name: l.id.clone().unwrap_or_default(),
975                    text: params
976                        .get("text")
977                        .and_then(Value::as_str)
978                        .unwrap_or_default()
979                        .to_owned(),
980                    start: l.in_,
981                    end: l.out,
982                    track: 0,
983                    params: params.clone(),
984                };
985                let mut text =
986                    TextLayer::new(vec![clip], Some([cw as f32, ch as f32]), dir.clone());
987                let under = layers.len();
988                let first = overlays.len();
989                let o = text
990                    .overlays(
991                        p0 as f64 / self.canvas.fps,
992                        frames as u64,
993                        self.canvas.fps,
994                        out,
995                    )
996                    .map_err(|e| err(at("source.text"), e.to_string()))?;
997                overlays.extend(o.into_iter().map(|mut o| {
998                    o.under = Some(under);
999                    o
1000                }));
1001                // The drawn overlays themselves (masks, placements, fades: they depend on the
1002                // piece's start and the font files' contents), and the font files' identities.
1003                recipes.push(json!({
1004                    "text": params,
1005                    "in": l.in_,
1006                    "out": l.out,
1007                    "under": under,
1008                    "overlays": overlay_digest(&overlays[first..]),
1009                    "fonts": font_files(params, dir).iter().map(|f| media.identity(f)).collect::<Vec<_>>(),
1010                }));
1011                continue;
1012            }
1013            let (src_dims, source) = match &l.source {
1014                Source::Video(p) => {
1015                    let (w, h) = media.dims(p)?;
1016                    (Some([f64::from(w), f64::from(h)]), None)
1017                }
1018                Source::Still(p) => {
1019                    let (w, h) = media.dims(p)?;
1020                    if !ffcine_render::still_fits(w, h) {
1021                        return Err(err(
1022                            at("source.still"),
1023                            format!(
1024                                "{}: {w}x{h} px (a still is at most {} px a side, {} in all)",
1025                                p.display(),
1026                                limits::STILL_SIDE,
1027                                limits::STILL_PIXELS
1028                            ),
1029                        ));
1030                    }
1031                    (Some([f64::from(w), f64::from(h)]), None)
1032                }
1033                Source::Comp(c) => (Some(c.canvas.size.map(f64::from)), None),
1034                Source::Solid(c) => (None, Some(c)),
1035                Source::Text { .. } => unreachable!("text is lowered above"),
1036            };
1037            // The picture inside the box: the source window (per frame with `content` or
1038            // `crop`) and its size in box px.
1039            let framing = match src_dims {
1040                Some(src) if l.content.is_some() || l.crop.is_some() => {
1041                    let env = self.env(l, src);
1042                    Some(self.framing(l, src, &span, vis.clone(), &env, &path)?)
1043                }
1044                _ => None,
1045            };
1046            let (crop, crops, pic, cropped) = if let Some(f) = &framing {
1047                (f.crop, f.crops.clone(), f.pic, f.cropped)
1048            } else {
1049                let (c, p) = fit(l.fit, src_dims, l.size);
1050                (c, Vec::new(), p, src_dims.unwrap_or(l.size))
1051            };
1052            let env = self.env(l, cropped);
1053            let off = [(l.size[0] - pic[0]) / 2.0, (l.size[1] - pic[1]) / 2.0];
1054
1055            let position = sample(
1056                &l.transform.position,
1057                &at("transform.position"),
1058                vis.clone(),
1059                &env,
1060            )?;
1061            let anchor = sample(
1062                &l.transform.anchor,
1063                &at("transform.anchor"),
1064                vis.clone(),
1065                &env,
1066            )?;
1067            let scale: Vec<[f64; 2]> = match &l.transform.scale {
1068                Scale::Uniform(t) => sample(t, &at("transform.scale"), vis.clone(), &env)?
1069                    .into_iter()
1070                    .map(|s| [s.max(0.0); 2])
1071                    .collect(),
1072                Scale::Xy(t) => sample(t, &at("transform.scale"), vis.clone(), &env)?
1073                    .into_iter()
1074                    .map(|s| s.map(|v| v.max(0.0)))
1075                    .collect(),
1076            };
1077            let rotation = sample(
1078                &l.transform.rotation,
1079                &at("transform.rotation"),
1080                vis.clone(),
1081                &env,
1082            )?;
1083            let mut opacity: Vec<f64> = sample(
1084                &l.transform.opacity,
1085                &at("transform.opacity"),
1086                vis.clone(),
1087                &env,
1088            )?
1089            .into_iter()
1090            .map(|o| o.clamp(0.0, 1.0))
1091            .collect();
1092            let solid = match source {
1093                Some(c) => {
1094                    let c = sample(c, &at("source.solid"), vis.clone(), &env)?;
1095                    for (o, c) in opacity.iter_mut().zip(&c) {
1096                        *o *= c[3].clamp(0.0, 1.0);
1097                    }
1098                    Some(c)
1099                }
1100                None => None,
1101            };
1102            let radius = sample(&l.radius, &at("radius"), vis.clone(), &env)?;
1103            finite(&at("radius"), &radius)?;
1104            let fade = sample(&l.fade, &at("fade"), vis.clone(), &env)?;
1105            let fade_colour = sample(&l.fade_colour, &at("fade_colour"), vis.clone(), &env)?;
1106
1107            // The texture: the picture at the largest scale in the piece, in render px.
1108            let smax = scale
1109                .iter()
1110                .fold([0.0f64; 2], |m, s| [m[0].max(s[0]), m[1].max(s[1])]);
1111            let tw = (pic[0] * smax[0].max(1e-3) * k[0]).ceil().max(2.0);
1112            let th = (pic[1] * smax[1].max(1e-3) * k[1]).ceil().max(2.0);
1113            if !(tw.is_finite() && th.is_finite() && tw.max(th) <= limits::TEXTURE_SIDE) {
1114                return Err(err(
1115                    at("transform.scale"),
1116                    format!(
1117                        "the layer's texture would be {tw}x{th} px at its largest scale (at most {} \
1118                         px a side at this render size)",
1119                        limits::TEXTURE_SIDE
1120                    ),
1121                ));
1122            }
1123            finite(&at("transform.position"), position.iter().flatten())?;
1124            finite(&at("transform.anchor"), anchor.iter().flatten())?;
1125            if let Some(r) = rotation
1126                .iter()
1127                .find(|r| !r.is_finite() || r.abs() > limits::ROTATION)
1128            {
1129                return Err(err(
1130                    at("transform.rotation"),
1131                    format!("{r}: at most {} degrees either way", limits::ROTATION),
1132                ));
1133            }
1134            let s = Sampled {
1135                rects: (0..position.len())
1136                    .map(|j| {
1137                        let [sx, sy] = scale[j];
1138                        // The box middle from the anchor, scaled and turned (counter-clockwise on
1139                        // screen, y down), from the position.
1140                        let q = [
1141                            l.size[0] * (0.5 - anchor[j][0]) * sx,
1142                            l.size[1] * (0.5 - anchor[j][1]) * sy,
1143                        ];
1144                        let (sin, cos) = rotation[j].to_radians().sin_cos();
1145                        let c = [
1146                            position[j][0] + cos * q[0] + sin * q[1],
1147                            position[j][1] - sin * q[0] + cos * q[1],
1148                        ];
1149                        let (w, h) = (pic[0] * sx, pic[1] * sy);
1150                        Crop {
1151                            x: ((c[0] - w / 2.0) * k[0]) as f32,
1152                            y: ((c[1] - h / 2.0) * k[1]) as f32,
1153                            width: (w * k[0]) as f32,
1154                            height: (h * k[1]) as f32,
1155                        }
1156                    })
1157                    .collect(),
1158                rotation,
1159                opacity,
1160            };
1161
1162            // Hooks: the solid colour or the layer's grade, the layer's fade, then alpha.
1163            let graded = self.layer_grade(l, &path, &vis, &env, media)?;
1164            let (layer_grade, mut hooks, graded_recipe) = match graded {
1165                Some(g) => (g.grade, g.hooks, Some(g.recipe)),
1166                None => (Grade::default(), Vec::new(), None),
1167            };
1168            if let Some(c) = &solid {
1169                let mut h = hook("solid", shaders::solid());
1170                for (n, name) in ["solid_r", "solid_g", "solid_b"].into_iter().enumerate() {
1171                    param(&mut h, name, f32s(c.iter().map(|v| v[n])));
1172                }
1173                hooks.push(h);
1174            }
1175            hooks.extend(fade_hook("fade", &fade, &fade_colour));
1176            // Box px to texture px, horizontally; `ky` is the vertical factor over it (a
1177            // non-uniform scale), so corners and feathers stay round in box px.
1178            let px = tw / pic[0].max(1e-6);
1179            let ky = (th / pic[1].max(1e-6)) / px;
1180            let edge = framing
1181                .as_ref()
1182                .map(|f| f.feather.clone())
1183                .filter(|f| f.iter().any(|v| *v > 0.0));
1184            let has_mask = l.mask.is_some() || edge.is_some();
1185            if has_mask || radius.iter().any(|r| *r > 0.0) || s.opacity.iter().any(|o| *o < 1.0) {
1186                let mut h = hook("alpha", shaders::layer_alpha());
1187                param(&mut h, "opacity", f32s(s.opacity.iter().copied()));
1188                if (ky - 1.0).abs() > 1e-9 {
1189                    param(&mut h, "ky", vec![ky as f32]);
1190                }
1191                param(
1192                    &mut h,
1193                    "radius",
1194                    f32s(radius.iter().map(|r| r.max(0.0) * px)),
1195                );
1196                let mask = match (&l.mask, &edge) {
1197                    (Some(m), _) => {
1198                        let rect = sample(&m.rect, &at("mask.rect"), vis.clone(), &env)?;
1199                        let feather = sample(&m.feather, &at("mask.feather"), vis.clone(), &env)?;
1200                        finite(&at("mask.rect"), rect.iter().flatten())?;
1201                        finite(&at("mask.feather"), &feather)?;
1202                        Some((m.shape, rect, feather, m.invert))
1203                    }
1204                    // A feathered crop: a rect mask on the picture, the soft edge inside it.
1205                    (None, Some(f)) => {
1206                        finite(&at("crop.feather"), f)?;
1207                        let rect = f
1208                            .iter()
1209                            .map(|f| {
1210                                let f = f.max(0.0);
1211                                [off[0] + f / 2.0, off[1] + f / 2.0, pic[0] - f, pic[1] - f]
1212                            })
1213                            .collect::<Vec<_>>();
1214                        Some((MaskShape::Rect, rect, f.clone(), false))
1215                    }
1216                    (None, None) => None,
1217                };
1218                if let Some((shape, rect, feather, invert)) = &mask {
1219                    param(
1220                        &mut h,
1221                        "shape",
1222                        vec![if *shape == MaskShape::Rect { 1.0 } else { 2.0 }],
1223                    );
1224                    param(
1225                        &mut h,
1226                        "mask_x",
1227                        f32s(rect.iter().map(|r| (r[0] - off[0]) / pic[0])),
1228                    );
1229                    param(
1230                        &mut h,
1231                        "mask_y",
1232                        f32s(rect.iter().map(|r| (r[1] - off[1]) / pic[1])),
1233                    );
1234                    param(&mut h, "mask_w", f32s(rect.iter().map(|r| r[2] / pic[0])));
1235                    param(&mut h, "mask_h", f32s(rect.iter().map(|r| r[3] / pic[1])));
1236                    param(
1237                        &mut h,
1238                        "feather",
1239                        f32s(feather.iter().map(|f| f.max(0.0) * px)),
1240                    );
1241                    param(&mut h, "invert", vec![if *invert { 1.0 } else { 0.0 }]);
1242                }
1243                hooks.push(h);
1244            }
1245
1246            let (mut crop, mut crops) = (crop, crops);
1247            let mut scene = None;
1248            let mut remap = None;
1249            let src = match (&l.source, &solid) {
1250                (Source::Video(p), _) if let Some((node, reach)) = &l.filter => {
1251                    let (prep, r) =
1252                        self.filter_prep(l, p, node, *reach, &span, &vis, &env, &path, media)?;
1253                    remap = Some(r);
1254                    LayerSource::Filtered(Arc::new(prep))
1255                }
1256                (Source::Video(p), _) if let Some(t) = &l.time => {
1257                    let (rate, n) = media.video_rate(p)?;
1258                    if !(rate.is_finite() && rate > 0.0 && n > 0) {
1259                        return Err(err(
1260                            at("time"),
1261                            format!("{}: no frames or frame rate to remap", p.display()),
1262                        ));
1263                    }
1264                    let picks: Vec<i64> = time::source_times(t, &env, vis.clone(), &at("time"))?
1265                        .into_iter()
1266                        .map(|(s, mid)| time::pick(s, mid, rate, n))
1267                        .collect();
1268                    let lo = picks.iter().copied().min().unwrap_or(0);
1269                    // Which source frames the piece shows: its key.
1270                    remap = Some(json!({
1271                        "video": media.identity(p),
1272                        "map": map_digest(&picks),
1273                    }));
1274                    LayerSource::Video {
1275                        input: p.clone(),
1276                        // Half a frame early: the decoder keeps the first frame at or after it.
1277                        start: Duration::from_secs_f64(((lo as f64 - 0.5) / rate).max(0.0)),
1278                        map: Some(picks.iter().map(|k| k - lo).collect()),
1279                    }
1280                }
1281                (Source::Video(p), _) => {
1282                    let st = l.source_in + vis.start as f64 / self.canvas.fps - l.in_;
1283                    LayerSource::Video {
1284                        input: p.clone(),
1285                        start: Duration::from_secs_f64(st.max(0.0)),
1286                        map: None,
1287                    }
1288                }
1289                (Source::Still(p), _) => LayerSource::Still(p.clone()),
1290                (Source::Comp(c), _) => {
1291                    // The scene at the texture's resolution: sub-comp px times texture px per
1292                    // window px, capped.
1293                    let [sw, sh] = c.canvas.size.map(f64::from);
1294                    let wmin = |f: fn(&Crop) -> f32, full: f64| {
1295                        crop.iter()
1296                            .chain(&crops)
1297                            .map(|c| f64::from(f(c)))
1298                            .fold(full, f64::min)
1299                            .max(1e-3)
1300                    };
1301                    let mut size = [
1302                        sw * tw / wmin(|c| c.width, sw),
1303                        sh * th / wmin(|c| c.height, sh),
1304                    ];
1305                    let cap = limits::TEXTURE_SIDE / size[0].max(size[1]);
1306                    if cap < 1.0 {
1307                        size = size.map(|v| v * cap);
1308                    }
1309                    let size = size.map(|v| v.ceil().max(2.0));
1310                    let ratio = [size[0] / sw, size[1] / sh];
1311                    let scale = |c: &Crop| Crop {
1312                        x: (f64::from(c.x) * ratio[0]) as f32,
1313                        y: (f64::from(c.y) * ratio[1]) as f32,
1314                        width: (f64::from(c.width) * ratio[0]) as f32,
1315                        height: (f64::from(c.height) * ratio[1]) as f32,
1316                    };
1317                    crop = crop.as_ref().map(scale);
1318                    crops = crops.iter().map(scale).collect();
1319                    // Its clock: 0 at the layer's `source_in`.
1320                    let start =
1321                        (l.source_in * self.canvas.fps).round() as i64 + (vis.start - span.start);
1322                    let size = Size {
1323                        width: size[0] as u32,
1324                        height: size[1] as u32,
1325                    };
1326                    let sub =
1327                        c.lower_in(start..start + (vis.end - vis.start), size, media, false)?;
1328                    scene = Some(json!({"comp": sub.recipe, "start": start}));
1329                    LayerSource::Scene(Arc::new(Scene {
1330                        size,
1331                        grade: sub.grade,
1332                        layers: sub.layers,
1333                        overlays: sub.overlays,
1334                    }))
1335                }
1336                (Source::Solid(_), _) => solid_image(),
1337                (Source::Text { .. }, _) => unreachable!("text is lowered above"),
1338            };
1339
1340            // Base promotion: the bottom layer, a normal opaque unturned video covering the
1341            // canvas over the whole piece, becomes the pass input.
1342            let covers = |r: &Crop| {
1343                (f64::from(r.x)).abs() < 0.5
1344                    && (f64::from(r.y)).abs() < 0.5
1345                    && (f64::from(r.width) - f64::from(out.width)).abs() < 0.5
1346                    && (f64::from(r.height) - f64::from(out.height)).abs() < 0.5
1347            };
1348            let promote = top
1349                && i == 0
1350                && matches!(l.source, Source::Video(_))
1351                && l.time.is_none()
1352                && l.filter.is_none()
1353                && l.blend == BlendMode::Normal
1354                && vis == range
1355                && !has_mask
1356                && radius.iter().all(|r| *r <= 0.0)
1357                && s.opacity.iter().all(|o| *o >= 1.0)
1358                && s.rotation.iter().all(|r| r % 360.0 == 0.0)
1359                && s.rects.iter().all(covers);
1360            if promote {
1361                let LayerSource::Video { input, start, .. } = src else {
1362                    unreachable!()
1363                };
1364                let mut r = json!({
1365                    "base": media.identity(&input),
1366                    "start": start.as_secs_f64(),
1367                });
1368                if let Some(g) = &graded_recipe {
1369                    r["layer_grade"] = g.clone();
1370                }
1371                recipes.push(r);
1372                base = Base::Video { input, start };
1373                base_grade = Some(Grade {
1374                    size: Some(out),
1375                    crop,
1376                    crops,
1377                    hooks,
1378                    ..layer_grade
1379                });
1380                continue;
1381            }
1382
1383            let grade = Grade {
1384                size: Some(Size {
1385                    width: tw as u32,
1386                    height: th as u32,
1387                }),
1388                crop,
1389                crops,
1390                dither: None,
1391                peak_detect: false,
1392                hooks,
1393                ..layer_grade
1394            };
1395            let rotation = if s.rotation.iter().all(|r| *r == 0.0) {
1396                Vec::new()
1397            } else {
1398                f32s(s.rotation.iter().copied())
1399            };
1400            let layer = Layer {
1401                source: src,
1402                grade,
1403                from: vis.start - p0,
1404                to: vis.end - p0,
1405                rects: s.rects,
1406                rotation,
1407                blend: l.blend,
1408            };
1409            let mut r = json!({
1410                "source": match &layer.source {
1411                    LayerSource::Video { input, start, .. } => remap.take().unwrap_or_else(|| json!({"video": media.identity(input), "start": start.as_secs_f64()})),
1412                    LayerSource::Image(_) => json!("solid"),
1413                    LayerSource::Still(p) => json!({"still": media.identity(p)}),
1414                    LayerSource::Scene(_) => scene.take().unwrap_or_default(),
1415                    LayerSource::Filtered(_) => remap.take().unwrap_or_default(),
1416                },
1417                "from": layer.from,
1418                "to": layer.to,
1419                "rects": digest(&layer.rects.iter().flat_map(|r| [r.x, r.y, r.width, r.height]).collect::<Vec<_>>()),
1420                "rotation": digest(&layer.rotation),
1421                "blend": layer.blend.as_str(),
1422                "grade": grade_recipe(&layer.grade),
1423            });
1424            if let Some(g) = graded_recipe {
1425                r["layer_grade"] = g;
1426            }
1427            recipes.push(r);
1428            layers.push(layer);
1429            shown.push(i);
1430        }
1431
1432        let grade = if let Some(g) = base_grade {
1433            g
1434        } else {
1435            let bg = sample(
1436                &self.canvas.background,
1437                "canvas.background",
1438                range.clone(),
1439                &cenv,
1440            )?;
1441            let mut h = hook("background", shaders::background());
1442            for (n, name) in ["bg_r", "bg_g", "bg_b"].into_iter().enumerate() {
1443                param(&mut h, name, f32s(bg.iter().map(|v| v[n])));
1444            }
1445            Grade {
1446                size: Some(out),
1447                hooks: vec![h],
1448                ..Grade::default()
1449            }
1450        };
1451        // The canvas fade: a top-most normal solid of `fade_colour` at opacity `1 - fade`, so
1452        // it fades the composite whatever the layers' blend modes.
1453        if canvas_fade.iter().any(|f| *f < 1.0) {
1454            let mut h = hook("solid", shaders::solid());
1455            for (n, name) in ["solid_r", "solid_g", "solid_b"].into_iter().enumerate() {
1456                param(&mut h, name, f32s(canvas_colour.iter().map(|v| v[n])));
1457            }
1458            let mut a = hook("alpha", shaders::layer_alpha());
1459            param(
1460                &mut a,
1461                "opacity",
1462                f32s(canvas_fade.iter().map(|f| 1.0 - f.clamp(0.0, 1.0))),
1463            );
1464            let layer = Layer {
1465                source: solid_image(),
1466                grade: Grade {
1467                    size: Some(Size {
1468                        width: 2,
1469                        height: 2,
1470                    }),
1471                    hooks: vec![h, a],
1472                    ..Grade::default()
1473                },
1474                from: 0,
1475                to: frames,
1476                rects: Vec::new(),
1477                rotation: Vec::new(),
1478                blend: BlendMode::Normal,
1479            };
1480            recipes.push(json!({
1481                "canvas_fade": grade_recipe(&layer.grade),
1482            }));
1483            layers.push(layer);
1484        }
1485        let recipe = json!({
1486            "comp": 1,
1487            "canvas": self.canvas.size,
1488            "out": [out.width, out.height],
1489            "grade": grade_recipe(&grade),
1490            "layers": recipes,
1491        });
1492        Ok(Piece {
1493            from: p0,
1494            frames,
1495            base,
1496            grade,
1497            layers,
1498            shown,
1499            overlays,
1500            recipe,
1501        })
1502    }
1503}
1504
1505/// A digest of what `overlays` draw on every frame: masks, placements, frame ranges and fades.
1506fn overlay_digest(overlays: &[Overlay]) -> Value {
1507    let mut h = Sha256::new();
1508    let f32s = |h: &mut Sha256, v: &[f32]| {
1509        for x in v {
1510            h.update(x.to_bits().to_le_bytes());
1511        }
1512    };
1513    let parts = |h: &mut Sha256, parts: &[ffcine_render::OverlayPart]| {
1514        h.update((parts.len() as u64).to_le_bytes());
1515        for p in parts {
1516            f32s(h, &p.pos);
1517            f32s(h, &p.size.unwrap_or([f32::NAN; 2]));
1518            f32s(h, &p.src.unwrap_or([f32::NAN; 4]));
1519            f32s(h, &p.colour);
1520        }
1521    };
1522    for o in overlays {
1523        h.update(o.mask.width.to_le_bytes());
1524        h.update(o.mask.height.to_le_bytes());
1525        h.update(Sha256::digest(&o.mask.data));
1526        parts(&mut h, &o.parts);
1527        h.update(o.from.to_le_bytes());
1528        h.update(o.to.to_le_bytes());
1529        h.update(o.fade_in.to_bits().to_le_bytes());
1530        h.update(o.fade_out.to_bits().to_le_bytes());
1531        if let Some(m) = &o.motion {
1532            h.update(m.first.to_le_bytes());
1533            h.update((m.frames.len() as u64).to_le_bytes());
1534            for f in &m.frames {
1535                parts(&mut h, f);
1536            }
1537        }
1538    }
1539    let d = h.finalize();
1540    Value::String(d[..16].iter().fold(String::new(), |mut s, b| {
1541        let _ = write!(s, "{b:02x}");
1542        s
1543    }))
1544}
1545
1546/// The font files a text layer's `params` name (`font_file`, also under `profiles`), resolved
1547/// as `ffcine-text` does: against `fonts_dir`, then `dir`.
1548fn font_files(params: &serde_json::Map<String, Value>, dir: &Path) -> Vec<PathBuf> {
1549    let mut out = Vec::new();
1550    let top = Value::Object(params.clone());
1551    let mut stack = vec![&top];
1552    while let Some(v) = stack.pop() {
1553        match v {
1554            Value::Object(m) => {
1555                if let Some(f) = m.get("font_file").and_then(Value::as_str) {
1556                    let sub = m.get("fonts_dir").and_then(Value::as_str).unwrap_or("");
1557                    out.push(dir.join(sub).join(f));
1558                }
1559                stack.extend(m.values());
1560            }
1561            Value::Array(a) => stack.extend(a),
1562            _ => {}
1563        }
1564    }
1565    out.sort();
1566    out.dedup();
1567    out
1568}
1569
1570/// An error at `what` unless every value is finite and within [`limits::COORD`] (position,
1571/// anchor, radius, mask).
1572fn finite<'a>(what: &str, values: impl IntoIterator<Item = &'a f64>) -> Result<()> {
1573    match values
1574        .into_iter()
1575        .find(|v| !v.is_finite() || v.abs() > limits::COORD)
1576    {
1577        Some(v) => Err(err(
1578            what,
1579            format!("{v}: out of range (at most {:e} either way)", limits::COORD),
1580        )),
1581        None => Ok(()),
1582    }
1583}
1584
1585/// `track` over comp frames `range`, errors prefixed with the parameter's path.
1586fn sample<T: Animatable>(
1587    track: &Track<T>,
1588    what: &str,
1589    range: Range<i64>,
1590    env: &Env,
1591) -> Result<Vec<T>> {
1592    track
1593        .sample(Clock::Layer, range, env)
1594        .map_err(|e| err(what, e.to_string()))
1595}
1596
1597/// The source window (`None`: all of it) and the picture's size in box px for `fit`.
1598fn fit(fit: Fit, src: Option<[f64; 2]>, size: [f64; 2]) -> (Option<Crop>, [f64; 2]) {
1599    let Some([sw, sh]) = src.filter(|s| s[0] > 0.0 && s[1] > 0.0) else {
1600        return (None, size);
1601    };
1602    #[allow(clippy::cast_possible_truncation)]
1603    let crop = |w: f64, h: f64| {
1604        Some(Crop {
1605            x: ((sw - w) / 2.0) as f32,
1606            y: ((sh - h) / 2.0) as f32,
1607            width: w as f32,
1608            height: h as f32,
1609        })
1610    };
1611    let [bw, bh] = size;
1612    match fit {
1613        Fit::Fill => (None, size),
1614        Fit::Cover => {
1615            let s = (bw / sw).max(bh / sh);
1616            let (w, h) = ((bw / s).min(sw), (bh / s).min(sh));
1617            if (w - sw).abs() < 1e-6 && (h - sh).abs() < 1e-6 {
1618                (None, size)
1619            } else {
1620                (crop(w, h), size)
1621            }
1622        }
1623        Fit::Contain => {
1624            let s = (bw / sw).min(bh / sh);
1625            (None, [sw * s, sh * s])
1626        }
1627        Fit::None => {
1628            // A source pixel is a canvas pixel.
1629            let (w, h) = (sw.min(bw), sh.min(bh));
1630            let c = if w < sw || h < sh { crop(w, h) } else { None };
1631            (c, [w, h])
1632        }
1633    }
1634}