pub mod layout;
pub mod story;
use std::collections::HashSet;
use ffcine_core::Result;
use serde_json::{Map, Value};
use crate::model::err;
pub const NAMES: &[&str] = &["layout", "story"];
pub fn expand(doc: &Value) -> Result<Value> {
let Some(m) = doc.as_object() else {
return Ok(doc.clone());
};
let Some(t) = m.get("template") else {
return Ok(doc.clone());
};
for k in ["canvas", "vars", "comps", "layers"] {
if m.contains_key(k) {
return Err(err(
k,
"a comp with a `template` gets its canvas and layers from it",
));
}
}
let t = t
.as_object()
.ok_or_else(|| err("template", format!("expected an object, got {t}")))?;
let mut out = if let Some(s) = t.get("story") {
if t.len() != 1 {
return Err(err("template", "`story` takes its parameters inside it"));
}
let s = s
.as_object()
.ok_or_else(|| err("template.story", "expected an object"))?;
story::expand(s)?
} else if t.contains_key("layout") {
layout::expand(t)?
} else {
return Err(err(
"template",
format!("expected one of {}", NAMES.join(", ")),
));
};
let Some(o) = out.as_object_mut() else {
return Err(err("template", "expanded to a non-object"));
};
let mut doc = Map::new();
doc.insert(
"schema".into(),
m.get("schema")
.cloned()
.unwrap_or_else(|| format!("{}/1", crate::SCHEMA).into()),
);
if let Some(p) = m.get("project") {
doc.insert("project".into(), p.clone());
}
doc.append(o);
for (k, v) in m {
if k.starts_with("x_") {
doc.insert(k.clone(), v.clone());
}
}
Ok(Value::Object(doc))
}
pub(crate) fn num(x: f64) -> String {
format!("{x}")
}
#[allow(clippy::cast_possible_truncation)]
pub(crate) fn jnum(x: f64) -> Value {
if x.fract() == 0.0 && x.abs() < 1e15 {
Value::from(x as i64)
} else {
Value::from(x)
}
}
pub(crate) fn size(v: Option<&Value>, path: &str) -> Result<[u32; 2]> {
let a = v
.and_then(Value::as_array)
.filter(|a| a.len() == 2)
.ok_or_else(|| err(path, "expected [width, height] in px"))?;
let px = |v: &Value| {
v.as_u64()
.and_then(|x| u32::try_from(x).ok())
.filter(|x| *x > 0)
.ok_or_else(|| err(path, format!("{v}: expected a positive whole number")))
};
Ok([px(&a[0])?, px(&a[1])?])
}
pub(crate) fn item_layer(v: &Value, path: &str) -> Result<Map<String, Value>> {
let m = v
.as_object()
.ok_or_else(|| err(path, format!("expected an object, got {v}")))?;
if m.contains_key("source") {
return Ok(m.clone());
}
let kinds = ["video", "still", "solid", "comp", "text"];
let found: Vec<&str> = kinds.into_iter().filter(|k| m.contains_key(*k)).collect();
let [k] = found[..] else {
return Err(err(
path,
"expected one of `video`, `still`, `solid`, `comp`, `text` (or a `source`)",
));
};
let mut out = Map::new();
for (key, x) in m {
if key == k {
let mut s = Map::new();
s.insert(k.into(), x.clone());
out.insert("source".into(), Value::Object(s));
} else {
out.insert(key.clone(), x.clone());
}
}
Ok(out)
}
#[derive(Default)]
pub(crate) struct Ids(HashSet<String>);
impl Ids {
pub(crate) fn take(&mut self, want: Option<&str>, fallback: &str) -> String {
let base = want.filter(|s| !s.is_empty()).unwrap_or(fallback);
let mut id = base.to_owned();
let mut j = 2;
while !self.0.insert(id.clone()) {
id = format!("{base}_{j}");
j += 1;
}
id
}
}
#[cfg(test)]
mod tests {
use std::path::Path;
use serde_json::{Value, json};
use super::expand;
use crate::Comp;
const STORY: &str = include_str!("../../tests/data/story.comp.json");
const LOWERED: &str = include_str!("../../tests/data/story.lowered.json");
fn norm(v: &Value) -> Value {
match v {
Value::Number(n) => json!(n.as_f64().expect("finite")),
Value::Array(a) => Value::Array(a.iter().map(norm).collect()),
Value::Object(m) => {
Value::Object(m.iter().map(|(k, v)| (k.clone(), norm(v))).collect())
}
v => v.clone(),
}
}
fn parse(s: &str) -> Value {
serde_json::from_str(s).expect("json")
}
fn doc_block(doc: &str, heading: &str) -> Value {
let at = doc.find(heading).expect("heading");
let a = doc[at..].find("```json\n").expect("block") + at + 8;
let b = doc[a..].find("```").expect("end") + a;
parse(&doc[a..b])
}
#[test]
fn story_lowers_to_the_worked_example() {
let got = expand(&parse(STORY)).expect("expand");
assert_eq!(norm(&got), norm(&parse(LOWERED)), "{got:#}");
}
#[test]
fn golden_files_are_the_docs_examples() {
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../docs/compositing.md");
let Ok(doc) = std::fs::read_to_string(&path) else {
eprintln!("skipped: no {}", path.display());
return;
};
assert_eq!(doc_block(&doc, "## Templates"), parse(STORY));
assert_eq!(
doc_block(&doc, "### Worked example: the story"),
parse(LOWERED)
);
}
#[test]
fn story_and_its_lowering_read_as_the_same_comp() {
let dir = Path::new("media");
let a = Comp::from_value(&parse(STORY), dir).expect("template");
let b = Comp::from_value(&parse(LOWERED), dir).expect("lowered");
let shown = |c: &Comp| {
let s = format!("{c:?}");
let mut out = String::new();
let mut rest = s.as_str();
while let Some(i) = rest.find("visible: {") {
out.push_str(&rest[..i]);
rest = &rest[i..];
rest = &rest[rest.find('}').map_or(rest.len(), |j| j + 1)..];
}
out + rest
};
assert_eq!(shown(&a), shown(&b));
}
#[test]
fn templates_reject_mixing_and_unknowns() {
let e = |v: Value| expand(&v).expect_err("error").to_string();
assert!(e(json!({"template": {"story": {}}, "layers": []})).contains("layers"));
assert!(e(json!({"template": {"grid": {}}})).contains("layout, story"));
let base = json!({"size": [1080, 1920], "duration": 2.0,
"panels": [[{"video": "a.mov"}, {"still": "b.png"}, {"still": "c.png"}]]});
let mut s = base.clone();
assert!(e(json!({"template": {"story": s.clone()}})).contains("one or two"));
s["panels"] = json!([[{"video": "a.mov"}, {"still": "b.png"}]]);
assert!(e(json!({"template": {"story": s.clone()}})).contains("swap"));
s["bogus"] = json!(1);
assert!(e(json!({"template": {"story": s}})).contains("template.story.bogus"));
}
#[test]
fn story_without_swap_or_exit_holds_still() {
let v = expand(&json!({"template": {"story": {
"size": [1080, 1920], "duration": 3.0, "tiles": 2, "gutter": 10,
"panels": [[{"still": "a.png"}], [{"video": "b.mov"}]]
}}}))
.expect("expand");
assert!(v.get("vars").is_none());
assert_eq!(v["canvas"]["background"], "black");
assert_eq!(v["layers"][1]["transform"]["position"], json!([0, 965]));
assert_eq!(v["layers"][1]["size"], json!([1080, 955]));
let tile = &v["comps"]["tile0"]["layers"][0];
assert_eq!(tile["transform"]["position"], json!([0, 0]));
assert!(tile.get("content").is_none());
Comp::from_value(&v, Path::new(".")).expect("reads");
}
#[test]
fn starter_deals_media_round_the_tiles() {
let media: Vec<String> = ["a.mp4", "b.JPG", "c.mov", "d.heic"]
.map(String::from)
.to_vec();
let doc = super::story::starter(&media, 3, 6.0).expect("starter");
let s = &doc["template"]["story"];
assert_eq!(
s["panels"],
json!([[{"video": "a.mp4"}, {"still": "d.heic"}], [{"still": "b.JPG"}], [{"video": "c.mov"}]])
);
assert_eq!(s["swap"]["at"], json!(2.4));
assert_eq!(s["exit"]["at"], json!(5.2));
Comp::from_value(&doc, Path::new("media")).expect("a valid comp");
let two = super::story::starter(&media[..2], 2, 6.0).expect("starter");
assert!(two["template"]["story"].get("swap").is_none());
assert!(super::story::starter(&media, 1, 6.0).is_err());
assert!(super::story::starter(&[], 3, 6.0).is_err());
let e = super::story::starter(&media[..2], 3, 6.0).expect_err("tiles");
assert!(e.to_string().contains("3 tiles for 2 files"), "{e}");
assert!((super::story::starter_min_duration(3, true) - 2.4).abs() < 1e-9);
assert!((super::story::starter_min_duration(3, false) - 1.7).abs() < 1e-9);
let e = super::story::starter(&media, 3, 2.0).expect_err("duration");
assert!(e.to_string().contains("at least 2.4 s"), "{e}");
let short = super::story::starter(&media, 3, 2.4).expect("the shortest");
Comp::from_value(&short, Path::new("media")).expect("a valid comp");
assert!(super::story::starter(&media, 3, f64::NAN).is_err());
}
#[test]
fn exit_with_its_own_timing_gets_its_own_vars() {
let v = expand(&json!({"template": {"story": {
"size": [1080, 1920], "duration": 4.0,
"swap": {"at": 1.0, "stagger": 0.1, "slide": 0.3},
"exit": {"at": 3.0, "stagger": 0.2, "slide": 0.3, "direction": "right"},
"panels": [[{"still": "a.png", "punch": {"at": 0.5, "zoom": 1.2}}, {"still": "b.png"}]],
"ken_burns": [1.0, 1.1]
}}}))
.expect("expand");
assert_eq!(v["vars"]["exit_step"], json!(0.2));
assert!(v["vars"].get("exit_slide").is_none());
assert_eq!(
v["layers"][0]["transform"]["position"][0],
"if(lt(t, exit + i * exit_step), 0, W * ease_in((t - exit - i * exit_step) / slide))"
);
let zoom = &v["comps"]["tile0"]["layers"][0]["content"]["zoom"];
assert_eq!(zoom["expr"], "value * lerp(1, 1.1, lt / dur)");
Comp::from_value(&v, Path::new(".")).expect("reads");
}
}