1use 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
15pub 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
26pub mod limits {
28 pub const CANVAS_SIDE: f64 = 16384.0;
30 pub const CANVAS_PIXELS: f64 = 8192.0 * 8192.0;
32 pub const ASPECT: f64 = 4.0;
34 pub const FPS: (f64, f64) = (1.0, 240.0);
36 pub const DURATION: f64 = 3600.0;
38 pub const LAYERS: usize = 64;
40 pub const VIDEO_LAYERS: usize = 16;
42 pub const BOX_SIDE: f64 = 65536.0;
44 pub const TEXTURE_SIDE: f64 = 16384.0;
46 pub const COORD: f64 = 1e9;
48 pub const ROTATION: f64 = 1e6;
50 pub const SPEED: f64 = 100.0;
52 pub const DEPTH: usize = 4;
54 pub const ZOOM: f64 = 1e4;
57 pub const STILL_SIDE: u32 = ffcine_render::STILL_SIDE;
59 pub const STILL_PIXELS: u64 = ffcine_render::STILL_PIXELS;
61}
62
63pub 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
70pub fn is_comp_file(path: &Path) -> bool {
73 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
84pub fn read_document(path: &Path) -> Result<Value> {
90 read(path)
91}
92
93fn 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#[derive(Debug, Clone, PartialEq)]
111pub struct Canvas {
112 pub size: [u32; 2],
114 pub fps: f64,
115 pub duration: f64,
117 pub background: Track<[f64; 4]>,
119 pub fade: Track<f64>,
121 pub fade_colour: Track<[f64; 4]>,
122}
123
124#[derive(Debug, Clone, PartialEq)]
126pub enum Source {
127 Video(PathBuf),
129 Still(PathBuf),
131 Solid(Track<[f64; 4]>),
133 Text {
135 params: Map<String, Value>,
136 dir: PathBuf,
137 },
138 Comp(Box<Comp>),
140}
141
142#[derive(Debug, Clone, PartialEq)]
145pub(crate) struct Content {
146 pub zoom: Track<f64>,
147 pub zoom_center: Track<[f64; 2]>,
148 pub crop_x: Track<f64>,
151 pub crop_y: Track<f64>,
152 pub punch: Vec<Punch>,
153 pub push: Option<[f64; 2]>,
155 pub push_center: Option<[f64; 2]>,
156 pub pan: Option<[[f64; 2]; 2]>,
157}
158
159#[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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
181pub enum Fit {
182 #[default]
184 Cover,
185 Contain,
187 Fill,
189 None,
191}
192
193#[derive(Debug, Clone, Copy, PartialEq, Eq)]
195pub enum MaskShape {
196 Rect,
197 Ellipse,
198}
199
200#[derive(Debug, Clone, PartialEq)]
202pub struct Mask {
203 pub shape: MaskShape,
204 pub rect: Track<[f64; 4]>,
206 pub feather: Track<f64>,
208 pub invert: bool,
209}
210
211#[derive(Debug, Clone, PartialEq)]
213pub(crate) enum Scale {
214 Uniform(Track<f64>),
215 Xy(Track<[f64; 2]>),
216}
217
218#[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#[derive(Debug, Clone, PartialEq)]
230pub struct CompLayer {
231 pub id: Option<String>,
232 pub source: Source,
233 pub in_: f64,
235 pub out: f64,
236 pub source_in: f64,
238 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 pub(crate) grade: Option<LayerGrade>,
251 pub(crate) time: Option<crate::time::Remap>,
253 pub(crate) filter: Option<(Node, Reach)>,
255 pub(crate) vars: Vars,
257}
258
259pub(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#[derive(Debug, Clone, PartialEq, Default)]
274pub(crate) struct LayerGrade {
275 pub params: ClipGrade,
277 pub tracks: Vec<(&'static str, Track<f64>)>,
279 pub filters: Vec<FilterSpec>,
281 pub shaders: Vec<ShaderSpec>,
283}
284
285#[derive(Debug, Clone, PartialEq)]
287pub(crate) enum FilterSpec {
288 Chain(String),
290 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#[derive(Debug, Clone, PartialEq)]
305pub(crate) struct ShaderSpec {
306 pub shader: String,
307 pub params: Vec<(String, Track<f64>)>,
308}
309
310#[derive(Debug, Clone, PartialEq)]
313pub struct CompProject {
314 pub project: Project,
315 pub dir: PathBuf,
318 pub given: bool,
320}
321
322#[derive(Debug, Clone, PartialEq)]
324pub struct Comp {
325 pub canvas: Canvas,
326 pub layers: Vec<CompLayer>,
327 pub(crate) vars: Vars,
328 pub project: Arc<CompProject>,
330}
331
332fn 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
353pub(crate) const FILTER_HINT: &str =
355 "(other libavfilter graphs run through the layer's `filter`, the escape hatch)";
356
357fn 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
441fn 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
474fn 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
497fn 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
517struct Ctx<'a> {
519 dir: &'a Path,
520 comps: &'a Map<String, Value>,
521 depth: usize,
522 project: &'a Arc<CompProject>,
523}
524
525struct Parent<'a> {
527 vars: &'a Vars,
528 fps: f64,
529 duration: f64,
530 size: [f64; 2],
531}
532
533fn 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
544fn 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
637fn 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 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 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 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 #[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 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 &["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 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
1295fn 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#[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}