mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-26 02:57:34 -06:00
Shapes (SHP-2): Stroke Taper (pixels/percent start/end length, width, ease) and Wave (amount, pixels/cycles, wavelength, phase) rendered as a variable-width outline; Dash 2/Gap 2/Dash 3/Gap 3 (shape.dashes.add/remove, timeline +/- buttons); radial gradient Highlight Length/Angle as a focal gradient. Masks (MSK-1): mask feather points on the Mask Path (mask.featherPoint.*, Mask Feather tool add/drag), per-point signed radius and tension, rendered from the signed distance to the path; carried through vertex edits. Motion blur (LYR-10): Adaptive Sample Limit comp setting; 2D layers take one sample per pixel of shutter travel between Samples Per Frame and the limit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
419 lines
22 KiB
Rust
419 lines
22 KiB
Rust
use effectcraft_color::Label;
|
||
use effectcraft_keyframe::{Keyframe, ShapePath, TextDoc, Value};
|
||
use effectcraft_project::build::{self, Ids};
|
||
use effectcraft_project::{Comp, ItemId, ItemKind, Layer, LayerSource, MaskMode, Node, Project, PropGroup};
|
||
use effectcraft_text::layout_doc;
|
||
use effectcraft_time::{FrameRate, Tick};
|
||
|
||
use super::*;
|
||
|
||
fn setup(w: u32, h: u32) -> (Project, ItemId, Comp) {
|
||
let mut p = Project::default();
|
||
let comp = Comp::new(w, h, FrameRate::FPS_30, Tick::from_seconds_f64(4.0));
|
||
let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into()));
|
||
(p, cid, comp)
|
||
}
|
||
|
||
fn text_layer(p: &mut Project, comp: &Comp, doc: TextDoc) -> Layer {
|
||
let mut l = build::layer(p, comp, "Text", LayerSource::Text, (comp.width, comp.height), None);
|
||
l.props.prop_mut("text/sourceText").unwrap().value = Value::Text(Box::new(doc));
|
||
l
|
||
}
|
||
|
||
/// Add an animator with `kinds` and selectors (`range`/`wiggly`/`expression`); returns its index.
|
||
fn animator(p: &mut Project, l: &mut Layer, kinds: &[&str], sels: &[&str]) -> usize {
|
||
let mut next = p.next_id;
|
||
let mut ids = Ids(&mut next);
|
||
let props = kinds.iter().flat_map(|k| build::text_anim_props(&mut ids, k, false)).collect();
|
||
let mut g = build::text_animator(&mut ids, "Animator", props);
|
||
let sg = g.sub_mut("selectors").unwrap();
|
||
for (i, k) in sels.iter().enumerate() {
|
||
if i == 0 && *k == "range" {
|
||
continue;
|
||
}
|
||
if i == 0 {
|
||
sg.children.clear();
|
||
}
|
||
sg.children.push(build::text_selector(&mut ids, k, k).unwrap().into());
|
||
}
|
||
p.next_id = next;
|
||
let anims = l.props.group_mut("text/animators").unwrap();
|
||
anims.children.push(g.into());
|
||
anims.children.len()
|
||
}
|
||
|
||
fn set(l: &mut Layer, path: &str, v: Value) {
|
||
l.props.prop_mut(path).unwrap_or_else(|| panic!("no {path}")).value = v;
|
||
}
|
||
|
||
fn ctx<'a>(p: &'a Project, cid: ItemId, t: f64) -> EvalCtx<'a> {
|
||
let comp = p.comp(cid).unwrap();
|
||
EvalCtx::new(p, cid, comp, Tick::from_seconds_f64(t))
|
||
}
|
||
|
||
fn selection(p: &Project, cid: ItemId, t: f64, anim: usize) -> Vec<f64> {
|
||
let c = ctx(p, cid, t);
|
||
let l = &p.comp(cid).unwrap().layers[0];
|
||
let doc = source_text(&c, l).unwrap();
|
||
let lay = layout_doc(&doc);
|
||
let a = l.props.group(&format!("text/animators/#{anim}")).unwrap();
|
||
animator_selection(&c, l, a, &lay).into_iter().map(|v| (v[0] * 1000.0).round() / 1000.0).collect()
|
||
}
|
||
|
||
fn doc(text: &str, size: f64) -> TextDoc {
|
||
TextDoc { text: text.into(), size, ..Default::default() }
|
||
}
|
||
|
||
#[test]
|
||
fn range_based_on_words_and_lines() {
|
||
let (mut p, cid, comp) = setup(400, 200);
|
||
let mut l = text_layer(&mut p, &comp, doc("AB CD\nEF", 30.0));
|
||
let a = animator(&mut p, &mut l, &["opacity"], &["range"]);
|
||
set(&mut l, "text/animators/#1/selectors/#1/advanced/basedOn", Value::Enum(2));
|
||
set(&mut l, "text/animators/#1/selectors/#1/advanced/smoothness", Value::Scalar(0.0));
|
||
set(&mut l, "text/animators/#1/selectors/#1/end", Value::Scalar(34.0));
|
||
p.comp_mut(cid).unwrap().layers.push(l);
|
||
// Glyphs: A B ␠ C D E F (the newline has no glyph). Word 1 only.
|
||
let s = selection(&p, cid, 0.0, a);
|
||
assert_eq!(s, vec![1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0], "{s:?}");
|
||
// Lines: the first half = line 1.
|
||
let l = &mut p.comp_mut(cid).unwrap().layers[0];
|
||
set(l, "text/animators/#1/selectors/#1/advanced/basedOn", Value::Enum(3));
|
||
set(l, "text/animators/#1/selectors/#1/end", Value::Scalar(50.0));
|
||
let s = selection(&p, cid, 0.0, a);
|
||
assert_eq!(s, vec![1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0], "{s:?}");
|
||
// Characters excluding spaces: 6 units, first half = A B C.
|
||
let l = &mut p.comp_mut(cid).unwrap().layers[0];
|
||
set(l, "text/animators/#1/selectors/#1/advanced/basedOn", Value::Enum(1));
|
||
let s = selection(&p, cid, 0.0, a);
|
||
assert_eq!(s[0..2], [1.0, 1.0]);
|
||
assert_eq!(s[3], 1.0);
|
||
assert_eq!(s[4..], [0.0, 0.0, 0.0]);
|
||
// Index units: start 1, end 3 characters.
|
||
let l = &mut p.comp_mut(cid).unwrap().layers[0];
|
||
set(l, "text/animators/#1/selectors/#1/advanced/basedOn", Value::Enum(0));
|
||
set(l, "text/animators/#1/selectors/#1/advanced/units", Value::Enum(1));
|
||
set(l, "text/animators/#1/selectors/#1/start", Value::Scalar(1.0));
|
||
set(l, "text/animators/#1/selectors/#1/end", Value::Scalar(3.0));
|
||
let s = selection(&p, cid, 0.0, a);
|
||
assert_eq!(s, vec![0.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0], "{s:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn selector_modes_and_randomize() {
|
||
let (mut p, cid, comp) = setup(400, 200);
|
||
let mut l = text_layer(&mut p, &comp, doc("ABCDEFGH", 30.0));
|
||
let a = animator(&mut p, &mut l, &["opacity"], &["range", "range"]);
|
||
let s1 = "text/animators/#1/selectors/#1";
|
||
let s2 = "text/animators/#1/selectors/#2";
|
||
set(&mut l, &format!("{s1}/end"), Value::Scalar(50.0));
|
||
set(&mut l, &format!("{s2}/end"), Value::Scalar(25.0));
|
||
set(&mut l, &format!("{s2}/advanced/mode"), Value::Enum(1)); // subtract
|
||
p.comp_mut(cid).unwrap().layers.push(l);
|
||
assert_eq!(selection(&p, cid, 0.0, a), vec![0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0]);
|
||
let modes = [
|
||
(2, vec![1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]),
|
||
(4, vec![1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0]),
|
||
(5, vec![0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0]),
|
||
];
|
||
for (m, want) in modes {
|
||
set(&mut p.comp_mut(cid).unwrap().layers[0], &format!("{s2}/advanced/mode"), Value::Enum(m));
|
||
assert_eq!(selection(&p, cid, 0.0, a), want, "mode {m}");
|
||
}
|
||
// A lone Subtract selector inverts.
|
||
let l = &mut p.comp_mut(cid).unwrap().layers[0];
|
||
if let Some(Node::Group(g)) = l.props.group_mut("text/animators/#1/selectors").map(|g| &mut g.children[1]) {
|
||
g.enabled = false;
|
||
}
|
||
set(l, &format!("{s1}/advanced/mode"), Value::Enum(1));
|
||
assert_eq!(selection(&p, cid, 0.0, a), vec![0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0]);
|
||
// Randomize Order: still half selected, but not the first half; seed changes it.
|
||
let l = &mut p.comp_mut(cid).unwrap().layers[0];
|
||
set(l, &format!("{s1}/advanced/mode"), Value::Enum(0));
|
||
set(l, &format!("{s1}/advanced/randomize"), Value::Bool(true));
|
||
let r1 = selection(&p, cid, 0.0, a);
|
||
assert_eq!(r1.iter().sum::<f64>(), 4.0);
|
||
assert_ne!(r1, vec![1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0]);
|
||
assert_eq!(r1, selection(&p, cid, 0.0, a));
|
||
set(&mut p.comp_mut(cid).unwrap().layers[0], &format!("{s1}/advanced/randomSeed"), Value::Scalar(5.0));
|
||
assert_ne!(selection(&p, cid, 0.0, a), r1);
|
||
}
|
||
|
||
#[test]
|
||
fn wiggly_selector_is_deterministic_and_time_dependent() {
|
||
let (mut p, cid, comp) = setup(400, 200);
|
||
let mut l = text_layer(&mut p, &comp, doc("WIGGLE", 40.0));
|
||
animator(&mut p, &mut l, &["position"], &["range", "wiggly"]);
|
||
set(&mut l, "text/animators/#1/properties/position", Value::Vec3([0.0, 50.0, 0.0]));
|
||
p.comp_mut(cid).unwrap().layers.push(l);
|
||
let l = &p.comp(cid).unwrap().layers[0];
|
||
let a = glyph_paths(&ctx(&p, cid, 0.5), l);
|
||
let b = glyph_paths(&ctx(&p, cid, 0.5), l);
|
||
let c = glyph_paths(&ctx(&p, cid, 1.3), l);
|
||
let ys = |v: &[(BezPath, CharXf)]| v.iter().map(|g| g.1.offset[1]).collect::<Vec<_>>();
|
||
assert_eq!(ys(&a), ys(&b));
|
||
assert_ne!(ys(&a), ys(&c));
|
||
// Within ±50 and not all identical across characters (correlation 50%).
|
||
assert!(ys(&a).iter().all(|y| y.abs() <= 50.0 + 1e-9));
|
||
assert!(ys(&a).windows(2).any(|w| (w[0] - w[1]).abs() > 1e-6));
|
||
// The layer cache keys a wiggly text layer by time.
|
||
let k1 = crate::cache::layer_key(&ctx(&p, cid, 0.5), l, 1.0, false);
|
||
let k2 = crate::cache::layer_key(&ctx(&p, cid, 1.3), l, 1.0, false);
|
||
assert_ne!(k1, k2);
|
||
// Renders identically twice.
|
||
let r1 = crate::render_frame(&p, cid, Tick::from_seconds_f64(0.5), 0.5);
|
||
let r2 = crate::render_frame(&p, cid, Tick::from_seconds_f64(0.5), 0.5);
|
||
assert_eq!(r1.data, r2.data);
|
||
}
|
||
|
||
fn bounds_of(paths: &[(BezPath, CharXf)]) -> kurbo::Rect {
|
||
let v: Vec<BezPath> = paths.iter().map(|p| p.0.clone()).collect();
|
||
effectcraft_path::bounds(&v).unwrap()
|
||
}
|
||
|
||
#[test]
|
||
fn tracking_line_anchor_and_character_offset() {
|
||
let (mut p, cid, comp) = setup(600, 200);
|
||
let mut l = text_layer(&mut p, &comp, doc("ABCD", 40.0));
|
||
animator(&mut p, &mut l, &["tracking", "lineAnchor"], &["range"]);
|
||
p.comp_mut(cid).unwrap().layers.push(l);
|
||
let w0 = bounds_of(&glyph_paths(&ctx(&p, cid, 0.0), &p.comp(cid).unwrap().layers[0]));
|
||
set(&mut p.comp_mut(cid).unwrap().layers[0], "text/animators/#1/properties/tracking", Value::Scalar(500.0));
|
||
let w1 = bounds_of(&glyph_paths(&ctx(&p, cid, 0.0), &p.comp(cid).unwrap().layers[0]));
|
||
// 4 chars × 500/1000 em × 40 px: the last glyph moves ~ 3.5 × 20 px further (before & after).
|
||
assert!((w1.width() - w0.width() - 3.0 * 20.0).abs() < 1.5, "{} {}", w0.width(), w1.width());
|
||
assert!((w1.x0 - w0.x0 - 10.0).abs() < 1.0, "first char shifts by half its tracking: {} {}", w0.x0, w1.x0);
|
||
// Line Anchor 100%: tracking grows to the left.
|
||
set(&mut p.comp_mut(cid).unwrap().layers[0], "text/animators/#1/properties/lineAnchor", Value::Scalar(100.0));
|
||
let w2 = bounds_of(&glyph_paths(&ctx(&p, cid, 0.0), &p.comp(cid).unwrap().layers[0]));
|
||
assert!((w2.x1 - w0.x1 + 10.0).abs() < 1.0, "{} {}", w0.x1, w2.x1);
|
||
|
||
// Character Offset 1: "ABCD" draws as "BCDE".
|
||
let (mut p, cid, comp) = setup(600, 200);
|
||
let mut l = text_layer(&mut p, &comp, doc("AZ9", 40.0));
|
||
animator(&mut p, &mut l, &["characterOffset"], &["range"]);
|
||
set(&mut l, "text/animators/#1/properties/characterOffset", Value::Scalar(1.0));
|
||
set(&mut l, "text/animators/#1/properties/characterAlignment", Value::Enum(3));
|
||
p.comp_mut(cid).unwrap().layers.push(l);
|
||
let got = bounds_of(&glyph_paths(&ctx(&p, cid, 0.0), &p.comp(cid).unwrap().layers[0]));
|
||
let (mut q, qid, qc) = setup(600, 200);
|
||
let l2 = text_layer(&mut q, &qc, doc("BA0", 40.0));
|
||
q.comp_mut(qid).unwrap().layers.push(l2);
|
||
let want = bounds_of(&glyph_paths(&ctx(&q, qid, 0.0), &q.comp(qid).unwrap().layers[0]));
|
||
assert!((got.width() - want.width()).abs() < 0.01 && (got.height() - want.height()).abs() < 0.01, "{got:?} {want:?}");
|
||
assert_eq!(substitute('z', &CharXf { char_offset: 2.0, ..Default::default() }), Some('b'));
|
||
assert_eq!(substitute('-', &CharXf { char_offset: 2.0, ..Default::default() }), None);
|
||
assert_eq!(substitute('-', &CharXf { char_offset: 2.0, char_range: 1, ..Default::default() }), Some('/'));
|
||
assert_eq!(substitute('a', &CharXf { char_value: Some((66.0, 1.0)), ..Default::default() }), Some('B'));
|
||
}
|
||
|
||
#[test]
|
||
fn anchor_grouping_line_rotates_around_line_centre() {
|
||
let (mut p, cid, comp) = setup(600, 300);
|
||
let mut l = text_layer(&mut p, &comp, doc("HHHH", 40.0));
|
||
animator(&mut p, &mut l, &["rotation"], &["range"]);
|
||
set(&mut l, "text/animators/#1/properties/rotation", Value::Scalar(90.0));
|
||
p.comp_mut(cid).unwrap().layers.push(l);
|
||
let per_char = glyph_paths(&ctx(&p, cid, 0.0), &p.comp(cid).unwrap().layers[0]);
|
||
let cx: Vec<f64> = per_char.iter().map(|g| effectcraft_path::bounds(std::slice::from_ref(&g.0)).unwrap().center().x).collect();
|
||
assert!(cx.windows(2).all(|w| w[1] > w[0] + 10.0), "characters rotate in place: {cx:?}");
|
||
set(&mut p.comp_mut(cid).unwrap().layers[0], "text/moreOptions/anchorGrouping", Value::Enum(2));
|
||
let line = glyph_paths(&ctx(&p, cid, 0.0), &p.comp(cid).unwrap().layers[0]);
|
||
let cx: Vec<f64> = line.iter().map(|g| effectcraft_path::bounds(std::slice::from_ref(&g.0)).unwrap().center().x).collect();
|
||
assert!(cx.windows(2).all(|w| (w[1] - w[0]).abs() < 1.0), "the line turns as one: {cx:?}");
|
||
let cy: Vec<f64> = line.iter().map(|g| effectcraft_path::bounds(std::slice::from_ref(&g.0)).unwrap().center().y).collect();
|
||
assert!(cy.windows(2).all(|w| w[1] > w[0] + 10.0), "{cy:?}");
|
||
}
|
||
|
||
fn mask(p: &mut Project, l: &mut Layer, pts: &[[f64; 2]], closed: bool) {
|
||
let mut next = p.next_id;
|
||
let mut ids = Ids(&mut next);
|
||
let sp = ShapePath { vertices: pts.to_vec(), in_tangents: vec![[0.0; 2]; pts.len()], out_tangents: vec![[0.0; 2]; pts.len()], closed, feather: Vec::new() };
|
||
let g = build::mask(&mut ids, "Mask 1", sp, MaskMode::None, [255, 255, 0]);
|
||
p.next_id = next;
|
||
let masks = l.props.group_mut("masks").unwrap();
|
||
masks.children.push(g.into());
|
||
}
|
||
|
||
#[test]
|
||
fn path_text_follows_a_mask() {
|
||
let (mut p, cid, comp) = setup(400, 400);
|
||
let mut l = text_layer(&mut p, &comp, doc("PATH", 30.0));
|
||
// A vertical path going down at x = 100.
|
||
mask(&mut p, &mut l, &[[100.0, 20.0], [100.0, 380.0]], false);
|
||
set(&mut l, "text/pathOptions/path", Value::Enum(1));
|
||
set(&mut l, "text/pathOptions/firstMargin", Value::Scalar(40.0));
|
||
p.comp_mut(cid).unwrap().layers.push(l);
|
||
let g = glyph_paths(&ctx(&p, cid, 0.0), &p.comp(cid).unwrap().layers[0]);
|
||
assert_eq!(g.len(), 4);
|
||
let bs: Vec<kurbo::Rect> = g.iter().map(|x| effectcraft_path::bounds(std::slice::from_ref(&x.0)).unwrap()).collect();
|
||
// Perpendicular: glyphs stand on the path, rotated 90° (to the right of x = 100), in order down.
|
||
for b in &bs {
|
||
assert!(b.x0 > 99.0 && b.x1 < 100.0 + 30.0, "{b:?}");
|
||
assert!(b.height() < 30.0);
|
||
}
|
||
assert!(bs.windows(2).all(|w| w[1].center().y > w[0].center().y));
|
||
assert!((bs[0].y0 - 60.0).abs() < 4.0, "first margin 40 from the start at y = 20: {:?}", bs[0]);
|
||
// Reverse Path: starts from the bottom.
|
||
set(&mut p.comp_mut(cid).unwrap().layers[0], "text/pathOptions/reversePath", Value::Bool(true));
|
||
let g = glyph_paths(&ctx(&p, cid, 0.0), &p.comp(cid).unwrap().layers[0]);
|
||
let b0 = effectcraft_path::bounds(std::slice::from_ref(&g[0].0)).unwrap();
|
||
assert!(b0.center().y > 300.0 && b0.x1 < 101.0, "{b0:?}");
|
||
// Not perpendicular: glyphs stay upright.
|
||
set(&mut p.comp_mut(cid).unwrap().layers[0], "text/pathOptions/perpendicular", Value::Bool(false));
|
||
let g = glyph_paths(&ctx(&p, cid, 0.0), &p.comp(cid).unwrap().layers[0]);
|
||
let b0 = effectcraft_path::bounds(std::slice::from_ref(&g[0].0)).unwrap();
|
||
assert!(b0.height() > b0.width(), "{b0:?}");
|
||
// Force alignment spreads to the margins.
|
||
let l = &mut p.comp_mut(cid).unwrap().layers[0];
|
||
set(l, "text/pathOptions/reversePath", Value::Bool(false));
|
||
set(l, "text/pathOptions/forceAlignment", Value::Bool(true));
|
||
set(l, "text/pathOptions/firstMargin", Value::Scalar(0.0));
|
||
let g = glyph_paths(&ctx(&p, cid, 0.0), &p.comp(cid).unwrap().layers[0]);
|
||
let first = effectcraft_path::bounds(std::slice::from_ref(&g[0].0)).unwrap().center().y;
|
||
let last = effectcraft_path::bounds(std::slice::from_ref(&g[3].0)).unwrap().center().y;
|
||
assert!(first < 40.0 && last > 360.0, "{first} {last}");
|
||
}
|
||
|
||
fn alpha_bbox(img: &crate::Image) -> Option<[i64; 4]> {
|
||
let mut b: Option<[i64; 4]> = None;
|
||
for y in 0..img.height as i64 {
|
||
for x in 0..img.width as i64 {
|
||
if img.get(x, y)[3] > 0.2 {
|
||
b = Some(match b {
|
||
None => [x, y, x, y],
|
||
Some(r) => [r[0].min(x), r[1].min(y), r[2].max(x), r[3].max(y)],
|
||
});
|
||
}
|
||
}
|
||
}
|
||
b
|
||
}
|
||
|
||
#[test]
|
||
fn render_key_pixels_fill_hue_blur_and_fill_stroke_order() {
|
||
let (mut p, cid, comp) = setup(200, 100);
|
||
let mut l = text_layer(&mut p, &comp, TextDoc { text: "I".into(), size: 90.0, fill: [1.0, 0.0, 0.0, 1.0], style: "Bold".into(), ..Default::default() });
|
||
set(&mut l, "transform/position", Value::Vec3([90.0, 80.0, 0.0]));
|
||
animator(&mut p, &mut l, &["fillHue"], &["range"]);
|
||
set(&mut l, "text/animators/#1/properties/fillHue", Value::Scalar(120.0));
|
||
p.comp_mut(cid).unwrap().layers.push(l);
|
||
let img = crate::render_frame(&p, cid, Tick::ZERO, 1.0);
|
||
let b = alpha_bbox(&img).unwrap();
|
||
let c = img.get((b[0] + b[2]) / 2, (b[1] + b[3]) / 2);
|
||
assert!(c[1] > 0.9 && c[0] < 0.1, "hue +120° turns red green: {c:?}");
|
||
// Fill Opacity 0 hides the fill.
|
||
let mut l = p.comp(cid).unwrap().layers[0].clone();
|
||
animator(&mut p, &mut l, &["fillOpacity"], &["range"]);
|
||
set(&mut l, "text/animators/#2/properties/fillOpacity", Value::Scalar(0.0));
|
||
p.comp_mut(cid).unwrap().layers[0] = l;
|
||
let img = crate::render_frame(&p, cid, Tick::ZERO, 1.0);
|
||
assert!(alpha_bbox(&img).is_none());
|
||
p.comp_mut(cid).unwrap().layers[0].props.group_mut("text/animators").unwrap().children.pop();
|
||
// Blur spreads the glyph.
|
||
let sharp = alpha_bbox(&crate::render_frame(&p, cid, Tick::ZERO, 1.0)).unwrap();
|
||
let mut l = p.comp(cid).unwrap().layers[0].clone();
|
||
animator(&mut p, &mut l, &["blur"], &["range"]);
|
||
set(&mut l, "text/animators/#2/properties/blur", Value::Vec2([20.0, 0.0]));
|
||
p.comp_mut(cid).unwrap().layers[0] = l;
|
||
let img = crate::render_frame(&p, cid, Tick::ZERO, 1.0);
|
||
let soft = img.get(sharp[0] - 4, (sharp[1] + sharp[3]) / 2);
|
||
assert!(soft[3] > 0.05, "blur reaches outside the sharp edge: {soft:?}");
|
||
let core = img.get((sharp[0] + sharp[2]) / 2, (sharp[1] + sharp[3]) / 2);
|
||
assert!(core[3] < 0.99, "and softens the core: {core:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn fill_stroke_modes_order_layers() {
|
||
// Two overlapping characters ("AA" with negative tracking), stroke on.
|
||
let (mut p, cid, comp) = setup(300, 150);
|
||
let d = TextDoc {
|
||
text: "OO".into(),
|
||
size: 100.0,
|
||
tracking: -400.0,
|
||
fill: [1.0, 0.0, 0.0, 1.0],
|
||
stroke: [0.0, 0.0, 1.0, 1.0],
|
||
apply_stroke: true,
|
||
stroke_width: 6.0,
|
||
stroke_over_fill: false,
|
||
..Default::default()
|
||
};
|
||
let mut l = text_layer(&mut p, &comp, d);
|
||
set(&mut l, "transform/position", Value::Vec3([60.0, 110.0, 0.0]));
|
||
p.comp_mut(cid).unwrap().layers.push(l);
|
||
let c = ctx(&p, cid, 0.0);
|
||
let geom = text_geom(&c, &p.comp(cid).unwrap().layers[0]).unwrap();
|
||
assert_eq!(geom.glyphs.len(), 2);
|
||
let order = |fs: u32| {
|
||
let mut g = text_geom(&c, &p.comp(cid).unwrap().layers[0]).unwrap();
|
||
g.fill_stroke = fs;
|
||
passes(&g)
|
||
};
|
||
assert_eq!(order(1), vec![(0, true), (1, true), (0, false), (1, false)]);
|
||
assert_eq!(order(2), vec![(0, false), (1, false), (0, true), (1, true)]);
|
||
assert_eq!(order(0), vec![(0, true), (0, false), (1, true), (1, false)]);
|
||
// Inter-character blending: Multiply darkens where the characters overlap.
|
||
let normal = crate::render_frame(&p, cid, Tick::ZERO, 1.0);
|
||
set(&mut p.comp_mut(cid).unwrap().layers[0], "text/moreOptions/interCharBlend", Value::Enum(2));
|
||
let multiply = crate::render_frame(&p, cid, Tick::ZERO, 1.0);
|
||
assert_ne!(normal.data, multiply.data);
|
||
}
|
||
|
||
#[test]
|
||
fn per_character_3d_projects_characters() {
|
||
let (mut p, cid, comp) = setup(400, 200);
|
||
let mut l = text_layer(&mut p, &comp, TextDoc { text: "MM".into(), size: 80.0, justify: effectcraft_keyframe::Justify::Center, ..Default::default() });
|
||
set(&mut l, "transform/position", Value::Vec3([200.0, 130.0, 0.0]));
|
||
l.switches.three_d = true;
|
||
animator(&mut p, &mut l, &["position", "rotation"], &["range"]);
|
||
p.comp_mut(cid).unwrap().layers.push(l);
|
||
let flat = alpha_bbox(&crate::render_frame(&p, cid, Tick::ZERO, 1.0)).unwrap();
|
||
// Per-character 3D off: Z position is ignored.
|
||
set(&mut p.comp_mut(cid).unwrap().layers[0], "text/animators/#1/properties/position", Value::Vec3([0.0, 0.0, -300.0]));
|
||
let off = alpha_bbox(&crate::render_frame(&p, cid, Tick::ZERO, 1.0)).unwrap();
|
||
assert_eq!(flat, off);
|
||
// On: characters come towards the camera and get bigger.
|
||
let l = &mut p.comp_mut(cid).unwrap().layers[0];
|
||
let t = l.props.sub_mut("text").unwrap();
|
||
t.get_mut("perChar3d").unwrap().value = Value::Bool(true);
|
||
let near = alpha_bbox(&crate::render_frame(&p, cid, Tick::ZERO, 1.0)).unwrap();
|
||
assert!(near[2] - near[0] > (flat[2] - flat[0]) + 20, "{flat:?} {near:?}");
|
||
assert_eq!(per_char_planes(&ctx(&p, cid, 0.0), &p.comp(cid).unwrap().layers[0], 1.0).len(), 2);
|
||
// Y rotation 90° turns every character edge-on: nothing visible.
|
||
let l = &mut p.comp_mut(cid).unwrap().layers[0];
|
||
set(l, "text/animators/#1/properties/position", Value::Vec3([0.0; 3]));
|
||
let mut next = p.next_id;
|
||
let ry = build::text_anim_props(&mut Ids(&mut next), "rotation", true).remove(1);
|
||
p.next_id = next;
|
||
let props: &mut PropGroup = p.comp_mut(cid).unwrap().layers[0].props.group_mut("text/animators/#1/properties").unwrap();
|
||
props.children.push(ry.into());
|
||
props.get_mut("rotationY").unwrap().value = Value::Scalar(90.0);
|
||
let img = crate::render_frame(&p, cid, Tick::ZERO, 1.0);
|
||
let covered = |img: &crate::Image| img.data.iter().filter(|p| p[3] > 0.2).count();
|
||
let vis = covered(&img);
|
||
let flat_px = {
|
||
let mut q = p.clone();
|
||
q.comp_mut(cid).unwrap().layers[0].props.group_mut("text/animators/#1/properties").unwrap().get_mut("rotationY").unwrap().value = Value::Scalar(0.0);
|
||
covered(&crate::render_frame(&q, cid, Tick::ZERO, 1.0))
|
||
};
|
||
assert!(vis * 10 < flat_px, "edge-on characters are thin slivers: {vis} of {flat_px}");
|
||
// 45°: narrower than flat but visible.
|
||
let props = p.comp_mut(cid).unwrap().layers[0].props.group_mut("text/animators/#1/properties").unwrap();
|
||
props.get_mut("rotationY").unwrap().value = Value::Scalar(60.0);
|
||
let b = alpha_bbox(&crate::render_frame(&p, cid, Tick::ZERO, 1.0)).unwrap();
|
||
assert!(b[2] - b[0] < flat[2] - flat[0] && b[2] > b[0] + 10, "{b:?} {flat:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn keyframed_selector_animates() {
|
||
let (mut p, cid, comp) = setup(400, 200);
|
||
let mut l = text_layer(&mut p, &comp, doc("ABCD", 40.0));
|
||
animator(&mut p, &mut l, &["opacity"], &["range"]);
|
||
l.props.prop_mut("text/animators/#1/selectors/#1/start").unwrap().keys =
|
||
vec![Keyframe::new(Tick::ZERO, Value::Scalar(0.0)), Keyframe::new(Tick::from_seconds_f64(1.0), Value::Scalar(100.0))];
|
||
p.comp_mut(cid).unwrap().layers.push(l);
|
||
assert_eq!(selection(&p, cid, 0.0, 1), vec![1.0; 4]);
|
||
assert_eq!(selection(&p, cid, 0.5, 1), vec![0.0, 0.0, 1.0, 1.0]);
|
||
assert_eq!(selection(&p, cid, 1.0, 1), vec![0.0; 4]);
|
||
}
|