Add craft-lottie: the Lottie wire layer from EffectCraft (#879)

The encodings every Lottie layer, mask and shape reuses, on serde_json
values: the document header, animatable properties and their keyframes
(legacy end values, hold keys, spatial tangents), ease tangents to and
from speed and influence, bezier paths, gradient stop lists, the tolerant
number and colour readers, and embedded images as data URIs.

Taken from effectcraft crates/lottie at e18f48fb (anim.rs, shapes.rs,
import.rs, export.rs, base64.rs) with EffectCraft's project types replaced
by plain values. The layer, shape, effect and text mapping stays in
EffectCraft, and dotLottie is ZIP, which is not this crate's format.
This commit is contained in:
Dylan M. Taylor
2026-11-11 16:24:27 -05:00
committed by GitHub
parent 58b02d4e6a
commit a9243c8c40
17 changed files with 1200 additions and 0 deletions
+1
View File
@@ -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 |
Generated
+8
View File
@@ -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"
+1
View File
@@ -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" }
+4
View File
@@ -116,6 +116,10 @@ craft-<name> = { git = "https://github.com/storytold/craft-libs", rev = "<commit
(`isobmff`) and Matroska/WebM (`matroska`) demuxers and muxers, Ogg (`ogg`) and AVI (`avi`) demuxers, from
FilmCraft, and an ISO BMFF box reader. Containers only; codecs are in `craft-audio`, and
video codecs will go in a separate crate.
- [`craft-lottie`](crates/lottie/README.md): Lottie (Bodymovin) JSON, the wire layer: the document
header, animatable properties and keyframes (legacy end values, hold keys, spatial tangents),
ease tangents to and from speed and influence, bezier paths, gradients and embedded image data
URIs. From EffectCraft; mapping layers and shapes onto a document stays in the app.
- [`craft-menu`](crates/menu/README.md): an app's menus as a macOS menu bar: the Mac layout
(app menu, Window, Help, system-key clashes), a platform backend trait, and key equivalents
fed back to egui as the input egui-winit would have made. Commands and their enabled state
+15
View File
@@ -0,0 +1,15 @@
[package]
name = "craft-lottie"
description = "Lottie (Bodymovin) JSON, the wire layer: the document header, animated properties and their keyframes, ease tangents, bezier paths, gradients, value coercions and embedded image data URIs"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
repository.workspace = true
[lints]
workspace = true
[dependencies]
serde_json = { workspace = true }
thiserror = { workspace = true }
+66
View File
@@ -0,0 +1,66 @@
# craft-lottie
Lottie (Bodymovin) JSON: the wire layer. A Lottie document is JSON, and most of what is hard about
it is a handful of encodings that every layer, mask and shape reuses. This crate reads and writes
those on `serde_json` values. It has no document model of its own yet: which layer, shape, effect
and text feature becomes what in an application's document is the application's work.
No file access: callers pass bytes, so the crate builds for wasm32.
| Part | Read | Write |
|---|---|---|
| Document JSON and header (`w`, `h`, `fr`, `ip`, `op`, `nm`) | `parse_document`, `Header::read` | the application builds the JSON |
| Animatable property (`{a, k, x}`) and keyframes | `read_prop` | `write_prop`, `write_keys` |
| Ease of a segment (`o`, `i`) and speed / influence | `Tangents::read`, `to_speed` | `Tangents::from_speed` |
| Bezier path (`{c, v, i, o}`) | `Bezier::from_json` | `Bezier::to_json` |
| Gradient stop list (`g.k`) | `Gradient::from_flat` | `Gradient::to_flat` |
| Numbers, colours, `#rrggbb` | `nums`, `color`, `hex_color` | `round6`, `to_hex` |
| Embedded images (`data:` URIs, RFC 4648 base64) | `base64::data_uri` | `base64::to_data_uri`, `image_mime` |
Not here: the layer, shape, mask, effect and text item layouts (they are still in EffectCraft's
`effectcraft-lottie`, written against its project model), and dotLottie (`.lottie`), which is a
ZIP archive around the same JSON.
## What the readers accept
Files in the wild come from many Bodymovin versions and other exporters, so the readers are
tolerant where players are:
- a property that is a bare value (`"o": 100`), a property without `a` whose `k` is a key list,
and a key value wrapped once more (`[[x, y]]`);
- keys before 5.0: a segment ends with `e` and the last key has no `s`; the value of a key is
then the `e` of the key before;
- ease tangents whose `x` and `y` are a number or a list, per dimension; a missing `x` is a
third of the way and a missing `y` is linear;
- a path without `c`: Bodymovin 3.x and early 4.x keep the flag outside the path (`cl` on a
mask, `closed` on an `sh` item), so the caller passes it; tangent lists shorter or longer than
the vertex list;
- colours written 0..=255 instead of 0..=1;
- a gradient list without alpha pairs, and a colour count larger than the list.
Input is hostile: nothing is indexed by a number from the file, JSON nesting is bounded by
`serde_json` (128 levels), and no reader panics. Sizes are those of the JSON the caller parsed;
bound the file size before calling.
## Ease
A Lottie keyframe holds the start value of a segment and the segment's normalised cubic Bezier
ease: `o` (out of this key) and `i` (into the next), 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
```
`Tangents::from_speed` and `Tangents::to_speed` are this formula and its inverse. A spatial
property eases along its motion path: one dimension, with `Δv` the arc length, which the caller
computes.
## Sources and clean room
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. The code comes from
EffectCraft's `effectcraft-lottie` crate. The four fixtures in `tests/fixtures` are hand-written
JSON (original work), not exported from any product.
+74
View File
@@ -0,0 +1,74 @@
//! Standard base64 (RFC 4648 §4) and `data:` URIs, for embedded image assets.
const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
pub fn encode(data: &[u8]) -> 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<Vec<u8>> {
let val = |c: u8| -> Option<u32> {
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<u8> = 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:<mime>;base64,<payload>` URI of `bytes`.
pub fn to_data_uri(mime: &str, bytes: &[u8]) -> String {
format!("data:{mime};base64,{}", encode(bytes))
}
/// Split a `data:<mime>;base64,<payload>` URI into (mime, bytes).
pub fn data_uri(s: &str) -> Option<(String, Vec<u8>)> {
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=");
}
}
+46
View File
@@ -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<Self> {
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)
}
}
+79
View File
@@ -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<Json, LottieError> {
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<Self, LottieError> {
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;
+46
View File
@@ -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<Self> {
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::<Vec<_>>();
json!({"c": self.closed, "v": pts(&self.vertices), "i": pts(&self.in_tangents), "o": pts(&self.out_tangents)})
}
}
+215
View File
@@ -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<f64> {
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<Key<'a>>),
}
/// 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<Tangents>,
/// `(to, ti)`: the spatial tangents out of this key and into the next.
pub spatial: Option<(Vec<f64>, Vec<f64>)>,
}
/// 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<Json> {
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::<Vec<_>>();
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::<Vec<_>>());
o["ti"] = json!(ti.iter().map(|v| round6(*v)).collect::<Vec<_>>());
}
}
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
}
+306
View File
@@ -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])]);
}
+73
View File
@@ -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<Vec<f64>> {
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
}
}
+51
View File
@@ -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": []
}
+51
View File
@@ -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 }
]
}
+97
View File
@@ -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 }
}
}
]
}
+67
View File
@@ -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 } }
]
}
]
}
]
}