mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-28 20:16:52 -06:00
760 lines
37 KiB
Rust
760 lines
37 KiB
Rust
//! Round-trip tests: build comps feature by feature, export → import, compare property values
|
|
//! and rendered frames; plus easing math and document-level checks.
|
|
|
|
use effectcraft_color::{BlendMode, Label};
|
|
use effectcraft_keyframe::{Ease, Gradient, Interp, Keyframe, ShapePath, TextDoc, Value};
|
|
use effectcraft_project::build::{self, Ids};
|
|
use effectcraft_project::{
|
|
AlphaMode, Comp, Expression, Footage, FootageKind, GroupKind, ItemId, ItemKind, Layer, LayerId, LayerSource, Marker, MaskMode, MatteKind, Node, Project,
|
|
PropGroup, Solid, TrackMatte,
|
|
};
|
|
use effectcraft_render::render_frame;
|
|
use effectcraft_time::{FrameRate, Tick};
|
|
use serde_json::{Value as Json, json};
|
|
|
|
use crate::{ExportOptions, export_comp, import};
|
|
|
|
fn s(x: f64) -> Tick {
|
|
Tick::from_seconds_f64(x)
|
|
}
|
|
|
|
fn setup() -> (Project, ItemId) {
|
|
let mut p = Project::default();
|
|
let comp = Comp::new(200, 120, FrameRate::FPS_30, s(2.0));
|
|
let cid = p.add_item("Main", Label::Sandstone, None, ItemKind::Comp(comp.into()));
|
|
(p, cid)
|
|
}
|
|
|
|
fn comp(p: &Project, cid: ItemId) -> Comp {
|
|
p.comp(cid).unwrap().clone()
|
|
}
|
|
|
|
fn push(p: &mut Project, cid: ItemId, l: Layer) -> LayerId {
|
|
let id = l.id;
|
|
p.comp_mut(cid).unwrap().layers.push(l);
|
|
id
|
|
}
|
|
|
|
fn solid_layer(p: &mut Project, cid: ItemId, color: [f32; 3], w: u32, h: u32) -> Layer {
|
|
let c = comp(p, cid);
|
|
let sid = p.add_item("Solid", Label::Red, None, ItemKind::Solid(Solid { color, width: w, height: h, pixel_aspect: 1.0 }));
|
|
build::layer(p, &c, "Solid", LayerSource::Solid { item: sid }, (w, h), None)
|
|
}
|
|
|
|
fn shape_layer(p: &mut Project, cid: ItemId, items: impl FnOnce(&mut Ids) -> Vec<PropGroup>) -> Layer {
|
|
let c = comp(p, cid);
|
|
let mut l = build::layer(p, &c, "Shape Layer 1", LayerSource::Shape, (c.width, c.height), None);
|
|
let mut next = p.next_id;
|
|
let groups = items(&mut Ids(&mut next));
|
|
p.next_id = next;
|
|
l.props.sub_mut("contents").unwrap().children.extend(groups.into_iter().map(Node::Group));
|
|
l
|
|
}
|
|
|
|
fn no_read(_: &str) -> Option<Vec<u8>> {
|
|
None
|
|
}
|
|
|
|
/// Export comp `cid`, serialise, import into a fresh project.
|
|
fn roundtrip(p: &Project, cid: ItemId, opts: &ExportOptions) -> (Project, ItemId, Vec<String>, Json) {
|
|
let res = export_comp(p, cid, opts, &no_read).unwrap();
|
|
let text = res.to_string_compact();
|
|
let mut q = Project::default();
|
|
let imp = import(&mut q, text.as_bytes(), "Imported", "", &mut |_| None).unwrap();
|
|
(q, imp.comp, res.warnings, res.json)
|
|
}
|
|
|
|
fn close(a: &Value, b: &Value, tol: f64) -> bool {
|
|
let near = |x: f64, y: f64| (x - y).abs() <= tol * (1.0 + x.abs().max(y.abs()) * 1e-3);
|
|
match (a, b) {
|
|
(Value::Path(p), Value::Path(q)) => {
|
|
let pts = |u: &[[f64; 2]], v: &[[f64; 2]]| u.len() == v.len() && u.iter().zip(v).all(|(m, n)| near(m[0], n[0]) && near(m[1], n[1]));
|
|
p.closed == q.closed && pts(&p.vertices, &q.vertices) && pts(&p.in_tangents, &q.in_tangents) && pts(&p.out_tangents, &q.out_tangents)
|
|
}
|
|
(Value::Gradient(g), Value::Gradient(h)) => {
|
|
g.colors.len() == h.colors.len()
|
|
&& g.colors.iter().zip(&h.colors).all(|(x, y)| near(x.0, y.0) && (0..3).all(|i| near(x.1[i] as f64, y.1[i] as f64)))
|
|
&& g.opacities.iter().zip(&h.opacities).all(|(x, y)| near(x.0, y.0) && near(x.1 as f64, y.1 as f64))
|
|
}
|
|
(Value::Text(x), Value::Text(y)) => x == y,
|
|
_ => {
|
|
let (ca, cb) = (a.components(), b.components());
|
|
ca.len() == cb.len() && ca.iter().zip(&cb).all(|(x, y)| near(*x, *y))
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Compare every property of `a` with the same-path property of `b` at the sample times.
|
|
/// Returns the number of properties compared.
|
|
fn compare_tree(a: &PropGroup, b: &PropGroup, times: &[Tick], path: &str) -> usize {
|
|
let mut n = 0;
|
|
for (i, c) in a.children.iter().enumerate() {
|
|
let occ = a.children[..i].iter().filter(|o| o.match_id() == c.match_id()).count();
|
|
let Some(other) = b.children.iter().filter(|o| o.match_id() == c.match_id()).nth(occ) else {
|
|
continue;
|
|
};
|
|
let here = format!("{path}/{}", c.match_id());
|
|
match (c, other) {
|
|
(Node::Prop(x), Node::Prop(y)) => {
|
|
for t in times {
|
|
let (va, vb) = (x.value_at(*t), y.value_at(*t));
|
|
assert!(close(&va, &vb, 1e-3), "{here} at {:.3}s: {va:?} != {vb:?}", t.seconds());
|
|
}
|
|
assert_eq!(x.expr.as_ref().map(|e| &e.text), y.expr.as_ref().map(|e| &e.text), "{here}: expression");
|
|
n += 1;
|
|
}
|
|
(Node::Group(x), Node::Group(y)) => {
|
|
if let (GroupKind::Mask { mode: m1, inverted: i1, .. }, GroupKind::Mask { mode: m2, inverted: i2, .. }) = (&x.kind, &y.kind) {
|
|
assert_eq!((m1, i1), (m2, i2), "{here}: mask mode");
|
|
}
|
|
assert_eq!(x.enabled, y.enabled, "{here}: enabled");
|
|
n += compare_tree(x, y, times, &here);
|
|
}
|
|
_ => panic!("{here}: node kind differs"),
|
|
}
|
|
}
|
|
n
|
|
}
|
|
|
|
fn sample_times(c: &Comp) -> Vec<Tick> {
|
|
(0..=12).map(|i| Tick(c.duration.0 * i / 12)).collect()
|
|
}
|
|
|
|
/// Compare layers (timing, switches, mattes, parents, properties).
|
|
fn compare_comps(p: &Project, a: ItemId, q: &Project, b: ItemId) -> usize {
|
|
let (ca, cb) = (p.comp(a).unwrap(), q.comp(b).unwrap());
|
|
assert_eq!((ca.width, ca.height, ca.frame_rate, ca.duration), (cb.width, cb.height, cb.frame_rate, cb.duration));
|
|
assert_eq!(ca.layers.len(), cb.layers.len(), "layer count");
|
|
let times = sample_times(ca);
|
|
let mut n = 0;
|
|
for (la, lb) in ca.layers.iter().zip(&cb.layers) {
|
|
assert_eq!(la.name, lb.name);
|
|
assert_eq!(la.source.type_name(), lb.source.type_name(), "{}", la.name);
|
|
assert_eq!((la.in_point, la.out_point, la.start_time), (lb.in_point, lb.out_point, lb.start_time), "{}: timing", la.name);
|
|
assert!((la.stretch - lb.stretch).abs() < 1e-9, "{}: stretch", la.name);
|
|
assert_eq!(la.blend_mode, lb.blend_mode, "{}: blend", la.name);
|
|
assert_eq!(la.parent.and_then(|x| ca.index_of(x)), lb.parent.and_then(|x| cb.index_of(x)), "{}: parent", la.name);
|
|
assert_eq!(la.track_matte.map(|m| (ca.index_of(m.layer), m.kind)), lb.track_matte.map(|m| (cb.index_of(m.layer), m.kind)), "{}: matte", la.name);
|
|
n += compare_tree(&la.props, &lb.props, ×, &la.name);
|
|
}
|
|
n
|
|
}
|
|
|
|
/// Rendered frames must agree within a small tolerance.
|
|
fn compare_renders(p: &Project, a: ItemId, q: &Project, b: ItemId, tol: f32) {
|
|
let c = p.comp(a).unwrap();
|
|
assert!(sample_times(c).into_iter().any(|t| not_blank(p, a, t)), "nothing rendered");
|
|
for t in sample_times(c).into_iter().step_by(3) {
|
|
let (x, y) = (render_frame(p, a, t, 0.5), render_frame(q, b, t, 0.5));
|
|
assert_eq!((x.width, x.height), (y.width, y.height));
|
|
let mut worst = 0.0f32;
|
|
let mut bad = 0usize;
|
|
for (u, v) in x.data.iter().zip(&y.data) {
|
|
let d = (0..4).map(|i| (u[i] - v[i]).abs()).fold(0.0, f32::max);
|
|
worst = worst.max(d);
|
|
if d > tol {
|
|
bad += 1;
|
|
}
|
|
}
|
|
assert!(bad * 200 <= x.data.len(), "at {:.2}s: {bad} pixels differ by more than {tol} (worst {worst})", t.seconds());
|
|
}
|
|
}
|
|
|
|
fn not_blank(p: &Project, cid: ItemId, t: Tick) -> bool {
|
|
render_frame(p, cid, t, 0.5).data.iter().any(|px| px[3] > 0.5)
|
|
}
|
|
|
|
// ---------------------------------------------------------------- easing math
|
|
|
|
#[test]
|
|
fn easing_converts_exactly_both_ways() {
|
|
let (mut p, cid) = setup();
|
|
let mut l = solid_layer(&mut p, cid, [1.0, 0.5, 0.0], 40, 40);
|
|
let tr = l.props.sub_mut("transform").unwrap();
|
|
// Custom speed / influence (non-spatial), easy ease, hold, auto-bezier and linear sides.
|
|
let rot = tr.get_mut("rotation").unwrap();
|
|
let mut a = Keyframe::new(s(0.0), Value::Scalar(0.0));
|
|
a.out_interp = Interp::Bezier;
|
|
a.out_ease = vec![Ease { speed: 300.0, influence: 0.6 }];
|
|
let mut b = Keyframe::new(s(0.8), Value::Scalar(180.0));
|
|
b.in_interp = Interp::Bezier;
|
|
b.in_ease = vec![Ease { speed: -50.0, influence: 0.2 }];
|
|
b.out_interp = Interp::Hold;
|
|
let c = Keyframe::new(s(1.2), Value::Scalar(90.0)).eased();
|
|
let mut d = Keyframe::new(s(1.6), Value::Scalar(45.0));
|
|
d.auto_bezier = true;
|
|
d.in_interp = Interp::Bezier;
|
|
d.out_interp = Interp::Bezier;
|
|
let e = Keyframe::new(s(1.9), Value::Scalar(0.0));
|
|
rot.keys = vec![a, b, c, d, e];
|
|
let sc = tr.get_mut("scale").unwrap();
|
|
sc.keys = vec![Keyframe::new(s(0.0), Value::Vec3([100.0, 50.0, 100.0])).eased(), Keyframe::new(s(1.0), Value::Vec3([20.0, 150.0, 100.0])).eased()];
|
|
sc.keys[0].out_ease = vec![Ease { speed: 10.0, influence: 0.9 }, Ease { speed: 0.0, influence: 0.1 }, Ease::EASY];
|
|
push(&mut p, cid, l);
|
|
let (q, nc, _, json) = roundtrip(&p, cid, &ExportOptions::default());
|
|
// The Lottie tangents of the first rotation segment: o = (0.6, 300·0.6·0.8/180).
|
|
let k0 = &json["layers"][0]["ks"]["r"]["k"][0];
|
|
assert!((k0["o"]["x"][0].as_f64().unwrap() - 0.6).abs() < 1e-9);
|
|
assert!((k0["o"]["y"][0].as_f64().unwrap() - 300.0 * 0.6 * 0.8 / 180.0).abs() < 1e-5);
|
|
assert_eq!(json["layers"][0]["ks"]["r"]["k"][1]["h"], json!(1));
|
|
let scale = &json["layers"][0]["ks"]["s"]["k"][0];
|
|
assert!((scale["o"]["x"][0].as_f64().unwrap() - 0.9).abs() < 1e-9);
|
|
assert!((scale["i"]["x"][0].as_f64().unwrap() - 2.0 / 3.0).abs() < 1e-6);
|
|
assert!((scale["o"]["y"][0].as_f64().unwrap() + 0.1125).abs() < 1e-9);
|
|
let rq = &q.comp(nc).unwrap().layers[0];
|
|
let rk = &rq.props.prop("transform/rotation").unwrap().keys;
|
|
assert!((rk[0].out_ease[0].speed - 300.0).abs() < 1e-3 && (rk[0].out_ease[0].influence - 0.6).abs() < 1e-6, "{:?}", rk[0].out_ease);
|
|
assert!((rk[1].in_ease[0].speed + 50.0).abs() < 1e-3);
|
|
assert_eq!(rk[1].out_interp, Interp::Hold);
|
|
assert!(compare_comps(&p, cid, &q, nc) > 5);
|
|
}
|
|
|
|
#[test]
|
|
fn spatial_tangents_and_separated_dimensions() {
|
|
let (mut p, cid) = setup();
|
|
let mut l = solid_layer(&mut p, cid, [0.0, 1.0, 0.0], 20, 20);
|
|
let pos = l.props.prop_mut("transform/position").unwrap();
|
|
let mut k1 = Keyframe::new(s(0.0), Value::Vec3([20.0, 20.0, 0.0])).eased();
|
|
k1.spatial_auto = false;
|
|
k1.spatial_out = [80.0, 0.0, 0.0];
|
|
let k2 = Keyframe::new(s(1.0), Value::Vec3([180.0, 100.0, 0.0])).eased();
|
|
let k3 = Keyframe::new(s(1.5), Value::Vec3([100.0, 60.0, 0.0]));
|
|
pos.keys = vec![k1, k2, k3];
|
|
push(&mut p, cid, l);
|
|
// A second layer with separated X/Y position.
|
|
let mut l2 = solid_layer(&mut p, cid, [0.0, 0.0, 1.0], 20, 20);
|
|
let mut next = p.next_id;
|
|
let tr = l2.props.sub_mut("transform").unwrap();
|
|
for (m, name, keys) in [
|
|
("positionY", "Y Position", vec![Keyframe::new(s(0.0), Value::Scalar(10.0)).eased(), Keyframe::new(s(2.0), Value::Scalar(110.0))]),
|
|
("positionX", "X Position", vec![Keyframe::new(s(0.5), Value::Scalar(30.0)), Keyframe::new(s(1.5), Value::Scalar(170.0)).eased()]),
|
|
] {
|
|
let mut pr = effectcraft_project::Property::new(next, m, name, Value::Scalar(0.0));
|
|
next += 1;
|
|
pr.keys = keys;
|
|
tr.children.insert(2, Node::Prop(pr));
|
|
}
|
|
let mut pz = effectcraft_project::Property::new(next, "positionZ", "Z Position", Value::Scalar(0.0));
|
|
pz.three_d_only = true;
|
|
next += 1;
|
|
tr.children.insert(4, Node::Prop(pz));
|
|
p.next_id = next;
|
|
push(&mut p, cid, l2);
|
|
let (q, nc, _, json) = roundtrip(&p, cid, &ExportOptions::default());
|
|
assert_eq!(json["layers"][1]["ks"]["p"]["s"], json!(true));
|
|
let to = &json["layers"][0]["ks"]["p"]["k"][0]["to"];
|
|
assert_eq!(to[0].as_f64(), Some(80.0));
|
|
assert!(compare_comps(&p, cid, &q, nc) > 10);
|
|
compare_renders(&p, cid, &q, nc, 2.0 / 255.0);
|
|
}
|
|
|
|
// ---------------------------------------------------------------- shapes
|
|
|
|
#[test]
|
|
fn shape_contents_roundtrip() {
|
|
let (mut p, cid) = setup();
|
|
let l = shape_layer(&mut p, cid, |ids| {
|
|
let mut rect = build::shape_rect(ids, [60.0, 40.0], [0.0, 0.0], 6.0);
|
|
rect.get_mut("size").unwrap().keys = vec![Keyframe::new(s(0.0), Value::Vec2([60.0, 40.0])).eased(), Keyframe::new(s(1.5), Value::Vec2([90.0, 20.0]))];
|
|
let ell = build::shape_ellipse(ids, [30.0, 30.0], [20.0, 10.0]);
|
|
let mut stroke = build::shape_stroke(ids, [0.0, 0.0, 1.0, 1.0], 3.0);
|
|
{
|
|
let d = stroke.sub_mut("dashes").unwrap();
|
|
d.get_mut("dash").unwrap().value = Value::Scalar(6.0);
|
|
d.get_mut("gap").unwrap().value = Value::Scalar(3.0);
|
|
}
|
|
stroke.get_mut("cap").unwrap().value = Value::Enum(1);
|
|
stroke.get_mut("join").unwrap().value = Value::Enum(2);
|
|
let mut fill = build::shape_fill(ids, [1.0, 0.2, 0.2, 1.0]);
|
|
fill.get_mut("color").unwrap().keys =
|
|
vec![Keyframe::new(s(0.0), Value::Color([1.0, 0.2, 0.2, 1.0])).hold(), Keyframe::new(s(1.0), Value::Color([0.2, 1.0, 0.2, 1.0])).hold()];
|
|
fill.get_mut("rule").unwrap().value = Value::Enum(1);
|
|
let mut g = build::shape_group(ids, "Group A", vec![rect, ell, stroke, fill]);
|
|
let tr = g.sub_mut("transform").unwrap();
|
|
tr.get_mut("position").unwrap().keys = vec![Keyframe::new(s(0.0), Value::Vec2([60.0, 60.0])), Keyframe::new(s(2.0), Value::Vec2([90.0, 50.0]))];
|
|
tr.get_mut("rotation").unwrap().value = Value::Scalar(15.0);
|
|
tr.get_mut("skew").unwrap().value = Value::Scalar(10.0);
|
|
g.get_mut("blend").unwrap().value = Value::Enum(BlendMode::ALL.iter().position(|b| *b == BlendMode::Screen).unwrap() as u32);
|
|
let star = build::shape_star(ids, true, 5.0, [150.0, 40.0], 30.0, 12.0);
|
|
let poly = build::shape_star(ids, false, 6.0, [150.0, 95.0], 20.0, 0.0);
|
|
let path = build::shape_path(
|
|
ids,
|
|
ShapePath {
|
|
vertices: vec![[10.0, 110.0], [40.0, 80.0], [70.0, 110.0]],
|
|
in_tangents: vec![[0.0, 0.0], [-10.0, 0.0], [0.0, 0.0]],
|
|
out_tangents: vec![[0.0, 0.0], [10.0, 0.0], [0.0, 0.0]],
|
|
closed: true,
|
|
feather: Vec::new(),
|
|
},
|
|
);
|
|
let grad = Gradient {
|
|
colors: vec![(0.0, [1.0, 1.0, 0.0, 1.0]), (0.5, [0.0, 1.0, 1.0, 1.0]), (1.0, [1.0, 0.0, 1.0, 1.0])],
|
|
opacities: vec![(0.0, 1.0), (1.0, 0.5)],
|
|
};
|
|
let gfill = build::shape_gradient_fill(ids, true, [150.0, 40.0], [180.0, 40.0], grad.clone());
|
|
let gstroke = build::shape_gradient_stroke(ids, false, [130.0, 95.0], [170.0, 95.0], grad, 4.0);
|
|
let mut g2 = build::shape_group(ids, "Group B", vec![star, gfill, poly, gstroke]);
|
|
g2.enabled = true;
|
|
let mut fill2 = build::shape_fill(ids, [0.9, 0.9, 0.1, 1.0]);
|
|
fill2.get_mut("opacity").unwrap().value = Value::Scalar(70.0);
|
|
let g3 = build::shape_group(ids, "Group C", vec![path, fill2]);
|
|
vec![g, g2, g3]
|
|
});
|
|
push(&mut p, cid, l);
|
|
let (q, nc, warnings, _) = roundtrip(&p, cid, &ExportOptions::default());
|
|
assert!(warnings.is_empty(), "{warnings:?}");
|
|
assert!(compare_comps(&p, cid, &q, nc) > 60);
|
|
compare_renders(&p, cid, &q, nc, 2.0 / 255.0);
|
|
}
|
|
|
|
#[test]
|
|
fn path_operators_roundtrip() {
|
|
let (mut p, cid) = setup();
|
|
let l = shape_layer(&mut p, cid, |ids| {
|
|
let mut out = vec![];
|
|
for (k, (x, y)) in ["round", "offset", "pucker", "twist", "zigzag", "merge"].iter().zip([
|
|
(30.0, 30.0),
|
|
(90.0, 30.0),
|
|
(150.0, 30.0),
|
|
(30.0, 90.0),
|
|
(90.0, 90.0),
|
|
(150.0, 90.0),
|
|
]) {
|
|
let rect = build::shape_rect(ids, [36.0, 30.0], [x, y], 0.0);
|
|
let ell = build::shape_ellipse(ids, [20.0, 20.0], [x + 10.0, y]);
|
|
let mut op = build::shape_simple_op(ids, k).unwrap();
|
|
match *k {
|
|
"pucker" => op.get_mut("amount").unwrap().value = Value::Scalar(-30.0),
|
|
"twist" => op.get_mut("angle").unwrap().value = Value::Scalar(40.0),
|
|
"zigzag" => op.get_mut("points").unwrap().value = Value::Enum(1),
|
|
"merge" => op.get_mut("mode").unwrap().value = Value::Enum(2),
|
|
"offset" => op.get_mut("amount").unwrap().value = Value::Scalar(-4.0),
|
|
_ => {}
|
|
}
|
|
let fill = build::shape_fill(ids, [0.2, 0.6, 1.0, 1.0]);
|
|
out.push(build::shape_group(ids, k, vec![rect, ell, op, fill]));
|
|
}
|
|
// Trim paths (animated) and a repeater.
|
|
let ell = build::shape_ellipse(ids, [50.0, 50.0], [100.0, 60.0]);
|
|
let mut trim = build::shape_trim(ids, 0.0, 100.0, 0.0);
|
|
trim.get_mut("end").unwrap().keys = vec![Keyframe::new(s(0.0), Value::Scalar(0.0)).eased(), Keyframe::new(s(1.5), Value::Scalar(100.0)).eased()];
|
|
trim.get_mut("mode").unwrap().value = Value::Enum(1);
|
|
let st = build::shape_stroke(ids, [1.0, 1.0, 1.0, 1.0], 4.0);
|
|
out.push(build::shape_group(ids, "Trim", vec![ell, trim, st]));
|
|
let r = build::shape_rect(ids, [8.0, 8.0], [10.0, 10.0], 0.0);
|
|
let f = build::shape_fill(ids, [1.0, 0.0, 0.0, 1.0]);
|
|
let mut rep = build::shape_repeater(ids, 4.0, [12.0, 0.0]);
|
|
rep.get_mut("composite").unwrap().value = Value::Enum(1);
|
|
let rt = rep.sub_mut("transform").unwrap();
|
|
rt.get_mut("rotation").unwrap().value = Value::Scalar(10.0);
|
|
rt.get_mut("endOpacity").unwrap().value = Value::Scalar(30.0);
|
|
out.push(build::shape_group(ids, "Repeat", vec![r, f, rep]));
|
|
out
|
|
});
|
|
push(&mut p, cid, l);
|
|
let (q, nc, warnings, _) = roundtrip(&p, cid, &ExportOptions::default());
|
|
assert!(warnings.is_empty(), "{warnings:?}");
|
|
assert!(compare_comps(&p, cid, &q, nc) > 50);
|
|
compare_renders(&p, cid, &q, nc, 2.0 / 255.0);
|
|
}
|
|
|
|
// ---------------------------------------------------------------- layers
|
|
|
|
#[test]
|
|
fn layers_parenting_timing_mattes_masks_and_blend_modes() {
|
|
let (mut p, cid) = setup();
|
|
let c = comp(&p, cid);
|
|
// Null parent with rotation; child solid.
|
|
let mut null = build::layer(&mut p, &c, "Null 1", LayerSource::Null, (100, 100), None);
|
|
null.props.prop_mut("transform/rotation").unwrap().keys = vec![Keyframe::new(s(0.0), Value::Scalar(0.0)), Keyframe::new(s(2.0), Value::Scalar(90.0))];
|
|
let null_id = null.id;
|
|
let mut child = solid_layer(&mut p, cid, [1.0, 1.0, 0.0], 40, 20);
|
|
child.parent = Some(null_id);
|
|
child.props.prop_mut("transform/anchor").unwrap().value = Value::Vec3([0.0, 0.0, 0.0]);
|
|
child.props.prop_mut("transform/scale").unwrap().value = Value::Vec3([150.0, 80.0, 100.0]);
|
|
child.in_point = s(0.2);
|
|
child.out_point = s(1.8);
|
|
child.start_time = s(0.1);
|
|
child.stretch = 200.0;
|
|
child.props.prop_mut("transform/opacity").unwrap().keys = vec![Keyframe::new(s(0.0), Value::Scalar(20.0)), Keyframe::new(s(0.8), Value::Scalar(100.0))];
|
|
// Matte (ellipse shape) over a solid with masks and a blend mode.
|
|
let matte = shape_layer(&mut p, cid, |ids| {
|
|
let e = build::shape_ellipse(ids, [120.0, 80.0], [100.0, 60.0]);
|
|
let f = build::shape_fill(ids, [1.0, 1.0, 1.0, 1.0]);
|
|
vec![build::shape_group(ids, "Ellipse", vec![e, f])]
|
|
});
|
|
let mut matte = matte;
|
|
matte.name = "Matte".into();
|
|
matte.switches.video = false;
|
|
let matte_id = matte.id;
|
|
let mut masked = solid_layer(&mut p, cid, [0.2, 0.4, 1.0], 200, 120);
|
|
masked.name = "Masked".into();
|
|
masked.blend_mode = BlendMode::Multiply;
|
|
masked.track_matte = Some(TrackMatte { layer: matte_id, kind: MatteKind::Alpha });
|
|
let mut next = p.next_id;
|
|
{
|
|
let mut ids = Ids(&mut next);
|
|
let mut m1 = build::mask(&mut ids, "Mask 1", ShapePath::rect([100.0, 60.0], 150.0, 90.0), MaskMode::Add, [255, 0, 0]);
|
|
m1.get_mut("expansion").unwrap().value = Value::Scalar(-5.0);
|
|
m1.get_mut("path").unwrap().keys = vec![
|
|
Keyframe::new(s(0.0), Value::Path(ShapePath::rect([100.0, 60.0], 150.0, 90.0))),
|
|
Keyframe::new(s(2.0), Value::Path(ShapePath::rect([90.0, 60.0], 120.0, 80.0))),
|
|
];
|
|
let mut m2 = build::mask(&mut ids, "Mask 2", ShapePath::ellipse([100.0, 60.0], 40.0, 40.0), MaskMode::Subtract, [0, 255, 0]);
|
|
m2.get_mut("opacity").unwrap().value = Value::Scalar(60.0);
|
|
let mut m3 = build::mask(&mut ids, "Mask 3", ShapePath::rect([30.0, 30.0], 20.0, 20.0), MaskMode::Add, [0, 0, 255]);
|
|
if let GroupKind::Mask { inverted, .. } = &mut m3.kind {
|
|
*inverted = true;
|
|
}
|
|
m3.get_mut("feather").unwrap().value = Value::Vec2([4.0, 4.0]);
|
|
let masks = masked.props.sub_mut("masks").unwrap();
|
|
masks.children.extend([m1, m2, m3].map(Node::Group));
|
|
}
|
|
p.next_id = next;
|
|
// A luma-inverted matte pair below.
|
|
let mut luma = solid_layer(&mut p, cid, [0.5, 0.5, 0.5], 200, 120);
|
|
luma.name = "Luma".into();
|
|
luma.switches.video = false;
|
|
let luma_id = luma.id;
|
|
let mut bg = solid_layer(&mut p, cid, [0.9, 0.1, 0.1], 200, 120);
|
|
bg.name = "Bg".into();
|
|
bg.track_matte = Some(TrackMatte { layer: luma_id, kind: MatteKind::LumaInverted });
|
|
for l in [null, child, matte, masked, luma, bg] {
|
|
push(&mut p, cid, l);
|
|
}
|
|
p.comp_mut(cid).unwrap().markers.push(Marker { time: s(1.0), duration: s(0.5), comment: "beat".into(), ..Default::default() });
|
|
let (q, nc, warnings, json) = roundtrip(&p, cid, &ExportOptions::default());
|
|
assert!(warnings.iter().any(|w| w.contains("feather")), "{warnings:?}");
|
|
assert_eq!(json["layers"][2]["td"], json!(1));
|
|
assert_eq!(json["layers"][3]["tt"], json!(1));
|
|
assert_eq!(json["layers"][3]["masksProperties"][1]["mode"], json!("s"));
|
|
assert_eq!(json["layers"][5]["tt"], json!(4));
|
|
assert_eq!(json["markers"][0]["cm"], json!("beat"));
|
|
assert_eq!(q.comp(nc).unwrap().markers[0].time, s(1.0));
|
|
assert!(compare_comps(&p, cid, &q, nc) > 20);
|
|
compare_renders(&p, cid, &q, nc, 2.0 / 255.0);
|
|
}
|
|
|
|
#[test]
|
|
fn precomp_with_time_remap() {
|
|
let (mut p, cid) = setup();
|
|
let inner = p.add_item("Inner", Label::Sandstone, None, ItemKind::Comp(Comp::new(100, 100, FrameRate::FPS_30, s(2.0)).into()));
|
|
let sl = shape_layer(&mut p, inner, |ids| {
|
|
let mut r = build::shape_rect(ids, [40.0, 40.0], [0.0, 0.0], 0.0);
|
|
r.get_mut("position").unwrap().keys = vec![Keyframe::new(s(0.0), Value::Vec2([-20.0, 0.0])), Keyframe::new(s(2.0), Value::Vec2([60.0, 0.0]))];
|
|
let f = build::shape_fill(ids, [0.0, 1.0, 0.5, 1.0]);
|
|
vec![build::shape_group(ids, "Box", vec![r, f])]
|
|
});
|
|
push(&mut p, inner, sl);
|
|
let c = comp(&p, cid);
|
|
let mut pre = build::layer(&mut p, &c, "Inner", LayerSource::Comp { item: inner }, (100, 100), None);
|
|
let mut next = p.next_id;
|
|
let mut tr = Ids(&mut next).prop("timeRemap", "Time Remap", Value::Scalar(0.0));
|
|
tr.keys = vec![Keyframe::new(s(0.0), Value::Scalar(1.5)), Keyframe::new(s(2.0), Value::Scalar(0.0))];
|
|
p.next_id = next;
|
|
pre.props.children.insert(0, Node::Prop(tr));
|
|
push(&mut p, cid, pre);
|
|
let (q, nc, _, json) = roundtrip(&p, cid, &ExportOptions::default());
|
|
assert_eq!(json["assets"].as_array().unwrap().len(), 1);
|
|
assert!(json["layers"][0]["tm"].is_object());
|
|
assert!(compare_comps(&p, cid, &q, nc) > 5);
|
|
let qi = match &q.comp(nc).unwrap().layers[0].source {
|
|
LayerSource::Comp { item } => *item,
|
|
_ => panic!("not a precomp"),
|
|
};
|
|
assert!(compare_comps(&p, inner, &q, qi) > 3);
|
|
compare_renders(&p, cid, &q, nc, 2.0 / 255.0);
|
|
}
|
|
|
|
#[test]
|
|
fn text_layers_roundtrip_as_text_and_as_shapes() {
|
|
let (mut p, cid) = setup();
|
|
let c = comp(&p, cid);
|
|
let mut l = build::layer(&mut p, &c, "Title", LayerSource::Text, (c.width, c.height), None);
|
|
let doc = TextDoc { text: "Hi\nthere".into(), size: 30.0, fill: [1.0, 0.8, 0.1, 1.0], tracking: 20.0, ..Default::default() };
|
|
let doc2 = TextDoc { text: "Bye".into(), ..doc.clone() };
|
|
let st = l.props.prop_mut("text/sourceText").unwrap();
|
|
st.value = Value::Text(Box::new(doc.clone()));
|
|
st.keys = vec![Keyframe::new(s(0.0), Value::Text(Box::new(doc))).hold(), Keyframe::new(s(1.5), Value::Text(Box::new(doc2))).hold()];
|
|
l.props.prop_mut("transform/position").unwrap().value = Value::Vec3([30.0, 50.0, 0.0]);
|
|
let mut next = p.next_id;
|
|
{
|
|
let mut ids = Ids(&mut next);
|
|
let mut props = build::text_anim_props(&mut ids, "opacity", false);
|
|
props.extend(build::text_anim_props(&mut ids, "position", false));
|
|
let mut a = build::text_animator(&mut ids, "Animator 1", props);
|
|
let pr = a.sub_mut("properties").unwrap();
|
|
pr.get_mut("opacity").unwrap().value = Value::Scalar(0.0);
|
|
pr.get_mut("position").unwrap().value = Value::Vec3([0.0, 20.0, 0.0]);
|
|
let sel = a.sub_mut("selectors").unwrap().children[0].as_group_mut().unwrap();
|
|
sel.get_mut("start").unwrap().keys = vec![Keyframe::new(s(0.0), Value::Scalar(0.0)), Keyframe::new(s(1.0), Value::Scalar(100.0))];
|
|
sel.sub_mut("advanced").unwrap().get_mut("shape").unwrap().value = Value::Enum(1);
|
|
l.props.group_mut("text/animators").unwrap().children.push(Node::Group(a));
|
|
}
|
|
p.next_id = next;
|
|
push(&mut p, cid, l);
|
|
let (q, nc, _, json) = roundtrip(&p, cid, &ExportOptions::default());
|
|
assert_eq!(json["layers"][0]["ty"], json!(5));
|
|
assert_eq!(json["layers"][0]["t"]["d"]["k"][0]["s"]["t"], json!("Hi\rthere"));
|
|
assert_eq!(json["fonts"]["list"][0]["fFamily"], json!("Inter"));
|
|
assert!(compare_comps(&p, cid, &q, nc) > 10);
|
|
compare_renders(&p, cid, &q, nc, 2.0 / 255.0);
|
|
// As glyph shapes: the static look at time 0 survives (animators baked out).
|
|
p.comp_mut(cid).unwrap().layers[0].props.group_mut("text/animators").unwrap().children.clear();
|
|
let opts = ExportOptions { text_as_shapes: true, ..Default::default() };
|
|
let res = export_comp(&p, cid, &opts, &no_read).unwrap();
|
|
assert_eq!(res.json["layers"][0]["ty"], json!(4));
|
|
let mut q2 = Project::default();
|
|
let imp = import(&mut q2, res.to_string_compact().as_bytes(), "x", "", &mut |_| None).unwrap();
|
|
let (a, b) = (render_frame(&p, cid, s(0.5), 1.0), render_frame(&q2, imp.comp, s(0.5), 1.0));
|
|
let (ca, cb) = (a.data.iter().map(|px| px[3] as f64).sum::<f64>(), b.data.iter().map(|px| px[3] as f64).sum::<f64>());
|
|
assert!(ca > 50.0 && (ca - cb).abs() / ca < 0.05, "glyph coverage {ca} vs {cb}");
|
|
}
|
|
|
|
#[test]
|
|
fn effects_expressions_and_warnings() {
|
|
let (mut p, cid) = setup();
|
|
let mut l = solid_layer(&mut p, cid, [1.0, 1.0, 1.0], 80, 60);
|
|
let mut next = p.next_id;
|
|
{
|
|
let mut ids = Ids(&mut next);
|
|
let fx = l.props.sub_mut("effects").unwrap();
|
|
for id in ["ec.blur.gaussian", "ec.color.tint", "ec.perspective.dropshadow", "ec.control.slider", "ec.distort.twirl"] {
|
|
if let Some(spec) = effectcraft_effects::find(id) {
|
|
let mut g = effectcraft_effects::instantiate(spec, &mut ids, spec.name, [80.0, 60.0]);
|
|
if let Some(b) = g.get_mut("blurriness") {
|
|
b.keys = vec![Keyframe::new(s(0.0), Value::Scalar(0.0)), Keyframe::new(s(1.0), Value::Scalar(8.0))];
|
|
}
|
|
if let Some(o) = g.get_mut("opacity") {
|
|
o.value = Value::Scalar(40.0);
|
|
}
|
|
if let Some(a) = g.get_mut("amount") {
|
|
a.value = Value::Scalar(60.0);
|
|
}
|
|
if let Some(sl) = g.get_mut("slider") {
|
|
sl.value = Value::Scalar(12.5);
|
|
}
|
|
fx.children.push(Node::Group(g));
|
|
}
|
|
}
|
|
}
|
|
p.next_id = next;
|
|
l.props.prop_mut("transform/rotation").unwrap().expr = Some(Expression { text: "time * 90".into(), enabled: true });
|
|
push(&mut p, cid, l);
|
|
let c = comp(&p, cid);
|
|
let cam = build::layer(&mut p, &c, "Camera 1", LayerSource::Camera, (c.width, c.height), None);
|
|
push(&mut p, cid, cam);
|
|
// Without the expression flag: warned and dropped.
|
|
let res = export_comp(&p, cid, &ExportOptions::default(), &no_read).unwrap();
|
|
assert!(res.json["layers"][0]["ks"]["r"].get("x").is_none());
|
|
let w = res.warnings.join("\n");
|
|
assert!(w.contains("expression not exported"), "{w}");
|
|
assert!(w.contains("effect not supported by Lottie"), "{w}");
|
|
assert!(w.contains("cameras and lights"), "{w}");
|
|
// With it: `x` strings, and supported effects survive.
|
|
let opts = ExportOptions { include_expressions: true, ..Default::default() };
|
|
let res = export_comp(&p, cid, &opts, &no_read).unwrap();
|
|
assert_eq!(res.json["layers"][0]["ks"]["r"]["x"], json!("time * 90"));
|
|
let ef = res.json["layers"][0]["ef"].as_array().unwrap();
|
|
assert_eq!(ef.iter().map(|e| e["ty"].as_u64().unwrap()).collect::<Vec<_>>(), vec![29, 20, 25, 5]);
|
|
assert!((ef[2]["ef"][1]["v"]["k"].as_f64().unwrap() - 40.0 * 2.55).abs() < 1e-9);
|
|
let mut q = Project::default();
|
|
let imp = import(&mut q, res.to_string_compact().as_bytes(), "x", "", &mut |_| None).unwrap();
|
|
let lq = &q.comp(imp.comp).unwrap().layers[0];
|
|
let fxq: Vec<&PropGroup> = lq.effects().unwrap().groups().collect();
|
|
assert_eq!(fxq.len(), 4);
|
|
assert!((fxq[2].get("opacity").unwrap().value.as_f64() - 40.0).abs() < 1e-9);
|
|
assert_eq!(fxq[3].get("slider").unwrap().value, Value::Scalar(12.5));
|
|
assert_eq!(lq.props.prop("transform/rotation").unwrap().expr.as_ref().unwrap().text, "time * 90");
|
|
let lp = &p.comp(cid).unwrap().layers[0];
|
|
let times = sample_times(p.comp(cid).unwrap());
|
|
compare_tree(lp.effects().unwrap().groups().next().unwrap(), fxq[0], ×, "blur");
|
|
}
|
|
|
|
#[test]
|
|
fn images_embed_as_base64_and_come_back() {
|
|
let (mut p, cid) = setup();
|
|
let png: Vec<u8> = vec![0x89, b'P', b'N', b'G', 1, 2, 3, 4, 5];
|
|
let fid = p.add_item(
|
|
"pic.png",
|
|
Label::Lavender,
|
|
None,
|
|
ItemKind::Footage(Footage {
|
|
path: "/virtual/pic.png".into(),
|
|
kind: FootageKind::Still,
|
|
width: 64,
|
|
height: 32,
|
|
pixel_aspect: 1.0,
|
|
frame_rate: FrameRate::FPS_30,
|
|
native_rate: None,
|
|
duration: Tick::ZERO,
|
|
has_video: true,
|
|
has_audio: false,
|
|
alpha: AlphaMode::Straight,
|
|
premul_color: [0.0; 3],
|
|
loop_count: 1,
|
|
codec: String::new(),
|
|
missing: false,
|
|
sequence: vec![],
|
|
color_profile: None,
|
|
..Default::default()
|
|
}),
|
|
);
|
|
let c = comp(&p, cid);
|
|
let l = build::layer(&mut p, &c, "pic.png", LayerSource::Footage { item: fid }, (64, 32), None);
|
|
push(&mut p, cid, l);
|
|
let bytes = png.clone();
|
|
let read = move |path: &str| (path == "/virtual/pic.png").then(|| bytes.clone());
|
|
let res = export_comp(&p, cid, &ExportOptions::default(), &read).unwrap();
|
|
let asset = &res.json["assets"][0];
|
|
assert!(asset["p"].as_str().unwrap().starts_with("data:image/png;base64,"));
|
|
assert_eq!(res.json["layers"][0]["ty"], json!(2));
|
|
let mut q = Project::default();
|
|
let mut stored = vec![];
|
|
let imp = import(&mut q, res.to_string_compact().as_bytes(), "x", "", &mut |img| {
|
|
stored.push(img.bytes.clone());
|
|
Some(format!("/tmp/{}.{}", img.id, img.ext))
|
|
})
|
|
.unwrap();
|
|
assert_eq!(stored, vec![png]);
|
|
let l = &q.comp(imp.comp).unwrap().layers[0];
|
|
let LayerSource::Footage { item } = l.source else { panic!("not footage") };
|
|
let ItemKind::Footage(f) = &q.item(item).unwrap().kind else { panic!() };
|
|
assert_eq!((f.width, f.height), (64, 32));
|
|
assert!(f.path.starts_with("/tmp/image_") && f.path.ends_with(".png"), "{}", f.path);
|
|
}
|
|
|
|
#[test]
|
|
fn dotlottie_archive_roundtrip() {
|
|
let (mut p, cid) = setup();
|
|
let l = solid_layer(&mut p, cid, [1.0, 0.0, 0.0], 50, 50);
|
|
push(&mut p, cid, l);
|
|
let res = export_comp(&p, cid, &ExportOptions::default(), &no_read).unwrap();
|
|
let zip = crate::to_dotlottie(&res, "Main");
|
|
assert!(zip.starts_with(b"PK"));
|
|
let mut q = Project::default();
|
|
let imp = import(&mut q, &zip, "x", "", &mut |_| None).unwrap();
|
|
assert_eq!(q.comp(imp.comp).unwrap().layers.len(), 1);
|
|
}
|
|
|
|
// ---------------------------------------------------------------- hand-written fixtures
|
|
|
|
fn fixture(name: &str) -> (Project, ItemId, Vec<String>) {
|
|
let path = format!("{}/tests/fixtures/{name}", env!("CARGO_MANIFEST_DIR"));
|
|
let bytes = std::fs::read(&path).unwrap();
|
|
let mut q = Project::default();
|
|
let imp = import(&mut q, &bytes, name, "", &mut |_| None).unwrap_or_else(|e| panic!("{name}: {e}"));
|
|
(q, imp.comp, imp.warnings)
|
|
}
|
|
|
|
#[test]
|
|
fn fixture_bouncing_ball() {
|
|
let (q, c, w) = fixture("bouncing-ball.json");
|
|
assert!(w.is_empty(), "{w:?}");
|
|
let comp = q.comp(c).unwrap();
|
|
assert_eq!((comp.width, comp.height, comp.frame_rate), (200, 200, FrameRate::FPS_30));
|
|
let l = &comp.layers[0];
|
|
let pos = l.props.prop("transform/position").unwrap();
|
|
assert_eq!(pos.keys.len(), 3);
|
|
// Eased: slow at the top, the key values are hit exactly.
|
|
assert_eq!(pos.value_at(s(0.5)).as_vec3()[1], 160.0);
|
|
assert!(not_blank(&q, c, s(0.25)));
|
|
}
|
|
|
|
#[test]
|
|
fn fixture_shapes_and_gradients() {
|
|
let (q, c, w) = fixture("shapes-gradient.json");
|
|
assert!(w.is_empty(), "{w:?}");
|
|
let l = &q.comp(c).unwrap().layers[0];
|
|
let contents = l.props.sub("contents").unwrap();
|
|
let g = contents.groups().next().unwrap();
|
|
let kinds: Vec<&str> = g.sub("contents").unwrap().groups().map(|x| x.match_id.as_str()).collect();
|
|
assert_eq!(kinds, ["star", "rect", "gfill", "stroke", "trim"]);
|
|
assert!(not_blank(&q, c, s(0.5)));
|
|
}
|
|
|
|
#[test]
|
|
fn fixture_precomp_matte_text() {
|
|
let (q, c, w) = fixture("precomp-matte-text.json");
|
|
assert!(w.is_empty(), "{w:?}");
|
|
let comp = q.comp(c).unwrap();
|
|
assert_eq!(comp.layers.len(), 4);
|
|
let matte = &comp.layers[0];
|
|
assert!(!matte.switches.video);
|
|
assert_eq!(comp.layers[1].track_matte.map(|m| (m.layer, m.kind)), Some((matte.id, MatteKind::Alpha)));
|
|
assert!(matches!(comp.layers[1].source, LayerSource::Comp { .. }));
|
|
assert_eq!(comp.layers[2].parent, Some(comp.layers[3].id));
|
|
let Value::Text(doc) = &comp.layers[2].props.prop("text/sourceText").unwrap().value else { panic!() };
|
|
assert_eq!(doc.text, "Hello\nLottie");
|
|
assert_eq!(doc.justify, effectcraft_keyframe::Justify::Center);
|
|
assert!(not_blank(&q, c, s(0.5)));
|
|
}
|
|
|
|
#[test]
|
|
fn fixture_legacy_keyframes_and_masks() {
|
|
let (q, c, w) = fixture("legacy-masks.json");
|
|
assert!(w.iter().all(|w| w.contains("not supported")), "{w:?}");
|
|
let comp = q.comp(c).unwrap();
|
|
let l = &comp.layers[0];
|
|
// Legacy `e` end values fill the last key.
|
|
let o = l.props.prop("transform/opacity").unwrap();
|
|
assert_eq!(o.keys.len(), 2);
|
|
assert_eq!(o.keys[1].value, Value::Scalar(100.0));
|
|
let masks: Vec<_> = l.masks().unwrap().groups().collect();
|
|
assert_eq!(masks.len(), 2);
|
|
assert!(matches!(masks[1].kind, GroupKind::Mask { mode: MaskMode::Subtract, inverted: true, .. }));
|
|
assert!(not_blank(&q, c, s(0.9)));
|
|
}
|
|
|
|
#[test]
|
|
fn text_style_runs_export_as_range_animators() {
|
|
let (mut p, cid) = setup();
|
|
let c = comp(&p, cid);
|
|
let mut l = build::layer(&mut p, &c, "Runs", LayerSource::Text, (c.width, c.height), None);
|
|
let mut doc = TextDoc { text: "Hello".into(), size: 30.0, fill: [1.0, 1.0, 1.0, 1.0], ..Default::default() };
|
|
doc.apply_style(1..3, |st| {
|
|
st.fill = [1.0, 0.0, 0.0, 1.0];
|
|
st.baseline_shift = 6.0;
|
|
});
|
|
l.props.prop_mut("text/sourceText").unwrap().value = Value::Text(Box::new(doc));
|
|
l.props.prop_mut("transform/position").unwrap().value = Value::Vec3([30.0, 60.0, 0.0]);
|
|
push(&mut p, cid, l);
|
|
let (q, nc, _, json) = roundtrip(&p, cid, &ExportOptions::default());
|
|
let a = &json["layers"][0]["t"]["a"][0];
|
|
assert_eq!(a["s"]["r"], json!(2), "{a}");
|
|
assert_eq!(a["s"]["s"]["k"], json!(1));
|
|
assert_eq!(a["s"]["e"]["k"], json!(3));
|
|
assert_eq!(a["a"]["fc"]["k"], json!([1.0, 0.0, 0.0, 1.0]));
|
|
assert_eq!(a["a"]["p"]["k"], json!([0.0, -6.0, 0.0]));
|
|
// Played back as an animator, it looks the same.
|
|
compare_renders(&p, cid, &q, nc, 2.0 / 255.0);
|
|
}
|
|
|
|
#[test]
|
|
fn extreme_enum_numbers_do_not_overflow() {
|
|
// Line cap / join / merge mode / text-selector enums are 1-based; a huge negative number
|
|
// (-1e308 rounds to i64::MIN) overflowed the `- 1`.
|
|
let path = format!("{}/tests/fixtures/shapes-gradient.json", env!("CARGO_MANIFEST_DIR"));
|
|
let mut doc: Json = serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap();
|
|
let mut hits = 0;
|
|
fn patch(j: &mut Json, hits: &mut usize) {
|
|
match j {
|
|
Json::Object(o) => {
|
|
for k in ["lc", "lj", "mm"] {
|
|
if o.contains_key(k) {
|
|
o.insert(k.into(), json!(-1e308));
|
|
*hits += 1;
|
|
}
|
|
}
|
|
o.values_mut().for_each(|v| patch(v, hits));
|
|
}
|
|
Json::Array(a) => a.iter_mut().for_each(|v| patch(v, hits)),
|
|
_ => {}
|
|
}
|
|
}
|
|
patch(&mut doc, &mut hits);
|
|
assert!(hits > 0);
|
|
let mut q = Project::default();
|
|
import(&mut q, doc.to_string().as_bytes(), "x", "", &mut |_| None).unwrap();
|
|
}
|