Skip to main content

ffcine_comp/
model.rs

1//! The `ffcine.comp/1` model: read from JSON or YAML, validated, every track compiled.
2
3use std::path::{Path, PathBuf};
4use std::str::FromStr;
5use std::sync::Arc;
6
7use ffcine_core::anim::{Clock, Env, Scope, Track, Vars, is_keys};
8use ffcine_core::camera::ProfileChoice;
9use ffcine_core::{Error, Result};
10use ffcine_render::BlendMode;
11use ffcine_render::filter_node::{self as fnode, Hw, MAX_ROLL, Node, Reach};
12use ffcine_timeline::{ClipGrade, Project, Timeline};
13use serde_json::{Map, Value};
14
15/// The schema name (`"schema": "ffcine.comp/1"`).
16pub const SCHEMA: &str = "ffcine.comp";
17const VERSION: u64 = 1;
18
19pub(crate) fn err(path: impl Into<String>, msg: impl Into<String>) -> Error {
20    Error::Track {
21        path: path.into(),
22        msg: msg.into(),
23    }
24}
25
26/// Limits a comp is checked against (`docs/compositing.md#limits`).
27pub mod limits {
28    /// Canvas px a side.
29    pub const CANVAS_SIDE: f64 = 16384.0;
30    /// Canvas px in all.
31    pub const CANVAS_PIXELS: f64 = 8192.0 * 8192.0;
32    /// Long side over short side, canvas and render.
33    pub const ASPECT: f64 = 4.0;
34    /// Frames per second, at least and at most.
35    pub const FPS: (f64, f64) = (1.0, 240.0);
36    /// Comp seconds (and layer `in`/`out`, `source_in`).
37    pub const DURATION: f64 = 3600.0;
38    /// Layers in a comp.
39    pub const LAYERS: usize = 64;
40    /// Video layers in a comp (each one a decoder while it shows).
41    pub const VIDEO_LAYERS: usize = 16;
42    /// A layer's box, px a side.
43    pub const BOX_SIDE: f64 = 65536.0;
44    /// A layer's texture (its picture at its largest scale, render px), px a side.
45    pub const TEXTURE_SIDE: f64 = 16384.0;
46    /// Position and anchor values, either way (px, box fractions).
47    pub const COORD: f64 = 1e9;
48    /// Rotation, degrees either way.
49    pub const ROTATION: f64 = 1e6;
50    /// A time remap's `speed`, either way (source s per layer s).
51    pub const SPEED: f64 = 100.0;
52    /// Comps nested in comps (`{"comp": ...}` sources), levels below the top.
53    pub const DEPTH: usize = 4;
54    /// `content` zooms (`zoom`, a punch's, `push`), and how far in they and `crop` may go in
55    /// all: the source window is at least 1/ZOOM of the source a side.
56    pub const ZOOM: f64 = 1e4;
57    /// A still source, px a side.
58    pub const STILL_SIDE: u32 = ffcine_render::STILL_SIDE;
59    /// A still source, px in all.
60    pub const STILL_PIXELS: u64 = ffcine_render::STILL_PIXELS;
61}
62
63/// Whether `v` is a comp document (`schema` is `ffcine.comp/N`), as opposed to a timeline.
64pub fn is_comp(v: &Value) -> bool {
65    v.get("schema")
66        .and_then(Value::as_str)
67        .is_some_and(|s| s.starts_with("ffcine.comp/"))
68}
69
70/// Whether the file at `path` is a comp document (JSON, or YAML by extension); `false` for
71/// timelines and unreadable files.
72pub fn is_comp_file(path: &Path) -> bool {
73    // By name first, so a comp with a syntax error reports it as a comp.
74    let name = path
75        .file_name()
76        .map(|n| n.to_string_lossy().to_lowercase())
77        .unwrap_or_default();
78    [".comp.json", ".comp.yaml", ".comp.yml"]
79        .iter()
80        .any(|e| name.ends_with(e))
81        || read(path).is_ok_and(|v| is_comp(&v))
82}
83
84/// A comp file's document as written (JSON, or YAML for `.yaml`/`.yml`), templates not
85/// expanded ([`crate::templates::expand`] does that).
86///
87/// # Errors
88/// An unreadable file or bad JSON/YAML.
89pub fn read_document(path: &Path) -> Result<Value> {
90    read(path)
91}
92
93/// A JSON or (by extension) YAML document.
94fn read(path: &Path) -> Result<Value> {
95    let text = std::fs::read_to_string(path).map_err(|source| Error::Io {
96        path: path.to_owned(),
97        source,
98    })?;
99    let yaml = path
100        .extension()
101        .is_some_and(|e| e.eq_ignore_ascii_case("yaml") || e.eq_ignore_ascii_case("yml"));
102    if yaml {
103        yaml_serde::from_str(&text).map_err(|e| err(path.display().to_string(), e.to_string()))
104    } else {
105        Ok(serde_json::from_str(&text)?)
106    }
107}
108
109/// The canvas: size, rate, duration, and the animated background and fade.
110#[derive(Debug, Clone, PartialEq)]
111pub struct Canvas {
112    /// Px.
113    pub size: [u32; 2],
114    pub fps: f64,
115    /// S.
116    pub duration: f64,
117    /// sRGB 0..1 (alpha ignored).
118    pub background: Track<[f64; 4]>,
119    /// 1 = the picture, 0 = `fade_colour`.
120    pub fade: Track<f64>,
121    pub fade_colour: Track<[f64; 4]>,
122}
123
124/// What a layer shows.
125#[derive(Debug, Clone, PartialEq)]
126pub enum Source {
127    /// A video file (relative paths are resolved against the comp file's directory).
128    Video(PathBuf),
129    /// A still picture (PNG, JPEG, WebP, HEIC, ...), decoded once through libav.
130    Still(PathBuf),
131    /// A colour, `[r, g, b, a]` sRGB 0..1; the alpha multiplies the opacity.
132    Solid(Track<[f64; 4]>),
133    /// `ffcine.text` parameters (`docs/text.md`); font files resolve against `dir`.
134    Text {
135        params: Map<String, Value>,
136        dir: PathBuf,
137    },
138    /// A nested comp, its canvas defaulted from the layer and its parent.
139    Comp(Box<Comp>),
140}
141
142/// `content`: the picture inside the box (`ffcine.geo.reframe/1` parameters, the box as the
143/// profile).
144#[derive(Debug, Clone, PartialEq)]
145pub(crate) struct Content {
146    pub zoom: Track<f64>,
147    pub zoom_center: Track<[f64; 2]>,
148    /// Window position on sources wider (`crop_x`) or taller (`crop_y`) than the box: 0
149    /// left/top, 1 right/bottom.
150    pub crop_x: Track<f64>,
151    pub crop_y: Track<f64>,
152    pub punch: Vec<Punch>,
153    /// `[from, to]` zoom eased over the layer (half linear, half cosine, as reframe's).
154    pub push: Option<[f64; 2]>,
155    pub push_center: Option<[f64; 2]>,
156    pub pan: Option<[[f64; 2]; 2]>,
157}
158
159/// A punch-in: `zoom` times the base zoom from `at` to `to` (layer seconds).
160#[derive(Debug, Clone, PartialEq)]
161pub(crate) struct Punch {
162    pub at: f64,
163    pub to: Option<f64>,
164    pub zoom: f64,
165    pub center: Option<[f64; 2]>,
166}
167
168/// `crop`: fractions of the source cut from each side, and the soft edge (a fraction of the
169/// source width), as `ffcine.geo.crop/1`.
170#[derive(Debug, Clone, PartialEq)]
171pub(crate) struct CropSpec {
172    pub left: Track<f64>,
173    pub right: Track<f64>,
174    pub top: Track<f64>,
175    pub bottom: Track<f64>,
176    pub feather: Track<f64>,
177}
178
179/// How the source fills the layer's box.
180#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
181pub enum Fit {
182    /// Fills the box, cropping the source to its aspect.
183    #[default]
184    Cover,
185    /// The whole source inside the box, centred.
186    Contain,
187    /// Stretched to the box.
188    Fill,
189    /// Source pixels 1:1 (canvas pixels), centred, cropped to the box.
190    None,
191}
192
193/// A mask's shape.
194#[derive(Debug, Clone, Copy, PartialEq, Eq)]
195pub enum MaskShape {
196    Rect,
197    Ellipse,
198}
199
200/// A shape mask in the layer's alpha.
201#[derive(Debug, Clone, PartialEq)]
202pub struct Mask {
203    pub shape: MaskShape,
204    /// `[x, y, w, h]`, box px.
205    pub rect: Track<[f64; 4]>,
206    /// Width of the soft edge, box px, centred on the shape's edge.
207    pub feather: Track<f64>,
208    pub invert: bool,
209}
210
211/// Uniform or per-axis scale.
212#[derive(Debug, Clone, PartialEq)]
213pub(crate) enum Scale {
214    Uniform(Track<f64>),
215    Xy(Track<[f64; 2]>),
216}
217
218/// Where the box lands: `position + R·S·q` for a box point `q` (px from the anchor).
219#[derive(Debug, Clone, PartialEq)]
220pub(crate) struct Transform {
221    pub position: Track<[f64; 2]>,
222    pub anchor: Track<[f64; 2]>,
223    pub scale: Scale,
224    pub rotation: Track<f64>,
225    pub opacity: Track<f64>,
226}
227
228/// One layer.
229#[derive(Debug, Clone, PartialEq)]
230pub struct CompLayer {
231    pub id: Option<String>,
232    pub source: Source,
233    /// Comp seconds: visible for `in_ <= t < out`.
234    pub in_: f64,
235    pub out: f64,
236    /// Source seconds shown at `in_`.
237    pub source_in: f64,
238    /// Box, px.
239    pub size: [f64; 2],
240    pub fit: Fit,
241    pub(crate) content: Option<Content>,
242    pub(crate) crop: Option<CropSpec>,
243    pub(crate) transform: Transform,
244    pub(crate) radius: Track<f64>,
245    pub mask: Option<Mask>,
246    pub blend: BlendMode,
247    pub(crate) fade: Track<f64>,
248    pub(crate) fade_colour: Track<[f64; 4]>,
249    /// `grade` (video, still and comp layers).
250    pub(crate) grade: Option<LayerGrade>,
251    /// `time` (video layers).
252    pub(crate) time: Option<crate::time::Remap>,
253    /// `filter`, the libavfilter escape hatch (video layers), and its resolved reach.
254    pub(crate) filter: Option<(Node, Reach)>,
255    /// The comp's vars, then the layer's.
256    pub(crate) vars: Vars,
257}
258
259/// The look params a layer's `grade` may animate (the look hook's `//!PARAM`s).
260pub(crate) const LOOK_PARAMS: [&str; 8] = [
261    "exposure",
262    "warmth",
263    "tint",
264    "contrast",
265    "gamma",
266    "lift",
267    "gain",
268    "saturation",
269];
270
271/// A layer's `grade` (`docs/compositing.md#per-layer-grade-and-filters`): resolved at lowering
272/// through the comp's project like a timeline clip's `ffcine.color.grade/1`.
273#[derive(Debug, Clone, PartialEq, Default)]
274pub(crate) struct LayerGrade {
275    /// The constant fields, as a clip's grade effect.
276    pub params: ClipGrade,
277    /// Animated look params ([`LOOK_PARAMS`]), set over the resolved grade per frame.
278    pub tracks: Vec<(&'static str, Track<f64>)>,
279    /// `filters`, after the resolved grade's `extra`.
280    pub filters: Vec<FilterSpec>,
281    /// `shaders`, after the filters.
282    pub shaders: Vec<ShaderSpec>,
283}
284
285/// A `grade.filters` entry.
286#[derive(Debug, Clone, PartialEq)]
287pub(crate) enum FilterSpec {
288    /// An ffmpeg filter chain (`ffcine_render::extra`), every filter and option supported.
289    Chain(String),
290    /// `{"name": {option: number, track or string}}`.
291    Object {
292        name: String,
293        opts: Vec<(String, FilterOpt)>,
294    },
295}
296
297#[derive(Debug, Clone, PartialEq)]
298pub(crate) enum FilterOpt {
299    Num(Track<f64>),
300    Text(String),
301}
302
303/// A `grade.shaders` entry, as `ffcine.fx.shader/1`: `//!PARAM`s as layer-clock tracks.
304#[derive(Debug, Clone, PartialEq)]
305pub(crate) struct ShaderSpec {
306    pub shader: String,
307    pub params: Vec<(String, Track<f64>)>,
308}
309
310/// The project a comp's grades resolve through: `project` (inline, or a timeline's), else the
311/// default one.
312#[derive(Debug, Clone, PartialEq)]
313pub struct CompProject {
314    pub project: Project,
315    /// LUT and shader paths resolve against it: the timeline's directory for a borrowed
316    /// project, else the comp file's.
317    pub dir: PathBuf,
318    /// Whether `project` was given: then every video, still and comp layer is graded.
319    pub given: bool,
320}
321
322/// A composition: a canvas and layers, bottom first.
323#[derive(Debug, Clone, PartialEq)]
324pub struct Comp {
325    pub canvas: Canvas,
326    pub layers: Vec<CompLayer>,
327    pub(crate) vars: Vars,
328    /// Shared with nested comps.
329    pub project: Arc<CompProject>,
330}
331
332/// Rejects keys that aren't `known` (`x_*` keys are kept and ignored); `later` names keys of a
333/// later step of the plan.
334fn check_keys(
335    m: &Map<String, Value>,
336    path: &str,
337    known: &[&str],
338    later: &[(&str, &str)],
339) -> Result<()> {
340    for k in m.keys() {
341        if k.starts_with("x_") || known.contains(&k.as_str()) {
342            continue;
343        }
344        let at = format!("{path}.{k}");
345        return Err(match later.iter().find(|(n, _)| n == k) {
346            Some((_, what)) => err(at, format!("not supported yet ({what})")),
347            None => err(at, format!("unknown key (known: {})", known.join(", "))),
348        });
349    }
350    Ok(())
351}
352
353/// Appended to a per-layer `filters` entry the GPU path can't run.
354pub(crate) const FILTER_HINT: &str =
355    "(other libavfilter graphs run through the layer's `filter`, the escape hatch)";
356
357/// A layer's `filter` (`docs/compositing.md#libavfilter-escape-hatch`) and its reach.
358fn filter_node(v: &Value, path: &str, dir: &Path) -> Result<(Node, Reach)> {
359    let m = object(v, path)?;
360    check_keys(
361        m,
362        path,
363        &[
364            "graph",
365            "hw",
366            "clock",
367            "preroll",
368            "postroll",
369            "whole_clip",
370            "timing",
371        ],
372        &[],
373    )?;
374    let at = |k: &str| format!("{path}.{k}");
375    let word = |k: &str| -> Result<Option<&str>> {
376        m.get(k)
377            .map(|v| {
378                v.as_str()
379                    .ok_or_else(|| err(at(k), format!("expected a string, got {v}")))
380            })
381            .transpose()
382    };
383    let graph = word("graph")?
384        .filter(|g| !g.trim().is_empty())
385        .ok_or_else(|| err(at("graph"), "missing (a libavfilter graph)"))?;
386    let hw = match word("hw")? {
387        None => Hw::Cpu,
388        Some(s) => {
389            Hw::parse(s).ok_or_else(|| err(at("hw"), format!("{s:?}: cpu, cuda or vulkan")))?
390        }
391    };
392    let clock = match word("clock")? {
393        None => fnode::Clock::Layer,
394        Some(s) => fnode::Clock::parse(s)
395            .ok_or_else(|| err(at("clock"), format!("{s:?}: layer or comp")))?,
396    };
397    let free_timing = match word("timing")? {
398        None | Some("keep") => false,
399        Some("free") => true,
400        Some(s) => return Err(err(at("timing"), format!("{s:?}: keep or free"))),
401    };
402    let roll = |k: &str| -> Result<Option<u32>> {
403        m.get(k)
404            .map(|v| {
405                v.as_u64()
406                    .and_then(|n| u32::try_from(n).ok())
407                    .filter(|n| *n <= MAX_ROLL)
408                    .ok_or_else(|| err(at(k), format!("{v}: frames, 0 to {MAX_ROLL}")))
409            })
410            .transpose()
411    };
412    let whole_clip = m
413        .get("whole_clip")
414        .map(|v| {
415            v.as_bool()
416                .ok_or_else(|| err(at("whole_clip"), format!("expected true or false, got {v}")))
417        })
418        .transpose()?;
419    let mut node = Node {
420        graph: graph.to_owned(),
421        hw,
422        clock,
423        preroll: roll("preroll")?,
424        postroll: roll("postroll")?,
425        whole_clip,
426        free_timing,
427        files: Vec::new(),
428        run: graph.to_owned(),
429        labelled: false,
430    };
431    let reach = node.resolve().map_err(|e| err(at("graph"), e))?;
432    node.locate(dir).map_err(|e| err(at("graph"), e))?;
433    Ok((node, reach))
434}
435
436fn object<'a>(v: &'a Value, path: &str) -> Result<&'a Map<String, Value>> {
437    v.as_object()
438        .ok_or_else(|| err(path, format!("expected an object, got {v}")))
439}
440
441/// A colour: `[r, g, b]`, `[r, g, b, a]`, a name or `#rrggbb[aa]`, keys of those, or an array
442/// of four constants or expressions.
443fn colour(v: &Value, scope: &Scope, path: &str) -> Result<Track<[f64; 4]>> {
444    fn rgba(v: &Value) -> Value {
445        match v {
446            Value::Array(a) if a.len() == 3 && a.iter().all(Value::is_number) => {
447                let mut a = a.clone();
448                a.push(1.0.into());
449                Value::Array(a)
450            }
451            v => v.clone(),
452        }
453    }
454    let norm = if is_keys(v) {
455        Value::Array(
456            v.as_array()
457                .into_iter()
458                .flatten()
459                .map(|k| {
460                    let mut k = k.clone();
461                    if let Some(x) = k.get("v").map(rgba) {
462                        k["v"] = x;
463                    }
464                    k
465                })
466                .collect(),
467        )
468    } else {
469        rgba(v)
470    };
471    Track::parse_in(&norm, scope, path)
472}
473
474/// `name` in `m` as a track, else `default`.
475fn track<T: ffcine_core::anim::Animatable>(
476    m: &Map<String, Value>,
477    name: &str,
478    scope: &Scope,
479    path: &str,
480    default: T,
481) -> Result<Track<T>> {
482    m.get(name).map_or(Ok(Track::Const(default)), |v| {
483        Track::parse_in(v, scope, &format!("{path}.{name}"))
484    })
485}
486
487fn pair(v: &Value, path: &str) -> Result<[f64; 2]> {
488    match v.as_array().map(Vec::as_slice) {
489        Some([a, b]) => match (a.as_f64(), b.as_f64()) {
490            (Some(a), Some(b)) if a > 0.0 && b > 0.0 => Ok([a, b]),
491            _ => Err(err(path, format!("expected [w, h] > 0, got {v}"))),
492        },
493        _ => Err(err(path, format!("expected [w, h], got {v}"))),
494    }
495}
496
497/// A frame rate: a number, `"30"` or `"30000/1001"`.
498fn fps(v: &Value, path: &str) -> Result<f64> {
499    let r = match v {
500        Value::Number(n) => n.as_f64(),
501        Value::String(s) => match s.split_once('/') {
502            Some((n, d)) => n
503                .trim()
504                .parse::<f64>()
505                .ok()
506                .zip(d.trim().parse::<f64>().ok())
507                .map(|(n, d)| n / d),
508            None => s.trim().parse().ok(),
509        },
510        _ => None,
511    };
512    let (lo, hi) = limits::FPS;
513    r.filter(|r| r.is_finite() && (lo..=hi).contains(r))
514        .ok_or_else(|| err(path, format!("expected a frame rate {lo} to {hi}, got {v}")))
515}
516
517/// What a comp is parsed in: the top file's directory, its `comps`, and how deep it is.
518struct Ctx<'a> {
519    dir: &'a Path,
520    comps: &'a Map<String, Value>,
521    depth: usize,
522    project: &'a Arc<CompProject>,
523}
524
525/// A nested comp's defaults, from the layer that uses it.
526struct Parent<'a> {
527    vars: &'a Vars,
528    fps: f64,
529    duration: f64,
530    size: [f64; 2],
531}
532
533/// A `[x, y]` of numbers.
534fn xy(v: &Value, path: &str) -> Result<[f64; 2]> {
535    match v.as_array().map(Vec::as_slice) {
536        Some([a, b]) => match (a.as_f64(), b.as_f64()) {
537            (Some(a), Some(b)) if a.is_finite() && b.is_finite() => Ok([a, b]),
538            _ => Err(err(path, format!("expected [x, y], got {v}"))),
539        },
540        _ => Err(err(path, format!("expected [x, y], got {v}"))),
541    }
542}
543
544/// `content`: reframe parameters, the layer's box as the profile.
545fn content(v: &Value, scope: &Scope, path: &str) -> Result<Content> {
546    let m = object(v, path)?;
547    check_keys(
548        m,
549        path,
550        &[
551            "zoom",
552            "zoom_center",
553            "crop_x",
554            "crop_y",
555            "focus",
556            "punch",
557            "push",
558            "push_center",
559            "pan",
560        ],
561        &[],
562    )
563    .map_err(|e| match e {
564        Error::Track { path, msg } if msg.starts_with("unknown key") => Error::Track {
565            msg: format!("{msg}; the layer's `fit` places the picture, not content's"),
566            path,
567        },
568        e => e,
569    })?;
570    let at = |k: &str| format!("{path}.{k}");
571    let focus = m.get("focus").map(|v| xy(v, &at("focus"))).transpose()?;
572    let [fx, fy] = focus.unwrap_or([0.5, 0.5]);
573    let punch = match m.get("punch") {
574        None => Vec::new(),
575        Some(Value::Array(a)) => a
576            .iter()
577            .enumerate()
578            .map(|(i, p)| {
579                let pp = format!("{path}.punch[{i}]");
580                let o = object(p, &pp)?;
581                check_keys(o, &pp, &["at", "to", "zoom", "center"], &[])?;
582                let num = |k: &str| o.get(k).and_then(Value::as_f64);
583                let punch = Punch {
584                    at: num("at").ok_or_else(|| err(format!("{pp}.at"), "expected seconds"))?,
585                    to: num("to"),
586                    zoom: num("zoom").unwrap_or(1.25),
587                    center: o
588                        .get("center")
589                        .map(|v| xy(v, &format!("{pp}.center")))
590                        .transpose()?,
591                };
592                if !(punch.zoom.is_finite() && punch.zoom > 0.0 && punch.zoom <= limits::ZOOM) {
593                    return Err(err(
594                        format!("{pp}.zoom"),
595                        format!("expected a number > 0, at most {}", limits::ZOOM),
596                    ));
597                }
598                Ok(punch)
599            })
600            .collect::<Result<_>>()?,
601        Some(v) => return Err(err(at("punch"), format!("expected an array, got {v}"))),
602    };
603    let pan = match m.get("pan") {
604        None => None,
605        Some(v) => match v.as_array().map(Vec::as_slice) {
606            Some([a, b]) => Some([xy(a, &at("pan"))?, xy(b, &at("pan"))?]),
607            _ => {
608                return Err(err(
609                    at("pan"),
610                    format!("expected [[x, y], [x, y]], got {v}"),
611                ));
612            }
613        },
614    };
615    let push = m.get("push").map(|v| xy(v, &at("push"))).transpose()?;
616    if push.is_some_and(|p| p.iter().any(|z| !(*z > 0.0 && *z <= limits::ZOOM))) {
617        return Err(err(
618            at("push"),
619            format!("zooms must be > 0, at most {}", limits::ZOOM),
620        ));
621    }
622    Ok(Content {
623        zoom: track(m, "zoom", scope, path, 1.0)?,
624        zoom_center: track(m, "zoom_center", scope, path, [fx, fy])?,
625        crop_x: track(m, "crop_x", scope, path, fx)?,
626        crop_y: track(m, "crop_y", scope, path, fy)?,
627        punch,
628        push,
629        push_center: m
630            .get("push_center")
631            .map(|v| xy(v, &at("push_center")))
632            .transpose()?,
633        pan,
634    })
635}
636
637/// `crop`: fractions of the source.
638fn crop(v: &Value, scope: &Scope, path: &str) -> Result<CropSpec> {
639    let m = object(v, path)?;
640    check_keys(m, path, &["left", "right", "top", "bottom", "feather"], &[])?;
641    Ok(CropSpec {
642        left: track(m, "left", scope, path, 0.0)?,
643        right: track(m, "right", scope, path, 0.0)?,
644        top: track(m, "top", scope, path, 0.0)?,
645        bottom: track(m, "bottom", scope, path, 0.0)?,
646        feather: track(m, "feather", scope, path, 0.0)?,
647    })
648}
649
650impl CompLayer {
651    /// Layers and video layers this one stands for (itself and a nested comp's).
652    fn count(&self) -> (usize, usize) {
653        match &self.source {
654            Source::Video(_) => (1, 1),
655            Source::Comp(c) => {
656                let (n, v) = c.count();
657                (n + 1, v)
658            }
659            _ => (1, 0),
660        }
661    }
662}
663
664impl Comp {
665    /// Reads `NAME.comp.json` or `NAME.comp.yaml`; relative media paths resolve against its
666    /// directory.
667    pub fn from_file(path: &Path) -> Result<Self> {
668        let v = read(path)?;
669        Self::from_value(&v, path.parent().unwrap_or(Path::new(".")))
670    }
671
672    /// Reads a comp document; `dir` resolves relative media paths.
673    pub fn from_value(v: &Value, dir: &Path) -> Result<Self> {
674        let expanded = crate::templates::expand(v)?;
675        let m = object(&expanded, "comp")?;
676        Self::schema(m, "schema", false)?;
677        check_keys(
678            m,
679            "comp",
680            &["schema", "canvas", "vars", "comps", "layers", "project"],
681            &[],
682        )?;
683        let project = Arc::new(match m.get("project") {
684            None => CompProject {
685                project: Project::default(),
686                dir: dir.to_owned(),
687                given: false,
688            },
689            Some(v) => comp_project(v, dir)?,
690        });
691        let empty = Map::new();
692        let comps = match m.get("comps") {
693            Some(v) => object(v, "comps")?,
694            None => &empty,
695        };
696        let ctx = Ctx {
697            dir,
698            comps,
699            depth: 0,
700            project: &project,
701        };
702        Self::parse(m, "", &ctx, None)
703    }
704
705    fn schema(m: &Map<String, Value>, path: &str, optional: bool) -> Result<()> {
706        match m.get("schema").and_then(Value::as_str) {
707            Some(s) if s == format!("{SCHEMA}/{VERSION}") => {}
708            Some(s) if s.starts_with("ffcine.comp/") => {
709                return Err(err(
710                    "schema",
711                    format!("unsupported version {s:?} (this ffcine reads {SCHEMA}/{VERSION})"),
712                ));
713            }
714            None if optional => {}
715            other => {
716                return Err(err(
717                    path,
718                    format!("expected \"{SCHEMA}/{VERSION}\", got {other:?}"),
719                ));
720            }
721        }
722        Ok(())
723    }
724
725    /// A comp's canvas, vars and layers; `parent` is `None` at the top, else the using layer's
726    /// box, vars and its comp's rate and duration (`p` prefixes error paths).
727    #[allow(
728        clippy::too_many_lines,
729        clippy::many_single_char_names,
730        clippy::needless_pass_by_value
731    )]
732    fn parse(
733        m: &Map<String, Value>,
734        p: &str,
735        ctx: &Ctx<'_>,
736        parent: Option<Parent<'_>>,
737    ) -> Result<Self> {
738        let at = |k: &str| {
739            if p.is_empty() {
740                k.to_owned()
741            } else {
742                format!("{p}.{k}")
743            }
744        };
745        let mut vars = parent.as_ref().map_or_else(Vars::new, |p| p.vars.clone());
746        if let Some(v) = m.get("vars") {
747            vars.push_level(object(v, &at("vars"))?, Clock::Layer, &at("vars"))?;
748        }
749        let scope = Scope::with_vars(Clock::Layer, &vars);
750
751        let empty = Map::new();
752        let c = match (m.get("canvas"), &parent) {
753            (Some(v), _) => object(v, &at("canvas"))?,
754            (None, Some(_)) => &empty,
755            (None, None) => return Err(err("canvas", "missing")),
756        };
757        let cp = at("canvas");
758        let ca = |k: &str| format!("{cp}.{k}");
759        check_keys(
760            c,
761            &cp,
762            &[
763                "size",
764                "fps",
765                "duration",
766                "background",
767                "fade",
768                "fade_colour",
769            ],
770            &[
771                ("grain", "canvas grain, see docs/compositing.md#not-yet"),
772                ("filter", "canvas.filter, see docs/compositing.md#not-yet"),
773            ],
774        )?;
775        let size = match (c.get("size"), &parent) {
776            (Some(v), _) => pair(v, &ca("size"))?,
777            (None, Some(p)) => p.size,
778            (None, None) => return Err(err("canvas.size", "missing")),
779        };
780        let [w, h] = size.map(|v| v.round().max(1.0));
781        if w.max(h) > limits::CANVAS_SIDE || w * h > limits::CANVAS_PIXELS {
782            return Err(err(
783                ca("size"),
784                format!(
785                    "{w}x{h}: at most {} px a side and {} px in all",
786                    limits::CANVAS_SIDE,
787                    limits::CANVAS_PIXELS
788                ),
789            ));
790        }
791        if w.max(h) / w.min(h) > limits::ASPECT {
792            return Err(err(
793                ca("size"),
794                format!(
795                    "{w}x{h}: the long side is at most {} times the short",
796                    limits::ASPECT
797                ),
798            ));
799        }
800        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
801        let size = size.map(|v| v.round().max(1.0) as u32);
802        let fps = match (c.get("fps"), &parent) {
803            (Some(v), Some(p)) => {
804                let f = fps(v, &ca("fps"))?;
805                if (f - p.fps).abs() > 1e-9 {
806                    return Err(err(
807                        ca("fps"),
808                        format!(
809                            "{f}: a nested comp runs at its parent's rate ({}); time remapping \
810                             of nested comps is not supported yet",
811                            p.fps
812                        ),
813                    ));
814                }
815                f
816            }
817            (Some(v), None) => fps(v, &ca("fps"))?,
818            (None, Some(p)) => p.fps,
819            (None, None) => return Err(err("canvas.fps", "missing")),
820        };
821        let duration = match (c.get("duration"), &parent) {
822            (None, Some(p)) => Some(p.duration),
823            (d, _) => d.and_then(Value::as_f64),
824        }
825        .filter(|d| d.is_finite() && *d > 0.0 && *d <= limits::DURATION)
826        .ok_or_else(|| {
827            err(
828                ca("duration"),
829                format!("expected seconds > 0, at most {}", limits::DURATION),
830            )
831        })?;
832        let black = Track::Const([0.0, 0.0, 0.0, 1.0]);
833        let canvas = Canvas {
834            size,
835            fps,
836            duration,
837            background: match c.get("background") {
838                Some(v) => colour(v, &scope, &ca("background"))?,
839                None => black.clone(),
840            },
841            fade: track(c, "fade", &scope, &cp, 1.0)?,
842            fade_colour: match c.get("fade_colour") {
843                Some(v) => colour(v, &scope, &ca("fade_colour"))?,
844                None => black.clone(),
845            },
846        };
847
848        let mut comp = Self {
849            canvas,
850            layers: Vec::new(),
851            vars,
852            project: Arc::clone(ctx.project),
853        };
854        let layers = match m.get("layers") {
855            Some(Value::Array(a)) => a.as_slice(),
856            Some(v) => return Err(err(at("layers"), format!("expected an array, got {v}"))),
857            None => &[],
858        };
859        if layers.len() > limits::LAYERS {
860            return Err(err(
861                at("layers"),
862                format!("{} layers: at most {}", layers.len(), limits::LAYERS),
863            ));
864        }
865        let (mut count, mut videos) = (0, 0);
866        for (i, l) in layers.iter().enumerate() {
867            let path = at(&format!("layers[{i}]"));
868            let layer = comp.layer(l, &path, ctx)?;
869            if let Some(id) = &layer.id
870                && let Some(j) = comp.layers.iter().position(|o| o.id.as_ref() == Some(id))
871            {
872                return Err(err(
873                    format!("{path}.id"),
874                    format!("{id:?} is already layers[{j}]'s id"),
875                ));
876            }
877            let (n, v) = layer.count();
878            count += n;
879            videos += v;
880            if count > limits::LAYERS {
881                return Err(err(
882                    format!("{path}.source"),
883                    format!(
884                        "at most {} layers in all (nested comps' included)",
885                        limits::LAYERS
886                    ),
887                ));
888            }
889            if videos > limits::VIDEO_LAYERS {
890                return Err(err(
891                    format!("{path}.source"),
892                    format!(
893                        "at most {} video layers (nested comps' included)",
894                        limits::VIDEO_LAYERS
895                    ),
896                ));
897            }
898            comp.layers.push(layer);
899        }
900        Ok(comp)
901    }
902
903    /// Layers and video layers, nested comps' included.
904    fn count(&self) -> (usize, usize) {
905        self.layers.iter().fold((0, 0), |(n, v), l| {
906            let (a, b) = l.count();
907            (n + a, v + b)
908        })
909    }
910
911    #[allow(clippy::too_many_lines, clippy::many_single_char_names)]
912    fn layer(&self, v: &Value, path: &str, ctx: &Ctx<'_>) -> Result<CompLayer> {
913        let dir = ctx.dir;
914        let m = object(v, path)?;
915        let text = m
916            .get("source")
917            .and_then(Value::as_object)
918            .is_some_and(|s| s.contains_key("text"));
919        let known: &[&str] = if text {
920            // A text layer places itself: its parameters are the text clip's.
921            &["id", "source", "in", "out", "vars"]
922        } else {
923            &[
924                "id",
925                "source",
926                "in",
927                "out",
928                "source_in",
929                "size",
930                "fit",
931                "content",
932                "crop",
933                "transform",
934                "radius",
935                "mask",
936                "blend",
937                "fade",
938                "fade_colour",
939                "grade",
940                "time",
941                "filter",
942                "vars",
943            ]
944        };
945        check_keys(m, path, known, &[])?;
946        let mut vars = self.vars.clone();
947        if let Some(v) = m.get("vars") {
948            vars.push_level(
949                object(v, &format!("{path}.vars"))?,
950                Clock::Layer,
951                &format!("{path}.vars"),
952            )?;
953        }
954        let scope = Scope::with_vars(Clock::Layer, &vars);
955        let [cw, ch] = self.canvas.size.map(f64::from);
956
957        // `in`, `out` and `source_in`: numbers or expressions over the vars, evaluated once.
958        let env = Env {
959            fps: self.canvas.fps,
960            canvas: [cw, ch],
961            size: [cw, ch],
962            source: [cw, ch],
963            in_: 0.0,
964            out: self.canvas.duration,
965            total: self.canvas.duration,
966            source_in: 0.0,
967            vars: Some(&vars),
968        };
969        let once = |name: &str, default: f64| -> Result<f64> {
970            let Some(v) = m.get(name) else {
971                return Ok(default);
972            };
973            let what = format!("{path}.{name}");
974            let t: Track<f64> = Track::parse_in(v, &scope, &what)?;
975            let x = t
976                .sample(Clock::Layer, 0..1, &env)
977                .map_err(|e| err(&what, e.to_string()))?[0];
978            Ok(x)
979        };
980        let in_ = once("in", 0.0)?;
981        let out = once("out", self.canvas.duration)?;
982        let source_in = once("source_in", 0.0)?;
983        if out.partial_cmp(&in_) != Some(std::cmp::Ordering::Greater) {
984            return Err(err(
985                path,
986                format!("`out` ({out}) must be after `in` ({in_})"),
987            ));
988        }
989        for (name, v) in [("in", in_), ("out", out), ("source_in", source_in)] {
990            if !(0.0..=limits::DURATION).contains(&v) {
991                return Err(err(
992                    format!("{path}.{name}"),
993                    format!("{v}: must be 0 to {} s", limits::DURATION),
994                ));
995            }
996        }
997
998        let size = match m.get("size") {
999            Some(v) => pair(v, &format!("{path}.size"))?,
1000            None => [cw, ch],
1001        };
1002        if size.iter().any(|v| *v > limits::BOX_SIDE) {
1003            return Err(err(
1004                format!("{path}.size"),
1005                format!("{size:?}: at most {} px a side", limits::BOX_SIDE),
1006            ));
1007        }
1008
1009        let sp = format!("{path}.source");
1010        let s = object(m.get("source").ok_or_else(|| err(&sp, "missing"))?, &sp)?;
1011        let file = |k: &str, v: &Value| -> Result<PathBuf> {
1012            Ok(dir
1013                .join(v.as_str().ok_or_else(|| {
1014                    err(format!("{sp}.{k}"), format!("expected a path, got {v}"))
1015                })?))
1016        };
1017        let source = match (s.len(), s.iter().next()) {
1018            (1, Some((k, v))) => match k.as_str() {
1019                "video" => Source::Video(file(k, v)?),
1020                "still" => Source::Still(file(k, v)?),
1021                "solid" => Source::Solid(colour(v, &scope, &format!("{sp}.solid"))?),
1022                "text" => {
1023                    let params = object(v, &format!("{sp}.text"))?;
1024                    if !params.get("text").is_some_and(Value::is_string) {
1025                        return Err(err(format!("{sp}.text.text"), "expected a string"));
1026                    }
1027                    Source::Text {
1028                        params: params.clone(),
1029                        dir: dir.to_owned(),
1030                    }
1031                }
1032                "comp" => {
1033                    let cp = format!("{sp}.comp");
1034                    if ctx.depth >= limits::DEPTH {
1035                        return Err(err(
1036                            &cp,
1037                            format!("comps nest at most {} levels deep", limits::DEPTH),
1038                        ));
1039                    }
1040                    let (body, bp) = match v {
1041                        Value::String(name) => (
1042                            ctx.comps.get(name).ok_or_else(|| {
1043                                err(&cp, format!("no comp {name:?} in the top-level `comps`"))
1044                            })?,
1045                            format!("comps.{name}"),
1046                        ),
1047                        v => (v, cp.clone()),
1048                    };
1049                    let b = object(body, &bp)?;
1050                    Self::schema(b, &format!("{bp}.schema"), true)?;
1051                    check_keys(
1052                        b,
1053                        &bp,
1054                        &["schema", "canvas", "vars", "layers"],
1055                        &[
1056                            ("comps", "comps are named at the top level only"),
1057                            ("project", "a nested comp uses its parent's project"),
1058                            ("template", "templates expand at the top level only"),
1059                        ],
1060                    )?;
1061                    let sub = Ctx {
1062                        dir,
1063                        comps: ctx.comps,
1064                        depth: ctx.depth + 1,
1065                        project: ctx.project,
1066                    };
1067                    let parent = Parent {
1068                        vars: &vars,
1069                        fps: self.canvas.fps,
1070                        duration: self.canvas.duration,
1071                        size,
1072                    };
1073                    Source::Comp(Box::new(Self::parse(b, &bp, &sub, Some(parent))?))
1074                }
1075                _ => {
1076                    return Err(err(
1077                        format!("{sp}.{k}"),
1078                        "unknown source (video, still, solid, text, comp)",
1079                    ));
1080                }
1081            },
1082            _ => {
1083                return Err(err(
1084                    &sp,
1085                    "expected one of {\"video\": PATH}, {\"still\": PATH}, {\"solid\": \
1086                     COLOUR}, {\"text\": {...}}, {\"comp\": NAME or {...}}",
1087                ));
1088            }
1089        };
1090        let fit = match m.get("fit").map(|v| (v.as_str(), v)) {
1091            None | Some((Some("cover"), _)) => Fit::Cover,
1092            Some((Some("contain"), _)) => Fit::Contain,
1093            Some((Some("fill"), _)) => Fit::Fill,
1094            Some((Some("none"), _)) => Fit::None,
1095            Some((_, v)) => {
1096                return Err(err(
1097                    format!("{path}.fit"),
1098                    format!("expected cover, contain, fill or none, got {v}"),
1099                ));
1100            }
1101        };
1102        let picture = matches!(
1103            source,
1104            Source::Video(_) | Source::Still(_) | Source::Comp(_)
1105        );
1106        for k in ["content", "crop"] {
1107            if !picture && m.contains_key(k) {
1108                return Err(err(
1109                    format!("{path}.{k}"),
1110                    "only video, still and comp layers have a picture to frame",
1111                ));
1112            }
1113        }
1114        let content = m
1115            .get("content")
1116            .map(|v| content(v, &scope, &format!("{path}.content")))
1117            .transpose()?;
1118        let crop = m
1119            .get("crop")
1120            .map(|v| crop(v, &scope, &format!("{path}.crop")))
1121            .transpose()?;
1122        if let Some(c) = &crop {
1123            let animated = [&c.left, &c.right, &c.top, &c.bottom]
1124                .iter()
1125                .any(|t| !matches!(t, Track::Const(_)));
1126            if animated && matches!(fit, Fit::Contain | Fit::None) {
1127                return Err(err(
1128                    format!("{path}.crop"),
1129                    "an animated crop needs fit cover or fill (contain and none would resize the box's picture)",
1130                ));
1131            }
1132            if m.contains_key("mask") && !matches!(c.feather, Track::Const(f) if f == 0.0) {
1133                return Err(err(
1134                    format!("{path}.crop.feather"),
1135                    "a feathered crop and a mask share the layer's alpha: use one",
1136                ));
1137            }
1138        }
1139
1140        let tp = format!("{path}.transform");
1141        let empty = Map::new();
1142        let t = match m.get("transform") {
1143            Some(v) => object(v, &tp)?,
1144            None => &empty,
1145        };
1146        check_keys(
1147            t,
1148            &tp,
1149            &["position", "anchor", "scale", "rotation", "opacity"],
1150            &[],
1151        )?;
1152        let scale = match t.get("scale") {
1153            None => Scale::Uniform(Track::Const(1.0)),
1154            Some(v) => {
1155                let uniform = v.is_number()
1156                    || v.is_string()
1157                    || (is_keys(v) && v[0]["v"].is_number())
1158                    || v.get("keys")
1159                        .is_some_and(|k| is_keys(k) && k[0]["v"].is_number());
1160                if uniform {
1161                    Scale::Uniform(Track::parse_in(v, &scope, &format!("{tp}.scale"))?)
1162                } else {
1163                    Scale::Xy(Track::parse_in(v, &scope, &format!("{tp}.scale"))?)
1164                }
1165            }
1166        };
1167        let transform = Transform {
1168            position: track(t, "position", &scope, &tp, [cw / 2.0, ch / 2.0])?,
1169            anchor: track(t, "anchor", &scope, &tp, [0.5, 0.5])?,
1170            scale,
1171            rotation: track(t, "rotation", &scope, &tp, 0.0)?,
1172            opacity: track(t, "opacity", &scope, &tp, 1.0)?,
1173        };
1174
1175        let mask = match m.get("mask") {
1176            None => None,
1177            Some(v) => {
1178                let mp = format!("{path}.mask");
1179                let k = object(v, &mp)?;
1180                check_keys(k, &mp, &["shape", "rect", "feather", "invert"], &[])?;
1181                let shape = match k.get("shape").and_then(Value::as_str) {
1182                    Some("rect") => MaskShape::Rect,
1183                    Some("ellipse") => MaskShape::Ellipse,
1184                    _ => return Err(err(format!("{mp}.shape"), "expected rect or ellipse")),
1185                };
1186                Some(Mask {
1187                    shape,
1188                    rect: track(k, "rect", &scope, &mp, [0.0, 0.0, size[0], size[1]])?,
1189                    feather: track(k, "feather", &scope, &mp, 0.0)?,
1190                    invert: match k.get("invert") {
1191                        None => false,
1192                        Some(Value::Bool(b)) => *b,
1193                        Some(v) => {
1194                            return Err(err(
1195                                format!("{mp}.invert"),
1196                                format!("expected true or false, got {v}"),
1197                            ));
1198                        }
1199                    },
1200                })
1201            }
1202        };
1203        let grade = match m.get("grade") {
1204            None => None,
1205            Some(_) if !picture => {
1206                return Err(err(
1207                    format!("{path}.grade"),
1208                    "only video, still and comp layers have a picture to grade",
1209                ));
1210            }
1211            Some(v) => {
1212                let gp = format!("{path}.grade");
1213                let g = layer_grade(v, &scope, &gp)?;
1214                if !matches!(source, Source::Video(_))
1215                    && let Some(c) = &g.params.params.camera
1216                    && c != "auto"
1217                {
1218                    return Err(err(
1219                        format!("{gp}.camera"),
1220                        "a camera profile applies to video layers only (stills and comps are \
1221                         display-referred)",
1222                    ));
1223                }
1224                Some(g)
1225            }
1226        };
1227        let time = match m.get("time") {
1228            None => None,
1229            Some(_) if !matches!(source, Source::Video(_)) => {
1230                return Err(err(
1231                    format!("{path}.time"),
1232                    if matches!(source, Source::Comp(_)) {
1233                        "time remapping of nested comps is not supported yet"
1234                    } else {
1235                        "only video layers have a source time to remap"
1236                    },
1237                ));
1238            }
1239            Some(v) => Some(crate::time::parse(v, &scope, &format!("{path}.time"))?),
1240        };
1241        let filter = match m.get("filter") {
1242            None => None,
1243            Some(_) if !matches!(source, Source::Video(_)) => {
1244                return Err(err(
1245                    format!("{path}.filter"),
1246                    "only video layers take a filter graph for now (stills and nested comps: \
1247                     see docs/compositing.md#not-yet)",
1248                ));
1249            }
1250            Some(v) => Some(filter_node(v, &format!("{path}.filter"), dir)?),
1251        };
1252        let blend = match m.get("blend") {
1253            None => BlendMode::Normal,
1254            Some(v) => v
1255                .as_str()
1256                .and_then(|s| BlendMode::from_str(s).ok())
1257                .ok_or_else(|| err(format!("{path}.blend"), format!("unknown blend mode {v}")))?,
1258        };
1259        Ok(CompLayer {
1260            id: match m.get("id") {
1261                None => None,
1262                Some(Value::String(s)) => Some(s.clone()),
1263                Some(v) => {
1264                    return Err(err(
1265                        format!("{path}.id"),
1266                        format!("expected a string, got {v}"),
1267                    ));
1268                }
1269            },
1270            source,
1271            in_,
1272            out,
1273            source_in,
1274            size,
1275            fit,
1276            content,
1277            crop,
1278            transform,
1279            radius: track(m, "radius", &scope, path, 0.0)?,
1280            mask,
1281            blend,
1282            fade: track(m, "fade", &scope, path, 1.0)?,
1283            fade_colour: match m.get("fade_colour") {
1284                Some(v) => colour(v, &scope, &format!("{path}.fade_colour"))?,
1285                None => Track::Const([0.0, 0.0, 0.0, 1.0]),
1286            },
1287            grade,
1288            time,
1289            filter,
1290            vars,
1291        })
1292    }
1293}
1294
1295/// `project`: an inline `ffcine.project/1`, or a timeline file (relative to `dir`) whose project
1296/// is borrowed.
1297fn comp_project(v: &Value, dir: &Path) -> Result<CompProject> {
1298    match v {
1299        Value::String(f) => {
1300            let path = dir.join(f);
1301            let t = Timeline::from_file(&path).map_err(|e| err("project", e.to_string()))?;
1302            let project = t
1303                .project()
1304                .ok_or_else(|| err("project", format!("{f}: the timeline has no project")))?
1305                .map_err(|e| err("project", format!("{f}: {e}")))?;
1306            Ok(CompProject {
1307                project,
1308                dir: path.parent().unwrap_or(Path::new(".")).to_owned(),
1309                given: true,
1310            })
1311        }
1312        Value::Object(m) => {
1313            match m.get("schema").and_then(Value::as_str) {
1314                None | Some(ffcine_timeline::project::PROJECT_SCHEMA) => {}
1315                Some(s) => {
1316                    return Err(err(
1317                        "project.schema",
1318                        format!(
1319                            "expected {:?}, got {s:?}",
1320                            ffcine_timeline::project::PROJECT_SCHEMA
1321                        ),
1322                    ));
1323                }
1324            }
1325            let mut m = m.clone();
1326            m.entry("schema")
1327                .or_insert_with(|| ffcine_timeline::project::PROJECT_SCHEMA.into());
1328            let project: Project = serde_json::from_value(Value::Object(m))
1329                .map_err(|e| err("project", e.to_string()))?;
1330            Ok(CompProject {
1331                project,
1332                dir: dir.to_owned(),
1333                given: true,
1334            })
1335        }
1336        v => Err(err(
1337            "project",
1338            format!("expected an ffcine.project/1 object or a timeline path, got {v}"),
1339        )),
1340    }
1341}
1342
1343/// A layer's `grade` object.
1344#[allow(clippy::many_single_char_names, clippy::too_many_lines)]
1345fn layer_grade(v: &Value, scope: &Scope, path: &str) -> Result<LayerGrade> {
1346    let m = object(v, path)?;
1347    let mut known = vec![
1348        "look",
1349        "camera",
1350        "lut",
1351        "lut_strength",
1352        "rgb_gain",
1353        "sharpen",
1354        "filters",
1355        "shaders",
1356    ];
1357    known.extend(LOOK_PARAMS);
1358    check_keys(
1359        m,
1360        path,
1361        &known,
1362        &[
1363            ("cdl", "per-layer CDL, see docs/compositing.md#not-yet"),
1364            (
1365                "denoise",
1366                "per-layer denoise, see docs/compositing.md#not-yet",
1367            ),
1368            (
1369                "stabilize",
1370                "per-layer stabilize, see docs/compositing.md#not-yet",
1371            ),
1372            ("grain", "per-layer grain, see docs/compositing.md#not-yet"),
1373            ("extra", "use `filters`"),
1374        ],
1375    )?;
1376    let at = |k: &str| format!("{path}.{k}");
1377    let mut consts = Map::new();
1378    let mut tracks = Vec::new();
1379    for k in LOOK_PARAMS {
1380        if let Some(v) = m.get(k) {
1381            match Track::<f64>::parse_in(v, scope, &at(k))? {
1382                Track::Const(c) => {
1383                    consts.insert(k.into(), c.into());
1384                }
1385                t => tracks.push((k, t)),
1386            }
1387        }
1388    }
1389    for k in ["look", "camera", "lut"] {
1390        if let Some(v) = m.get(k) {
1391            let s = v
1392                .as_str()
1393                .ok_or_else(|| err(at(k), format!("expected a string, got {v}")))?;
1394            if k == "camera" {
1395                ProfileChoice::from_str(s).map_err(|e| err(at(k), e.to_string()))?;
1396            }
1397            consts.insert(k.into(), s.into());
1398        }
1399    }
1400    if let Some(v) = m.get("lut_strength") {
1401        let x = match v.as_f64() {
1402            Some(x) if (0.0..=1.0).contains(&x) => x,
1403            Some(x) => return Err(err(at("lut_strength"), format!("{x}: expected 0 to 1"))),
1404            None => {
1405                return Err(err(
1406                    at("lut_strength"),
1407                    "expected a number 0 to 1 (an animated lut_strength is not supported yet, \
1408                     see docs/compositing.md#not-yet)",
1409                ));
1410            }
1411        };
1412        consts.insert("lut_strength".into(), x.into());
1413    }
1414    if let Some(v) = m.get("sharpen") {
1415        match v.as_f64() {
1416            Some(x) if x.is_finite() && x >= 0.0 => {
1417                consts.insert("sharpen".into(), x.into());
1418            }
1419            _ => {
1420                return Err(err(
1421                    at("sharpen"),
1422                    format!("expected a number >= 0 (held per layer), got {v}"),
1423                ));
1424            }
1425        }
1426    }
1427    if let Some(v) = m.get("rgb_gain") {
1428        match v.as_array().map(Vec::as_slice) {
1429            Some([r, g, b])
1430                if [r, g, b]
1431                    .iter()
1432                    .all(|c| c.as_f64().is_some_and(|c| c.is_finite() && c >= 0.0)) =>
1433            {
1434                consts.insert("rgb_gain".into(), v.clone());
1435            }
1436            _ => {
1437                return Err(err(
1438                    at("rgb_gain"),
1439                    format!("expected [r, g, b], numbers >= 0 (held per layer), got {v}"),
1440                ));
1441            }
1442        }
1443    }
1444    let params: ClipGrade =
1445        serde_json::from_value(Value::Object(consts)).map_err(|e| err(path, e.to_string()))?;
1446
1447    let list = |k: &str| -> Result<&[Value]> {
1448        match m.get(k) {
1449            None => Ok(&[]),
1450            Some(Value::Array(a)) => Ok(a.as_slice()),
1451            Some(v) => Err(err(at(k), format!("expected an array, got {v}"))),
1452        }
1453    };
1454    let mut filters = Vec::new();
1455    for (i, f) in list("filters")?.iter().enumerate() {
1456        let fp = format!("{path}.filters[{i}]");
1457        filters.push(match f {
1458            Value::String(chain) => {
1459                ffcine_render::extra::strict_hooks(std::slice::from_ref(chain))
1460                    .map_err(|e| err(&fp, format!("{e} {FILTER_HINT}")))?;
1461                FilterSpec::Chain(chain.clone())
1462            }
1463            Value::Object(o) if o.len() == 1 => {
1464                let (name, opts) = o.iter().next().unwrap_or_else(|| unreachable!());
1465                let op = format!("{fp}.{name}");
1466                let mut out = Vec::new();
1467                for (k, v) in object(opts, &op)? {
1468                    out.push((
1469                        k.clone(),
1470                        match v {
1471                            Value::String(s) => FilterOpt::Text(s.clone()),
1472                            v => FilterOpt::Num(Track::parse_in(v, scope, &format!("{op}.{k}"))?),
1473                        },
1474                    ));
1475                }
1476                FilterSpec::Object {
1477                    name: name.clone(),
1478                    opts: out,
1479                }
1480            }
1481            v => {
1482                return Err(err(
1483                    &fp,
1484                    format!(
1485                        "expected a filter chain string or {{\"filter\": {{option: value}}}}, got {v}"
1486                    ),
1487                ));
1488            }
1489        });
1490    }
1491    let mut shaders = Vec::new();
1492    for (i, v) in list("shaders")?.iter().enumerate() {
1493        let sp = format!("{path}.shaders[{i}]");
1494        let o = object(v, &sp)?;
1495        check_keys(o, &sp, &["shader", "params"], &[])?;
1496        let shader = o
1497            .get("shader")
1498            .and_then(Value::as_str)
1499            .ok_or_else(|| err(format!("{sp}.shader"), "expected a name or path"))?
1500            .to_owned();
1501        let mut params = Vec::new();
1502        if let Some(p) = o.get("params") {
1503            for (k, v) in object(p, &format!("{sp}.params"))? {
1504                params.push((
1505                    k.clone(),
1506                    Track::parse_in(v, scope, &format!("{sp}.params.{k}"))?,
1507                ));
1508            }
1509        }
1510        shaders.push(ShaderSpec { shader, params });
1511    }
1512    Ok(LayerGrade {
1513        params,
1514        tracks,
1515        filters,
1516        shaders,
1517    })
1518}