diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 73369641..6168eedb 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -79,3 +79,4 @@ and the exact transfer revision are recorded in each vendored crate's SOURCE.jso | `hayro-interpret 0.7.0 / src/font/generated/metrics.rs` | Adobe (Core 14 AFM metrics, PDF specification encodings); tables by The Hayro Authors | https://github.com/storytold/pdfcraft/blob/7437bde378f43856eada40af25b983812e615e8a/vendor/hayro-interpret/src/font/generated/metrics.rs | MIT OR Apache-2.0 | `2ec9e608573eda459de1e1d9bda3bb494315e36bee885acc88912a11fe0caa41` | Glyph widths and encodings for non-embedded standard fonts. Non-visual dependency data; existing embedding unchanged | | `crates/audio/src/ac3/golden_frames.bin` | FilmCraft contributors (original work: 12 AC-3 and E-AC-3 syncframes that ffmpeg's `ac3` and `eac3` encoders made from tones and seeded noise synthesised by ffmpeg's lavfi generators; no third-party or recorded audio) | [FilmCraft `crates/ac3`](https://github.com/storytold/filmcraft/tree/1c9c2e82/crates/ac3/src) | MIT OR Apache-2.0 | | `crates/audio/tests/fixtures/` | SoundCraft and craft-libs contributors (original work: 0.1 s test tones from ffmpeg's `sine` generator and hand-built silent MPEG frames; no third-party or recorded audio; commands in `crates/audio/tests/fixtures/README.md`) | [SoundCraft `crates/audio-io/tests/fixtures`](https://github.com/storytold/soundcraft/tree/e86d09a/crates/audio-io/tests/fixtures); `tone.mov`, `tone.mka`, `tone.opus`, `tone.ac3` and `tone.eac3` made in this repository | MIT OR Apache-2.0 | +| `crates/lottie/tests/fixtures/` | EffectCraft contributors (original work: four hand-written Lottie JSON documents, written from the public Lottie format description; not exported from any Adobe product or third-party file) | [EffectCraft `crates/lottie/tests/fixtures`](https://github.com/storytold/effectcraft/tree/e18f48fb/crates/lottie/tests/fixtures) | MIT OR Apache-2.0 | diff --git a/Cargo.lock b/Cargo.lock index fa5c0548..790674b3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1036,6 +1036,14 @@ dependencies = [ "tempfile", ] +[[package]] +name = "craft-lottie" +version = "0.1.0" +dependencies = [ + "serde_json", + "thiserror 2.0.21", +] + [[package]] name = "craft-media" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 37aae1ef..80e9af0a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,6 +48,7 @@ craft-audio = { path = "crates/audio" } craft-media = { path = "crates/media", default-features = false } craft-media-testkit = { path = "crates/media-testkit" } craft-mime = { path = "crates/mime" } +craft-lottie = { path = "crates/lottie" } craft-menu = { path = "crates/menu" } craft-metafile = { path = "crates/metafile" } craft-pcraft-format = { path = "crates/pcraft-format" } diff --git a/README.md b/README.md index 82885d16..8e8db21f 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,10 @@ craft- = { git = "https://github.com/storytold/craft-libs", rev = " String { + let mut out = String::with_capacity(data.len().div_ceil(3) * 4); + for chunk in data.chunks(3) { + let b = [chunk[0], *chunk.get(1).unwrap_or(&0), *chunk.get(2).unwrap_or(&0)]; + let n = (b[0] as u32) << 16 | (b[1] as u32) << 8 | b[2] as u32; + for i in 0..4 { + if i <= chunk.len() { + out.push(ALPHABET[(n >> (18 - 6 * i) & 63) as usize] as char); + } else { + out.push('='); + } + } + } + out +} + +pub fn decode(s: &str) -> Option> { + let val = |c: u8| -> Option { + Some(match c { + b'A'..=b'Z' => c - b'A', + b'a'..=b'z' => c - b'a' + 26, + b'0'..=b'9' => c - b'0' + 52, + b'+' | b'-' => 62, + b'/' | b'_' => 63, + _ => return None, + } as u32) + }; + let clean: Vec = s.bytes().filter(|c| !c.is_ascii_whitespace() && *c != b'=').collect(); + let mut out = Vec::with_capacity(clean.len() * 3 / 4); + for chunk in clean.chunks(4) { + let mut n = 0u32; + for (i, &c) in chunk.iter().enumerate() { + n |= val(c)? << (18 - 6 * i); + } + let bytes = [(n >> 16) as u8, (n >> 8) as u8, n as u8]; + match chunk.len() { + 4 => out.extend_from_slice(&bytes), + 3 => out.extend_from_slice(&bytes[..2]), + 2 => out.push(bytes[0]), + _ => return None, + } + } + Some(out) +} + +/// A `data:;base64,` URI of `bytes`. +pub fn to_data_uri(mime: &str, bytes: &[u8]) -> String { + format!("data:{mime};base64,{}", encode(bytes)) +} + +/// Split a `data:;base64,` URI into (mime, bytes). +pub fn data_uri(s: &str) -> Option<(String, Vec)> { + let rest = s.strip_prefix("data:")?; + let (head, payload) = rest.split_once(',')?; + let mime = head.strip_suffix(";base64")?; + Some((mime.to_string(), decode(payload)?)) +} + +#[cfg(test)] +mod tests { + #[test] + fn roundtrip() { + for s in ["", "f", "fo", "foo", "foob", "fooba", "foobar"] { + let e = super::encode(s.as_bytes()); + assert_eq!(super::decode(&e).unwrap(), s.as_bytes()); + } + assert_eq!(super::encode(b"foobar"), "Zm9vYmFy"); + assert_eq!(super::encode(b"fo"), "Zm8="); + } +} diff --git a/crates/lottie/src/gradient.rs b/crates/lottie/src/gradient.rs new file mode 100644 index 00000000..7edd6002 --- /dev/null +++ b/crates/lottie/src/gradient.rs @@ -0,0 +1,46 @@ +//! Gradients: the flat stop list of a `g.k` value. + +use serde_json::json; + +use crate::{Json, nums, round6}; + +/// A gradient: colour stops and opacity stops, each at a position in 0..=1. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct Gradient { + /// `(position, [r, g, b])`. + pub colors: Vec<(f64, [f64; 3])>, + /// `(position, alpha)`. + pub opacities: Vec<(f64, f64)>, +} + +impl Gradient { + /// Reads the flat list: `ncolors` (the `g.p` of the item) groups of `position, r, g, b`, + /// then pairs of `position, alpha`. `ncolors` is held to the groups the list has; a list + /// without alpha pairs is opaque (`(0, 1)` and `(1, 1)`). + pub fn from_flat(j: &Json, ncolors: usize) -> Option { + let v = nums(j)?; + let n = ncolors.min(v.len() / 4); + let (head, rest) = v.split_at_checked(n * 4)?; + let colors = head.as_chunks::<4>().0.iter().map(|[p, r, g, b]| (*p, [*r, *g, *b])).collect(); + let mut opacities: Vec<(f64, f64)> = rest.as_chunks::<2>().0.iter().map(|[p, a]| (*p, *a)).collect(); + if opacities.is_empty() { + opacities = vec![(0.0, 1.0), (1.0, 1.0)]; + } + Some(Self { colors, opacities }) + } + + /// The flat list, rounded to six decimals. The alpha pairs are left out when every stop is + /// opaque. + pub fn to_flat(&self) -> Json { + let mut out = vec![]; + for (p, [r, g, b]) in &self.colors { + out.extend([round6(*p), round6(*r), round6(*g), round6(*b)]); + } + if !self.opacities.iter().all(|(_, a)| (*a - 1.0).abs() < 1e-6) { + for (p, a) in &self.opacities { + out.extend([round6(*p), round6(*a)]); + } + } + json!(out) + } +} diff --git a/crates/lottie/src/lib.rs b/crates/lottie/src/lib.rs new file mode 100644 index 00000000..e0e7113d --- /dev/null +++ b/crates/lottie/src/lib.rs @@ -0,0 +1,79 @@ +//! Lottie (Bodymovin) JSON: the wire layer. +//! +//! A Lottie document is JSON, and most of its difficulty is in a few encodings that every layer +//! and shape reuses. This crate reads and writes those, on `serde_json` values, without a +//! document model of its own: +//! +//! - [`parse_document`] and [`Header`]: the document and its size, frame rate and frame range; +//! - [`read_prop`] / [`write_prop`]: an animatable property (`{a, k, x}`), its keyframes with the +//! legacy end values, hold keys and spatial tangents, and [`Tangents`], the ease of a segment +//! converted to and from speed and influence; +//! - [`Bezier`]: a path (`{c, v, i, o}`); [`Gradient`]: the flat stop list of a gradient; +//! - [`nums`], [`color`], [`hex_color`]: the tolerant value readers; +//! - [`base64`] and [`image_mime`]: embedded image assets as `data:` URIs. +//! +//! Implemented from the public Lottie format description (the lottie-spec 1.0 schema and the +//! LottieFiles format documentation); no player or exporter source was used. +//! +//! Which layer, shape, effect and text feature maps to what in an application's document is the +//! application's work. dotLottie (`.lottie`) is a ZIP archive around the same JSON and is not +//! read here. + +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + +pub mod base64; +mod gradient; +mod path; +mod prop; +mod value; + +pub use gradient::Gradient; +pub use path::Bezier; +pub use prop::{Ease, Key, KeyOut, Prop, PropValue, Tangents, is_animated, read_prop, tangent, write_keys, write_prop}; +pub use serde_json::Value as Json; +pub use value::{color, hex_color, image_mime, nums, round6, to_hex}; + +/// Errors from reading a Lottie document. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum LottieError { + #[error("not a Lottie document: {0}")] + Parse(String), +} + +/// Parses the JSON of a Lottie document. Nesting is bounded by `serde_json` (128 levels). +pub fn parse_document(bytes: &[u8]) -> Result { + serde_json::from_slice(bytes).map_err(|e| LottieError::Parse(e.to_string())) +} + +/// The fields every document starts with. +#[derive(Debug, Clone, PartialEq)] +pub struct Header<'a> { + /// `w`, `h`: the size in pixels. + pub width: f64, + pub height: f64, + /// `fr`: frames per second; 30 when missing or not positive. + pub frame_rate: f64, + /// `ip`: the first frame; 0 when missing. + pub in_point: f64, + /// `op`: the frame after the last; one second after `ip` when missing, and at least one + /// frame after it. + pub out_point: f64, + /// `nm`, when it is not empty. + pub name: Option<&'a str>, +} + +impl<'a> Header<'a> { + /// Reads the header. A document without a numeric `w` and `h` is not a Lottie document. + pub fn read(doc: &'a Json) -> Result { + let f = |k: &str| doc.get(k).and_then(Json::as_f64); + let (Some(width), Some(height)) = (f("w"), f("h")) else { return Err(LottieError::Parse("missing `w`/`h`".into())) }; + let frame_rate = f("fr").filter(|v| *v > 0.0).unwrap_or(30.0); + let in_point = f("ip").unwrap_or(0.0); + let out_point = f("op").unwrap_or(in_point + frame_rate).max(in_point + 1.0); + let name = doc.get("nm").and_then(Json::as_str).filter(|n| !n.is_empty()); + Ok(Self { width, height, frame_rate, in_point, out_point, name }) + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/lottie/src/path.rs b/crates/lottie/src/path.rs new file mode 100644 index 00000000..a8301ccb --- /dev/null +++ b/crates/lottie/src/path.rs @@ -0,0 +1,46 @@ +//! Bezier paths (`{c, v, i, o}`). + +use serde_json::json; + +use crate::{Json, nums, round6}; + +/// A path: vertices with in and out tangents relative to their vertex. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct Bezier { + pub closed: bool, + pub vertices: Vec<[f64; 2]>, + /// One per vertex. + pub in_tangents: Vec<[f64; 2]>, + /// One per vertex. + pub out_tangents: Vec<[f64; 2]>, +} + +impl Bezier { + /// Reads a path, or the first of a one-element array (a key's `s`). `closed` is used when + /// the path has no `c`: Bodymovin 3.x and early 4.x keep the flag outside the path, as `cl` + /// on a mask and `closed` on an `sh` item. Tangent lists are cut or padded with zeros to the + /// number of vertices; a point that is not numbers is `[0, 0]`. + pub fn from_json(j: &Json, closed: bool) -> Option { + let o = match j { + Json::Array(a) => a.first()?, + o => o, + }; + let pts = |k: &str| -> Vec<[f64; 2]> { + let point = |p: &Json| nums(p).map_or([0.0; 2], |v| [v.first().copied().unwrap_or(0.0), v.get(1).copied().unwrap_or(0.0)]); + o.get(k).and_then(Json::as_array).map(|a| a.iter().map(point).collect()).unwrap_or_default() + }; + let vertices = pts("v"); + let n = vertices.len(); + let fit = |mut v: Vec<[f64; 2]>| { + v.resize(n, [0.0; 2]); + v + }; + Some(Self { in_tangents: fit(pts("i")), out_tangents: fit(pts("o")), vertices, closed: o.get("c").and_then(Json::as_bool).unwrap_or(closed) }) + } + + /// The path as `{c, v, i, o}`, coordinates rounded to six decimals. + pub fn to_json(&self) -> Json { + let pts = |v: &[[f64; 2]]| v.iter().map(|[x, y]| json!([round6(*x), round6(*y)])).collect::>(); + json!({"c": self.closed, "v": pts(&self.vertices), "i": pts(&self.in_tangents), "o": pts(&self.out_tangents)}) + } +} diff --git a/crates/lottie/src/prop.rs b/crates/lottie/src/prop.rs new file mode 100644 index 00000000..0b417400 --- /dev/null +++ b/crates/lottie/src/prop.rs @@ -0,0 +1,215 @@ +//! Animatable properties and their keyframes. +//! +//! A Lottie keyframe holds the start value `s` of a segment and the segment's normalised cubic +//! Bezier ease: `o` (out tangent of this key) and `i` (in tangent of the next key), each with `x` +//! (time fraction) and `y` (value progress fraction), per dimension. After Effects' temporal ease +//! is speed (units per second) and influence (fraction of the segment): for a segment of duration +//! `d` and value change `Δv`, +//! +//! ```text +//! o.x = influence_out o.y = speed_out · influence_out · d / Δv +//! i.x = 1 − influence_in i.y = 1 − speed_in · influence_in · d / Δv +//! ``` +//! +//! Spatial properties ease along the motion path (`Δv` is the arc length, one dimension) and carry +//! `to`/`ti` spatial tangents. Hold keys set `h: 1`. + +use serde_json::json; + +use crate::{Json, round6}; + +/// Whether a property value is keyframed: `a` is 1, or `k` is a list of keys (files without `a`). +pub fn is_animated(j: &Json) -> bool { + if j.get("a").and_then(Json::as_i64) == Some(1) { + return true; + } + j.get("k").and_then(Json::as_array).and_then(|a| a.first()).is_some_and(|f| f.get("t").is_some()) +} + +/// Component `d` of an ease tangent field (`x` or `y` of an `o` or `i`), which may be a number +/// or a list; a list shorter than `d` gives its first. +pub fn tangent(j: &Json, field: &str, d: usize) -> Option { + match j.get(field)? { + Json::Number(n) => n.as_f64(), + Json::Array(a) => a.get(d).or(a.first()).and_then(Json::as_f64), + _ => None, + } +} + +/// One keyframe as the file has it. Values stay JSON: their type depends on the property. +#[derive(Debug, Clone, PartialEq)] +pub struct Key<'a> { + /// `t`: the frame; 0 when missing. + pub t: f64, + /// The value at the key: its `s`, or the end value `e` of the key before (files before 5.0 + /// end a segment with `e` and give the last key no value). + pub s: Option<&'a Json>, + /// `h` is 1: the value holds until the next key. + pub hold: bool, + /// `o`: the out tangent of the segment that starts here. + pub o: Option<&'a Json>, + /// `i`: the in tangent of the segment that starts here (it arrives at the next key). + pub i: Option<&'a Json>, + /// `to`: the spatial out tangent, zeros when missing. + pub to: [f64; 3], + /// `ti`: the spatial in tangent at the next key, zeros when missing. + pub ti: [f64; 3], +} + +/// The value of a property. +#[derive(Debug, Clone, PartialEq)] +pub enum PropValue<'a> { + /// No `k`, or a keyframed property whose `k` is not a list. + None, + /// One value. + Static(&'a Json), + /// Keyframes in file order. + Keys(Vec>), +} + +/// An animatable property. +#[derive(Debug, Clone, PartialEq)] +pub struct Prop<'a> { + /// `x`: the expression, when it is not blank. + pub expression: Option<&'a str>, + pub value: PropValue<'a>, +} + +fn vec3_of(j: Option<&Json>) -> [f64; 3] { + let a = j.and_then(Json::as_array); + let g = |i: usize| a.and_then(|a| a.get(i)).and_then(Json::as_f64).unwrap_or(0.0); + [g(0), g(1), g(2)] +} + +/// Reads an animatable property. A value that is not an object is the static value itself (some +/// files write `"o": 100`). +pub fn read_prop(j: &Json) -> Prop<'_> { + if !j.is_object() { + return Prop { expression: None, value: PropValue::Static(j) }; + } + let expression = j.get("x").and_then(Json::as_str).filter(|x| !x.trim().is_empty()); + let Some(k) = j.get("k") else { return Prop { expression, value: PropValue::None } }; + if !is_animated(j) { + return Prop { expression, value: PropValue::Static(k) }; + } + let Some(list) = k.as_array() else { return Prop { expression, value: PropValue::None } }; + let mut keys = Vec::with_capacity(list.len()); + let mut prev_end: Option<&Json> = None; + for kj in list { + keys.push(Key { + t: kj.get("t").and_then(Json::as_f64).unwrap_or(0.0), + s: kj.get("s").or(prev_end), + hold: kj.get("h").and_then(Json::as_i64) == Some(1), + o: kj.get("o"), + i: kj.get("i"), + to: vec3_of(kj.get("to")), + ti: vec3_of(kj.get("ti")), + }); + prev_end = kj.get("e"); + } + Prop { expression, value: PropValue::Keys(keys) } +} + +/// One side of a keyframe's temporal ease, as After Effects has it. +#[derive(Debug, Clone, Copy, Default, PartialEq)] +pub struct Ease { + /// Units per second. + pub speed: f64, + /// Fraction of the segment, 0..=1. + pub influence: f64, +} + +/// The ease of one segment in one dimension: the two inner control points of a cubic Bezier from +/// (0, 0) to (1, 1), time across and value progress up. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Tangents { + pub ox: f64, + pub oy: f64, + pub ix: f64, + pub iy: f64, +} + +impl Tangents { + /// Reads dimension `d` of a key's `o` and `i`. A missing `x` is a third of the way (1/3 and + /// 2/3), `x` is held to 0..=1, and a missing `y` equals its `x` (linear). + pub fn read(o: &Json, i: &Json, d: usize) -> Self { + let ox = tangent(o, "x", d).unwrap_or(1.0 / 3.0).clamp(0.0, 1.0); + let oy = tangent(o, "y", d).unwrap_or(ox); + let ix = tangent(i, "x", d).unwrap_or(2.0 / 3.0).clamp(0.0, 1.0); + let iy = tangent(i, "y", d).unwrap_or(ix); + Self { ox, oy, ix, iy } + } + + /// Whether both control points are on the diagonal: constant speed. + pub fn is_linear(&self) -> bool { + (self.ox - self.oy).abs() <= 1e-4 && (self.ix - self.iy).abs() <= 1e-4 + } + + /// The speed and influence of the segment's two sides (out of its first key, into its + /// second), for a value change `dv` over `dur` seconds. An influence is at least 0.001. + pub fn to_speed(&self, dv: f64, dur: f64) -> (Ease, Ease) { + let inf_o = self.ox.max(0.001); + let inf_i = (1.0 - self.ix).max(0.001); + (Ease { speed: self.oy * dv / (inf_o * dur), influence: inf_o }, Ease { speed: (1.0 - self.iy) * dv / (inf_i * dur), influence: inf_i }) + } + + /// The tangents of a segment from the ease out of its first key and into its second, for a + /// value change `dv` over `dur` seconds, rounded to six decimals. Influences are held to + /// 0..=1 and kept as they are even when the handles cross in time. Without a value change + /// the progress is 0 and 1. + pub fn from_speed(out: Ease, into: Ease, dv: f64, dur: f64) -> Self { + let (i0, i1) = (out.influence.clamp(0.0, 1.0), into.influence.clamp(0.0, 1.0)); + let (y0, y1) = if dv.abs() > 1e-12 { (out.speed * i0 * dur / dv, 1.0 - into.speed * i1 * dur / dv) } else { (0.0, 1.0) }; + Self { ox: round6(i0), oy: round6(y0), ix: round6(1.0 - i1), iy: round6(y1) } + } +} + +/// One keyframe to write. +#[derive(Debug, Clone, PartialEq)] +pub struct KeyOut { + /// The frame. + pub t: f64, + /// The value at the key. + pub s: Json, + /// Hold until the next key (`h: 1`); `ease` and `spatial` are then not written. + pub hold: bool, + /// The ease of the segment that starts here, one entry per dimension. + pub ease: Vec, + /// `(to, ti)`: the spatial tangents out of this key and into the next. + pub spatial: Option<(Vec, Vec)>, +} + +/// Keys as the `k` list of a keyframed property. A value that is not a list is wrapped in one +/// (`s: [5]`). The last key is written without tangents: no segment starts there. +pub fn write_keys(keys: &[KeyOut]) -> Vec { + let last = keys.len().saturating_sub(1); + keys.iter() + .enumerate() + .map(|(n, k)| { + let s = if k.s.is_array() { k.s.clone() } else { json!([k.s]) }; + let mut o = json!({"t": round6(k.t), "s": s}); + if k.hold { + o["h"] = json!(1); + } else if n < last { + let col = |f: fn(&Tangents) -> f64| k.ease.iter().map(f).collect::>(); + o["o"] = json!({"x": col(|t| t.ox), "y": col(|t| t.oy)}); + o["i"] = json!({"x": col(|t| t.ix), "y": col(|t| t.iy)}); + if let Some((to, ti)) = &k.spatial { + o["to"] = json!(to.iter().map(|v| round6(*v)).collect::>()); + o["ti"] = json!(ti.iter().map(|v| round6(*v)).collect::>()); + } + } + o + }) + .collect() +} + +/// An animatable property: `{a: 0, k: value}` without keys, `{a: 1, k: [keys]}` with, and `x` +/// for an expression. +pub fn write_prop(value: Json, keys: &[KeyOut], expression: Option<&str>) -> Json { + let mut out = if keys.is_empty() { json!({"a": 0, "k": value}) } else { json!({"a": 1, "k": write_keys(keys)}) }; + if let Some(x) = expression { + out["x"] = json!(x); + } + out +} diff --git a/crates/lottie/src/tests.rs b/crates/lottie/src/tests.rs new file mode 100644 index 00000000..b978130f --- /dev/null +++ b/crates/lottie/src/tests.rs @@ -0,0 +1,306 @@ +use serde_json::json; + +use crate::{ + Bezier, Ease, Gradient, Header, Json, KeyOut, LottieError, PropValue, Tangents, base64, color, hex_color, image_mime, is_animated, nums, +}; +use crate::{parse_document, read_prop, round6, tangent, to_hex, write_keys, write_prop}; + +fn fixture(name: &str) -> Json { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures").join(name); + parse_document(&std::fs::read(path).unwrap()).unwrap() +} + +/// Every animatable property (`{a, k}`) under `j`. +fn props<'a>(j: &'a Json, out: &mut Vec<&'a Json>) { + match j { + Json::Object(m) => { + if m.contains_key("a") && m.contains_key("k") { + out.push(j); + } + m.values().for_each(|v| props(v, out)); + } + Json::Array(a) => a.iter().for_each(|v| props(v, out)), + _ => {} + } +} + +#[test] +fn header_defaults_and_refusals() { + let doc = json!({"w": 120, "h": 80, "fr": 25, "ip": 10, "op": 60, "nm": "Ball"}); + assert_eq!( + Header::read(&doc).unwrap(), + Header { width: 120.0, height: 80.0, frame_rate: 25.0, in_point: 10.0, out_point: 60.0, name: Some("Ball") } + ); + // No rate: 30. No `op`: one second after `ip`. An empty name is no name. + let doc = json!({"w": 1, "h": 1, "fr": 0, "ip": 5, "nm": ""}); + let h = Header::read(&doc).unwrap(); + assert_eq!((h.frame_rate, h.in_point, h.out_point, h.name), (30.0, 5.0, 35.0, None)); + // `op` before `ip`: one frame. + assert_eq!(Header::read(&json!({"w": 1, "h": 1, "ip": 5, "op": 2})).unwrap().out_point, 6.0); + for bad in [json!({"w": 1}), json!({"w": "1", "h": 1}), json!([1, 2]), json!(null)] { + assert_eq!(Header::read(&bad), Err(LottieError::Parse("missing `w`/`h`".into()))); + } + assert!(matches!(parse_document(b"{\"w\": 1"), Err(LottieError::Parse(_)))); + assert!(matches!(parse_document(b"PK\x03\x04"), Err(LottieError::Parse(_)))); +} + +#[test] +fn nesting_deeper_than_128_levels_is_refused() { + let deep = |n: usize| format!("{}{}", "[".repeat(n), "]".repeat(n)); + assert!(parse_document(deep(100).as_bytes()).is_ok()); + assert!(parse_document(deep(200).as_bytes()).is_err()); +} + +#[test] +fn numbers_and_colours() { + assert_eq!(nums(&json!(5)), Some(vec![5.0])); + assert_eq!(nums(&json!([5])), Some(vec![5.0])); + assert_eq!(nums(&json!([[1, 2]])), Some(vec![1.0, 2.0])); + assert_eq!(nums(&json!([1, "x"])), None); + assert_eq!(nums(&json!("5")), None); + assert_eq!(color(&json!([0.5, 0.25])), Some([0.5, 0.25, 0.0, 1.0])); + // 0..=255 files: colour divided, alpha kept. + assert_eq!(color(&json!([255, 0, 51, 1])), Some([1.0, 0.0, 0.2, 1.0])); + assert_eq!(color(&json!([])), None); + assert_eq!(hex_color("#e0c040"), [224.0 / 255.0, 192.0 / 255.0, 64.0 / 255.0]); + // `+1` parses as a number but is not two hex digits; a short string has no blue. + assert_eq!(hex_color("#+1+2+3"), [0.0, 0.0, 0.0]); + assert_eq!(hex_color("ff80"), [1.0, 128.0 / 255.0, 0.0]); + assert_eq!(to_hex([1.0, 0.5, -3.0]), "#ff8000"); + assert_eq!(hex_color(&to_hex([0.2, 0.4, 0.6])), [0.2, 0.4, 0.6]); + assert_eq!(round6(1.234_567_89), 1.234_568); + assert_eq!(round6(f64::NAN), 0.0); + assert_eq!(round6(f64::INFINITY), 0.0); +} + +#[test] +fn image_signatures_are_matched_in_full() { + assert_eq!(image_mime(b"\x89PNG\r\n\x1a\n\0"), Some("image/png")); + assert_eq!(image_mime(b"GIF89a\0"), Some("image/gif")); + assert_eq!(image_mime(b"GIF87a\0"), Some("image/gif")); + assert_eq!(image_mime(&[0xff, 0xd8, 0xff, 0xe0]), Some("image/jpeg")); + assert_eq!(image_mime(b"RIFF\0\0\0\0WEBPVP8 "), Some("image/webp")); + // Near matches (a prefix of the signature, or text that starts with it) are not images. + assert_eq!(image_mime(b"\x89PNG\x01\x02\x03\x04"), None); + assert_eq!(image_mime(b"GIF8 is a text file"), None); + assert_eq!(image_mime(b"RIFF\0\0\0\0WAVE"), None); + assert_eq!(image_mime(b""), None); +} + +#[test] +fn base64_and_data_uris() { + for s in ["", "f", "fo", "foo", "foob", "fooba", "foobar"] { + assert_eq!(base64::decode(&base64::encode(s.as_bytes())).unwrap(), s.as_bytes()); + } + assert_eq!(base64::encode(b"foobar"), "Zm9vYmFy"); + assert_eq!(base64::encode(b"fo"), "Zm8="); + // URL-safe letters and white space are read; anything else, or one stray letter, is not. + assert_eq!(base64::decode("-_ 8=\n"), Some(vec![0xfb, 0xff])); + assert_eq!(base64::decode("Zm9v!"), None); + assert_eq!(base64::decode("Zm9vY"), None); + let uri = base64::to_data_uri("image/png", b"\x89PNG"); + assert_eq!(uri, "data:image/png;base64,iVBORw=="); + assert_eq!(base64::data_uri(&uri), Some(("image/png".to_string(), b"\x89PNG".to_vec()))); + assert_eq!(base64::data_uri("data:image/png,raw"), None); + assert_eq!(base64::data_uri("images/a.png"), None); +} + +#[test] +fn paths_read_legacy_closed_flags_and_fit_tangents() { + let j = json!({"v": [[0, 0], [10, 0], [10, 10]], "i": [[1, 1]], "o": [[2, 2], [3, 3], [4, 4], [5, 5]]}); + let p = Bezier::from_json(&j, true).unwrap(); + assert!(p.closed, "no `c`: the flag kept outside the path applies"); + assert_eq!(p.vertices.len(), 3); + assert_eq!(p.in_tangents, [[1.0, 1.0], [0.0, 0.0], [0.0, 0.0]]); + assert_eq!(p.out_tangents, [[2.0, 2.0], [3.0, 3.0], [4.0, 4.0]]); + assert!(!Bezier::from_json(&json!({"c": false, "v": []}), true).unwrap().closed); + // A key's `s` wraps the path in a list; a point that is not numbers is the origin. + let wrapped = Bezier::from_json(&json!([{"c": true, "v": [[1, 2], "x", [3]]}]), false).unwrap(); + assert_eq!(wrapped.vertices, [[1.0, 2.0], [0.0, 0.0], [3.0, 0.0]]); + assert_eq!(Bezier::from_json(&json!([]), false), None); + assert_eq!(Bezier::from_json(&json!(5), false), Some(Bezier::default())); + let back = Bezier::from_json(&p.to_json(), false).unwrap(); + assert_eq!(back, p); + assert_eq!(Bezier { vertices: vec![[1.0 / 3.0, 2.0]], ..Bezier::default() }.to_json()["v"], json!([[0.333_333, 2.0]])); +} + +#[test] +fn gradients_split_colour_and_alpha_stops() { + let flat = json!([0.0, 0.1, 0.4, 0.9, 1.0, 0.9, 0.2, 0.5]); + let g = Gradient::from_flat(&flat, 2).unwrap(); + assert_eq!(g.colors, [(0.0, [0.1, 0.4, 0.9]), (1.0, [0.9, 0.2, 0.5])]); + assert_eq!(g.opacities, [(0.0, 1.0), (1.0, 1.0)]); + assert_eq!(g.to_flat(), flat, "opaque: no alpha pairs written"); + let with_alpha = json!([0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 1.0, 0.5, 0.25]); + let g = Gradient::from_flat(&with_alpha, 2).unwrap(); + assert_eq!(g.opacities, [(0.0, 1.0), (0.5, 0.25)]); + assert_eq!(g.to_flat(), with_alpha); + // A count beyond the list is held to the groups there are; an odd tail is dropped. + let g = Gradient::from_flat(&json!([0, 1, 1, 1, 0.5]), 99).unwrap(); + assert_eq!((g.colors.len(), g.opacities.len()), (1, 2)); + assert_eq!(Gradient::from_flat(&json!([]), usize::MAX).unwrap().colors, []); + assert_eq!(Gradient::from_flat(&json!("x"), 2), None); +} + +#[test] +fn properties_static_keyed_legacy_and_bare() { + // A bare value. + assert_eq!(read_prop(&json!(100)).value, PropValue::Static(&json!(100))); + let p = json!({"a": 0, "k": [1, 2], "x": "wiggle(1, 2)"}); + let r = read_prop(&p); + assert_eq!(r.expression, Some("wiggle(1, 2)")); + assert_eq!(r.value, PropValue::Static(&json!([1, 2]))); + assert_eq!(read_prop(&json!({"a": 0, "k": 1, "x": " "})).expression, None); + assert_eq!(read_prop(&json!({"a": 1})).value, PropValue::None); + assert_eq!(read_prop(&json!({"a": 1, "k": 5})).value, PropValue::None); + // No `a`, but `k` is a list of keys. + assert!(is_animated(&json!({"k": [{"t": 0, "s": [1]}]}))); + assert!(!is_animated(&json!({"k": [1, 2, 3]}))); + + // Before 5.0: a segment ends with `e`, and the last key has no value of its own. + let legacy = json!({"a": 1, "k": [ + {"t": 0, "s": [0], "e": [100], "i": {"x": [0.833], "y": [0.833]}, "o": {"x": [0.167], "y": [0.167]}}, + {"t": 20} + ]}); + let PropValue::Keys(keys) = read_prop(&legacy).value else { panic!("keys") }; + assert_eq!(keys.len(), 2); + assert_eq!((keys[0].t, keys[0].s), (0.0, Some(&json!([0])))); + assert_eq!((keys[1].t, keys[1].s), (20.0, Some(&json!([100])))); + assert!(keys[1].o.is_none() && !keys[1].hold); + + let spatial = json!({"a": 1, "k": [{"t": 0, "s": [0, 0], "h": 1, "to": [1, 2], "ti": [3, 4, 5, 6]}, {"s": [9, 9]}, {"t": 4}]}); + let PropValue::Keys(keys) = read_prop(&spatial).value else { panic!("keys") }; + assert!(keys[0].hold); + assert_eq!((keys[0].to, keys[0].ti), ([1.0, 2.0, 0.0], [3.0, 4.0, 5.0])); + assert_eq!(keys[1].t, 0.0, "no `t`: frame 0"); + assert_eq!(keys[2].s, None, "no `s` and no `e` before it"); +} + +#[test] +fn tangents_are_numbers_or_lists() { + let o = json!({"x": [0.1, 0.2], "y": 0.5}); + assert_eq!(tangent(&o, "x", 1), Some(0.2)); + assert_eq!(tangent(&o, "x", 7), Some(0.1), "a short list gives its first"); + assert_eq!(tangent(&o, "y", 3), Some(0.5)); + assert_eq!(tangent(&o, "z", 0), None); + assert_eq!(tangent(&json!({"x": "a"}), "x", 0), None); + // Defaults: thirds, linear; `x` held to 0..=1. + let t = Tangents::read(&json!({}), &json!({}), 0); + assert_eq!(t, Tangents { ox: 1.0 / 3.0, oy: 1.0 / 3.0, ix: 2.0 / 3.0, iy: 2.0 / 3.0 }); + assert!(t.is_linear()); + let t = Tangents::read(&json!({"x": 5, "y": 2}), &json!({"x": -1}), 0); + assert_eq!(t, Tangents { ox: 1.0, oy: 2.0, ix: 0.0, iy: 0.0 }); + assert!(!t.is_linear()); +} + +#[test] +fn ease_converts_exactly_both_ways() { + // 0 to 180 in 0.8 s, leaving at 300/s with 60 % influence, arriving at -50/s with 20 %. + let (out, into) = (Ease { speed: 300.0, influence: 0.6 }, Ease { speed: -50.0, influence: 0.2 }); + let t = Tangents::from_speed(out, into, 180.0, 0.8); + assert!((t.ox - 0.6).abs() < 1e-9); + assert!((t.oy - 300.0 * 0.6 * 0.8 / 180.0).abs() < 1e-5); + assert!((t.ix - 0.8).abs() < 1e-9); + assert!((t.iy - (1.0 + 50.0 * 0.2 * 0.8 / 180.0)).abs() < 1e-5); + let (o, i) = t.to_speed(180.0, 0.8); + assert!((o.speed - 300.0).abs() < 1e-3 && (o.influence - 0.6).abs() < 1e-6, "{o:?}"); + assert!((i.speed + 50.0).abs() < 1e-3 && (i.influence - 0.2).abs() < 1e-6, "{i:?}"); + // A falling value: 100 to 20 at 10/s out. + let t = Tangents::from_speed(Ease { speed: 10.0, influence: 0.9 }, Ease { speed: 0.0, influence: 1.0 / 3.0 }, -80.0, 1.0); + assert!((t.oy + 0.1125).abs() < 1e-9); + assert!((t.ix - 2.0 / 3.0).abs() < 1e-6); + // No value change: progress 0 and 1, nothing divided by zero; influences held to 0..=1. + let t = Tangents::from_speed(Ease { speed: 9.0, influence: 7.0 }, Ease { speed: 9.0, influence: -1.0 }, 0.0, 1.0); + assert_eq!(t, Tangents { ox: 1.0, oy: 0.0, ix: 1.0, iy: 1.0 }); + // A zero `x` still gives a finite speed. + let (o, i) = Tangents { ox: 0.0, oy: 0.0, ix: 1.0, iy: 1.0 }.to_speed(10.0, 1.0); + assert!(o.speed.is_finite() && i.speed.is_finite()); + assert_eq!((o.influence, i.influence), (0.001, 0.001)); +} + +#[test] +fn keys_are_written_and_read_back() { + let lin = Tangents { ox: 0.25, oy: 0.5, ix: 0.75, iy: 1.0 }; + let keys = [ + KeyOut { t: 0.0, s: json!(5), hold: false, ease: vec![lin], spatial: None }, + KeyOut { t: 10.000_000_4, s: json!([1, 2]), hold: true, ease: vec![lin], spatial: None }, + KeyOut { t: 20.0, s: json!([3, 4]), hold: false, ease: vec![lin, lin], spatial: Some((vec![1.0, 2.0], vec![-1.0 / 3.0, 0.0])) }, + KeyOut { t: 30.0, s: json!([5, 6]), hold: false, ease: vec![lin], spatial: Some((vec![9.0], vec![9.0])) }, + ]; + let list = write_keys(&keys); + assert_eq!(list[0], json!({"t": 0.0, "s": [5], "o": {"x": [0.25], "y": [0.5]}, "i": {"x": [0.75], "y": [1.0]}})); + assert_eq!(list[1], json!({"t": 10.0, "s": [1, 2], "h": 1})); + assert_eq!(list[2]["o"]["x"], json!([0.25, 0.25])); + assert_eq!((&list[2]["to"], &list[2]["ti"]), (&json!([1.0, 2.0]), &json!([-0.333_333, 0.0]))); + assert_eq!(list[3], json!({"t": 30.0, "s": [5, 6]}), "the last key starts no segment"); + + let prop = write_prop(json!(0), &keys, Some("time")); + assert_eq!((&prop["a"], &prop["x"]), (&json!(1), &json!("time"))); + let PropValue::Keys(back) = read_prop(&prop).value else { panic!("keys") }; + assert_eq!(back.len(), 4); + assert!(back[1].hold && !back[0].hold); + assert_eq!(Tangents::read(back[0].o.unwrap(), back[0].i.unwrap(), 0), lin); + assert_eq!(back[2].to, [1.0, 2.0, 0.0]); + + assert_eq!(write_prop(json!([1, 2]), &[], None), json!({"a": 0, "k": [1, 2]})); + assert!(write_keys(&[]).is_empty()); +} + +#[test] +fn fixtures_read() { + let mut keyed = 0; + for name in ["bouncing-ball.json", "legacy-masks.json", "precomp-matte-text.json", "shapes-gradient.json"] { + let doc = fixture(name); + let h = Header::read(&doc).unwrap(); + assert!(h.width > 0.0 && h.out_point > h.in_point, "{name}"); + let mut all = vec![]; + props(&doc, &mut all); + assert!(!all.is_empty(), "{name}"); + for p in all { + match read_prop(p).value { + PropValue::Keys(keys) => { + keyed += 1; + assert!(keys.iter().all(|k| k.s.is_some()), "{name}: a key without a value"); + assert!(keys.windows(2).all(|w| w[0].t <= w[1].t), "{name}: keys out of order"); + } + PropValue::Static(v) => { + assert!(nums(v).is_some() || Bezier::from_json(v, false).is_some_and(|b| !b.vertices.is_empty()), "{name}: {v}") + } + PropValue::None => panic!("{name}: a property without a value"), + } + } + } + assert!(keyed >= 3, "{keyed}"); + + // The legacy file: opacity 0 to 100 through an `e`, and a closed four-point mask. + let doc = fixture("legacy-masks.json"); + let layer = &doc["layers"][0]; + let PropValue::Keys(keys) = read_prop(&layer["ks"]["o"]).value else { panic!("keys") }; + assert_eq!(keys[1].s, Some(&json!([100]))); + assert_eq!(hex_color(layer["sc"].as_str().unwrap()), [224.0 / 255.0, 192.0 / 255.0, 64.0 / 255.0]); + let PropValue::Static(pt) = read_prop(&layer["masksProperties"][0]["pt"]).value else { panic!("static") }; + let mask = Bezier::from_json(pt, false).unwrap(); + assert!(mask.closed); + assert_eq!(mask.vertices, [[10.0, 10.0], [110.0, 10.0], [110.0, 110.0], [10.0, 110.0]]); + + // The gradient fill: two colour stops, opaque. + let doc = fixture("shapes-gradient.json"); + let mut fills = vec![]; + fn find<'a>(j: &'a Json, out: &mut Vec<&'a Json>) { + match j { + Json::Object(m) => { + if m.get("ty").and_then(Json::as_str) == Some("gf") { + out.push(j); + } + m.values().for_each(|v| find(v, out)); + } + Json::Array(a) => a.iter().for_each(|v| find(v, out)), + _ => {} + } + } + find(&doc, &mut fills); + let g = &fills[0]["g"]; + let PropValue::Static(stops) = read_prop(&g["k"]).value else { panic!("static") }; + let grad = Gradient::from_flat(stops, g["p"].as_u64().unwrap() as usize).unwrap(); + assert_eq!(grad.colors, [(0.0, [0.1, 0.4, 0.9]), (1.0, [0.9, 0.2, 0.5])]); +} diff --git a/crates/lottie/src/value.rs b/crates/lottie/src/value.rs new file mode 100644 index 00000000..03c77533 --- /dev/null +++ b/crates/lottie/src/value.rs @@ -0,0 +1,73 @@ +//! Tolerant readers and writers of plain values. + +use crate::Json; + +/// Rounds to six decimals, the precision values are written with; a non-finite value becomes 0. +pub fn round6(v: f64) -> f64 { + if v.is_finite() { (v * 1e6).round() / 1e6 } else { 0.0 } +} + +/// The numbers of a value: `5`, `[5]`, `[1, 2, 3]`, or the same wrapped once more (`[[x, y]]`, +/// as a key's `s` may be). `None` when anything in it is not a number. +pub fn nums(j: &Json) -> Option> { + match j { + Json::Number(n) => Some(vec![n.as_f64()?]), + Json::Array(a) => { + if a.len() == 1 + && let Some(inner @ Json::Array(_)) = a.first() + { + return nums(inner); + } + a.iter().map(Json::as_f64).collect() + } + _ => None, + } +} + +/// A colour as `[r, g, b, a]` in 0..=1. Missing components are 0 and a missing alpha is 1. Some +/// files write 0..=255: when a colour component is above 1, the three are divided by 255. +pub fn color(j: &Json) -> Option<[f64; 4]> { + let v = nums(j)?; + let g = |i: usize, d: f64| v.get(i).copied().unwrap_or(d); + let mut c = [*v.first()?, g(1, 0.0), g(2, 0.0), g(3, 1.0)]; + if c.iter().take(3).any(|x| *x > 1.0) { + for x in c.iter_mut().take(3) { + *x /= 255.0; + } + } + Some(c) +} + +/// A `#rrggbb` colour (a solid layer's `sc`) as components in 0..=1. A pair that is not two hex +/// digits is 0. +pub fn hex_color(sc: &str) -> [f32; 3] { + let h = sc.trim_start_matches('#'); + let c = |i: usize| { + let pair = h.get(i..i + 2).filter(|x| x.bytes().all(|b| b.is_ascii_hexdigit())); + f32::from(pair.and_then(|x| u8::from_str_radix(x, 16).ok()).unwrap_or(0)) / 255.0 + }; + [c(0), c(2), c(4)] +} + +/// Components in 0..=1 as `#rrggbb`. +pub fn to_hex(c: [f32; 3]) -> String { + let b = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; + let [r, g, bl] = c; + format!("#{:02x}{:02x}{:02x}", b(r), b(g), b(bl)) +} + +/// The image type `bytes` hold, by their whole signature; `None` for anything else. These are +/// the types an image asset may embed. +pub fn image_mime(bytes: &[u8]) -> Option<&'static str> { + if bytes.starts_with(b"\x89PNG\r\n\x1a\n") { + Some("image/png") + } else if bytes.starts_with(&[0xff, 0xd8, 0xff]) { + Some("image/jpeg") + } else if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") { + Some("image/gif") + } else if bytes.starts_with(b"RIFF") && bytes.get(8..12) == Some(b"WEBP") { + Some("image/webp") + } else { + None + } +} diff --git a/crates/lottie/tests/fixtures/bouncing-ball.json b/crates/lottie/tests/fixtures/bouncing-ball.json new file mode 100644 index 00000000..ec172259 --- /dev/null +++ b/crates/lottie/tests/fixtures/bouncing-ball.json @@ -0,0 +1,51 @@ +{ + "v": "5.12.0", + "nm": "Bouncing Ball", + "fr": 30, + "ip": 0, + "op": 30, + "w": 200, + "h": 200, + "ddd": 0, + "assets": [], + "layers": [ + { + "ddd": 0, + "ind": 1, + "ty": 4, + "nm": "Ball", + "sr": 1, + "ks": { + "a": { "a": 0, "k": [0, 0, 0] }, + "p": { + "a": 1, + "k": [ + { "t": 0, "s": [100, 40, 0], "o": { "x": 0.33, "y": 0 }, "i": { "x": 0.67, "y": 1 }, "to": [0, 0, 0], "ti": [0, 0, 0] }, + { "t": 15, "s": [100, 160, 0], "o": { "x": 0.33, "y": 0 }, "i": { "x": 0.67, "y": 1 }, "to": [0, 0, 0], "ti": [0, 0, 0] }, + { "t": 30, "s": [100, 40, 0] } + ] + }, + "s": { "a": 0, "k": [100, 100, 100] }, + "r": { "a": 0, "k": 0 }, + "o": { "a": 0, "k": 100 } + }, + "ao": 0, + "ip": 0, + "op": 30, + "st": 0, + "bm": 0, + "shapes": [ + { + "ty": "gr", + "nm": "Ball", + "it": [ + { "ty": "el", "nm": "Circle", "d": 1, "p": { "a": 0, "k": [0, 0] }, "s": { "a": 0, "k": [40, 40] } }, + { "ty": "fl", "nm": "Fill", "c": { "a": 0, "k": [0.95, 0.35, 0.2, 1] }, "o": { "a": 0, "k": 100 }, "r": 1 }, + { "ty": "tr", "a": { "a": 0, "k": [0, 0] }, "p": { "a": 0, "k": [0, 0] }, "s": { "a": 0, "k": [100, 100] }, "r": { "a": 0, "k": 0 }, "o": { "a": 0, "k": 100 } } + ] + } + ] + } + ], + "markers": [] +} diff --git a/crates/lottie/tests/fixtures/legacy-masks.json b/crates/lottie/tests/fixtures/legacy-masks.json new file mode 100644 index 00000000..a02d4182 --- /dev/null +++ b/crates/lottie/tests/fixtures/legacy-masks.json @@ -0,0 +1,51 @@ +{ + "v": "4.8.0", + "nm": "Legacy Masks", + "fr": 30, + "ip": 0, + "op": 30, + "w": 120, + "h": 120, + "assets": [], + "layers": [ + { + "ind": 1, + "ty": 1, + "nm": "Card", + "sc": "#e0c040", + "sw": 120, + "sh": 120, + "ip": 0, + "op": 30, + "st": 0, + "sr": 1, + "hasMask": true, + "ks": { + "a": { "a": 0, "k": [60, 60, 0] }, + "p": { "a": 0, "k": [60, 60, 0] }, + "s": { "a": 0, "k": [100, 100, 100] }, + "r": { "a": 0, "k": 0 }, + "o": { "a": 1, "k": [{ "t": 0, "s": [0], "e": [100], "i": { "x": [0.833], "y": [0.833] }, "o": { "x": [0.167], "y": [0.167] } }, { "t": 20 }] } + }, + "masksProperties": [ + { + "nm": "Outer", + "mode": "a", + "inv": false, + "pt": { "a": 0, "k": { "c": true, "v": [[10, 10], [110, 10], [110, 110], [10, 110]], "i": [[0, 0], [0, 0], [0, 0], [0, 0]], "o": [[0, 0], [0, 0], [0, 0], [0, 0]] } }, + "o": { "a": 0, "k": 100 }, + "x": { "a": 0, "k": 0 } + }, + { + "nm": "Hole", + "mode": "s", + "inv": true, + "pt": { "a": 0, "k": { "c": true, "v": [[40, 40], [80, 40], [80, 80], [40, 80]], "i": [[0, 0], [0, 0], [0, 0], [0, 0]], "o": [[0, 0], [0, 0], [0, 0], [0, 0]] } }, + "o": { "a": 0, "k": 100 }, + "x": { "a": 0, "k": 0 } + } + ] + }, + { "ind": 2, "ty": 6, "nm": "Sound", "ip": 0, "op": 30, "st": 0, "sr": 1 } + ] +} diff --git a/crates/lottie/tests/fixtures/precomp-matte-text.json b/crates/lottie/tests/fixtures/precomp-matte-text.json new file mode 100644 index 00000000..d9510ff3 --- /dev/null +++ b/crates/lottie/tests/fixtures/precomp-matte-text.json @@ -0,0 +1,97 @@ +{ + "v": "5.12.0", + "nm": "Precomp Matte Text", + "fr": 30, + "ip": 0, + "op": 30, + "w": 240, + "h": 160, + "fonts": { "list": [{ "fName": "Inter-Regular", "fFamily": "Inter", "fStyle": "Regular", "ascent": 75 }] }, + "assets": [ + { + "id": "comp_bg", + "nm": "Background", + "w": 240, + "h": 160, + "layers": [ + { + "ind": 1, + "ty": 1, + "nm": "Teal", + "sc": "#1a8c8c", + "sw": 240, + "sh": 160, + "ip": 0, + "op": 30, + "st": 0, + "sr": 1, + "ks": { "a": { "a": 0, "k": [120, 80, 0] }, "p": { "a": 0, "k": [120, 80, 0] }, "s": { "a": 0, "k": [100, 100, 100] }, "r": { "a": 0, "k": 0 }, "o": { "a": 0, "k": 100 } } + } + ] + } + ], + "layers": [ + { + "ind": 1, + "ty": 4, + "nm": "Matte", + "td": 1, + "ip": 0, + "op": 30, + "st": 0, + "sr": 1, + "ks": { "a": { "a": 0, "k": [0, 0] }, "p": { "a": 0, "k": [120, 80] }, "s": { "a": 0, "k": [100, 100] }, "r": { "a": 0, "k": 0 }, "o": { "a": 0, "k": 100 } }, + "shapes": [ + { "ty": "el", "nm": "Oval", "p": { "a": 0, "k": [0, 0] }, "s": { "a": 0, "k": [200, 120] } }, + { "ty": "fl", "nm": "Fill", "c": { "a": 0, "k": [1, 1, 1, 1] }, "o": { "a": 0, "k": 100 } } + ] + }, + { + "ind": 2, + "ty": 0, + "nm": "Background", + "refId": "comp_bg", + "tt": 1, + "w": 240, + "h": 160, + "ip": 0, + "op": 30, + "st": 0, + "sr": 1, + "ks": { "a": { "a": 0, "k": [120, 80, 0] }, "p": { "a": 0, "k": [120, 80, 0] }, "s": { "a": 0, "k": [100, 100, 100] }, "r": { "a": 0, "k": 0 }, "o": { "a": 0, "k": 100 } } + }, + { + "ind": 3, + "ty": 5, + "nm": "Title", + "parent": 4, + "ip": 0, + "op": 30, + "st": 0, + "sr": 1, + "ks": { "a": { "a": 0, "k": [0, 0, 0] }, "p": { "a": 0, "k": [0, 0, 0] }, "s": { "a": 0, "k": [100, 100, 100] }, "r": { "a": 0, "k": 0 }, "o": { "a": 0, "k": 100 } }, + "t": { + "d": { "k": [{ "t": 0, "s": { "t": "Hello\rLottie", "f": "Inter-Regular", "s": 28, "fc": [1, 1, 1], "j": 2, "tr": 0, "lh": 33.6, "ls": 0 } }] }, + "a": [], + "m": { "g": 1, "a": { "a": 0, "k": [0, 0] } }, + "p": {} + } + }, + { + "ind": 4, + "ty": 3, + "nm": "Controller", + "ip": 0, + "op": 30, + "st": 0, + "sr": 1, + "ks": { + "a": { "a": 0, "k": [0, 0, 0] }, + "p": { "a": 1, "k": [{ "t": 0, "s": [120, 60, 0], "o": { "x": 0.5, "y": 0 }, "i": { "x": 0.5, "y": 1 } }, { "t": 29, "s": [120, 100, 0] }] }, + "s": { "a": 0, "k": [100, 100, 100] }, + "r": { "a": 0, "k": 0 }, + "o": { "a": 0, "k": 100 } + } + } + ] +} diff --git a/crates/lottie/tests/fixtures/shapes-gradient.json b/crates/lottie/tests/fixtures/shapes-gradient.json new file mode 100644 index 00000000..29de5ebf --- /dev/null +++ b/crates/lottie/tests/fixtures/shapes-gradient.json @@ -0,0 +1,67 @@ +{ + "v": "5.12.0", + "nm": "Shapes and Gradient", + "fr": 24, + "ip": 0, + "op": 24, + "w": 160, + "h": 120, + "assets": [], + "layers": [ + { + "ind": 1, + "ty": 4, + "nm": "Shapes", + "sr": 1, + "ks": { "a": { "a": 0, "k": [0, 0] }, "p": { "a": 0, "k": [80, 60] }, "s": { "a": 0, "k": [100, 100] }, "r": { "a": 0, "k": 0 }, "o": { "a": 0, "k": 100 } }, + "ip": 0, + "op": 24, + "st": 0, + "bm": 0, + "shapes": [ + { + "ty": "gr", + "nm": "Badge", + "it": [ + { "ty": "sr", "nm": "Star", "sy": 1, "d": 1, "pt": { "a": 0, "k": 6 }, "p": { "a": 0, "k": [-30, 0] }, "r": { "a": 0, "k": 0 }, "ir": { "a": 0, "k": 12 }, "or": { "a": 0, "k": 28 }, "is": { "a": 0, "k": 0 }, "os": { "a": 0, "k": 0 } }, + { "ty": "rc", "nm": "Box", "d": 1, "p": { "a": 0, "k": [30, 0] }, "s": { "a": 0, "k": [50, 40] }, "r": { "a": 0, "k": 8 } }, + { + "ty": "gf", + "nm": "Gradient", + "o": { "a": 0, "k": 100 }, + "r": 1, + "s": { "a": 0, "k": [-60, 0] }, + "e": { "a": 0, "k": [60, 0] }, + "t": 1, + "g": { "p": 2, "k": { "a": 0, "k": [0, 0.1, 0.4, 0.9, 1, 0.9, 0.2, 0.5] } } + }, + { + "ty": "st", + "nm": "Outline", + "c": { "a": 0, "k": [1, 1, 1, 1] }, + "o": { "a": 0, "k": 100 }, + "w": { "a": 0, "k": 2 }, + "lc": 2, + "lj": 2, + "ml": 4, + "d": [ + { "n": "d", "nm": "dash", "v": { "a": 0, "k": 4 } }, + { "n": "g", "nm": "gap", "v": { "a": 0, "k": 2 } }, + { "n": "o", "nm": "offset", "v": { "a": 0, "k": 0 } } + ] + }, + { + "ty": "tm", + "nm": "Trim", + "s": { "a": 0, "k": 0 }, + "e": { "a": 1, "k": [{ "t": 0, "s": [10], "o": { "x": [0.167], "y": [0.167] }, "i": { "x": [0.833], "y": [0.833] } }, { "t": 23, "s": [100] }] }, + "o": { "a": 0, "k": 0 }, + "m": 1 + }, + { "ty": "tr", "a": { "a": 0, "k": [0, 0] }, "p": { "a": 0, "k": [0, 0] }, "s": { "a": 0, "k": [100, 100] }, "r": { "a": 0, "k": 0 }, "o": { "a": 0, "k": 100 } } + ] + } + ] + } + ] +}