Files
effectcraft/crates/render/src/tests_motion_tile.rs
T
dedandClaude Opus 5.5 915ba28a9d Motion Tile's Output size works on adjustment layers (#1727)
On an adjustment layer Motion Tile kept the comp-sized buffer whatever the
Output Width / Height, and the adjustment draw dropped any stack result whose
size differed from the canvas. After Effects grows (or crops) the comp
rectangle there as on any layer, so a Transform after it brings the extra
tiles into view, as in the frames attached to #1727 and #1409.

- motion_tile_frame: one output frame for the CPU and GPU effect; on an
  adjustment it grows about the comp rectangle on whole pixels, keeps the
  buffer at 100 % and past 16 384 pixels a side.
- adjustment_crop: the CPU and GPU adjustment draws put a stack result whose
  frame changed back onto the canvas instead of dropping it (Enc::crop on
  the GPU).

Tests: the #1727 After Effects frames (910 x 340 % and 90 x 340 % then
Scale 12 %), tiles past the frame and cropping on CPU, GPU vs CPU for
adjustment and full-frame layers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 10:35:47 +03:00

170 lines
7.8 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Motion Tile's Output Width / Height on adjustment layers and on full-frame layers, with a
//! Transform after it that scales the tiled result into view (#1727, #1991).
use effectcraft_color::Label;
use effectcraft_keyframe::Value;
use effectcraft_project::build::{self, Ids};
use effectcraft_project::{Comp, ItemId, ItemKind, Layer, LayerSource, Project, Solid};
use effectcraft_time::{FrameRate, Tick};
use crate::{Image, render_frame};
const W: u32 = 200;
const H: u32 = 100;
fn solid(p: &mut Project, comp: &Comp, color: [f32; 3], w: u32, h: u32, pos: [f64; 2]) -> Layer {
let sid = p.add_item("Solid", Label::Red, None, ItemKind::Solid(Solid { color, width: w, height: h, pixel_aspect: 1.0 }));
let mut l = build::layer(p, comp, "Solid", LayerSource::Solid { item: sid }, (w, h), None);
l.props.prop_mut("transform/position").unwrap().value = Value::Vec3([pos[0], pos[1], 0.0]);
l
}
/// A white frame with a red 20×20 square in its top-left corner.
fn picture(p: &mut Project, comp: &Comp) -> Vec<Layer> {
vec![solid(p, comp, [1.0, 0.0, 0.0], 20, 20, [10.0, 10.0]), solid(p, comp, [1.0, 1.0, 1.0], W, H, [100.0, 50.0])]
}
/// Motion Tile (Output `out` %) then Transform (Scale `scale` %), on `l`.
fn tile_then_scale(p: &mut Project, l: &mut Layer, out: f64, scale: f64) {
let size = [W as f64, H as f64];
for (id, vals) in [
("ec.stylize.motiontile", vec![("outputWidth", Value::Scalar(out)), ("outputHeight", Value::Scalar(out))]),
("ec.distort.transform", vec![("scaleHeight", Value::Scalar(scale)), ("scaleWidth", Value::Scalar(scale))]),
] {
let spec = effectcraft_effects::find(id).unwrap();
let mut next = p.next_id;
let mut g = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), spec.name, size);
p.next_id = next;
for (k, v) in vals {
g.prop_mut(k).unwrap().value = v;
}
l.props.sub_mut("effects").unwrap().children.push(g.into());
}
}
fn comp() -> (Project, ItemId, Comp) {
let mut p = Project::default();
p.settings.bit_depth = effectcraft_project::BitDepth::Bpc32;
let comp = Comp::new(W, H, FrameRate::FPS_30, Tick::from_seconds_f64(1.0));
let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into()));
(p, cid, comp)
}
/// The picture under an adjustment layer carrying the two effects.
fn adjustment_scene(out: f64, scale: f64) -> (Project, ItemId) {
let (mut p, cid, comp) = comp();
let mut adj = solid(&mut p, &comp, [1.0, 1.0, 1.0], W, H, [100.0, 50.0]);
adj.switches.adjustment = true;
tile_then_scale(&mut p, &mut adj, out, scale);
let mut layers = vec![adj];
layers.extend(picture(&mut p, &comp));
p.comp_mut(cid).unwrap().layers = layers;
(p, cid)
}
/// The picture as a full-frame precomp layer carrying the two effects.
fn layer_scene(out: f64, scale: f64) -> (Project, ItemId) {
let (mut p, cid, comp) = comp();
let inner = Comp::new(W, H, FrameRate::FPS_30, Tick::from_seconds_f64(1.0));
let iid = p.add_item("Picture", Label::Sandstone, None, ItemKind::Comp(inner.clone().into()));
let layers = picture(&mut p, &inner);
p.comp_mut(iid).unwrap().layers = layers;
let mut pre = build::layer(&mut p, &comp, "Picture", LayerSource::Comp { item: iid }, (W, H), None);
tile_then_scale(&mut p, &mut pre, out, scale);
p.comp_mut(cid).unwrap().layers.push(pre);
(p, cid)
}
fn is(img: &Image, x: i64, y: i64, want: [f32; 4], what: &str) {
let got = img.get(x, y);
assert!(got.iter().zip(want).all(|(a, b)| (a - b).abs() < 0.02), "{what}: ({x}, {y}) is {got:?}, want {want:?}");
}
const RED: [f32; 4] = [1.0, 0.0, 0.0, 1.0];
const WHITE: [f32; 4] = [1.0; 4];
const CLEAR: [f32; 4] = [0.0; 4];
/// Output 300 % then Scale 50 % about the frame centre: comp pixel (x, y) shows the tiled frame
/// at (100 + 2(x − 100), 50 + 2(y − 50)), so the red square repeats every 100 × 50 pixels and
/// the frame is covered edge to edge, the tiles from beyond the frame included.
fn assert_tiled(img: &Image, what: &str) {
for (x, y) in [(55, 30), (155, 30), (55, 80), (155, 80)] {
is(img, x, y, RED, what);
}
for (x, y) in [(5, 5), (195, 5), (5, 95), (195, 95), (100, 50), (100, 5)] {
is(img, x, y, WHITE, what);
}
}
#[test]
fn adjustment_motion_tile_output_spreads_tiles_past_the_frame() {
let (p, cid) = adjustment_scene(300.0, 50.0);
assert_tiled(&render_frame(&p, cid, Tick::ZERO, 1.0), "adjustment, Output 300 %");
}
#[test]
fn layer_motion_tile_output_spreads_tiles_past_the_frame() {
let (p, cid) = layer_scene(300.0, 50.0);
assert_tiled(&render_frame(&p, cid, Tick::ZERO, 1.0), "full-frame layer, Output 300 %");
}
/// Output under 100 % crops: only the centre 50 % of the frame is left, the rest is transparent
/// (After Effects shows the comp background there).
#[test]
fn adjustment_motion_tile_output_under_100_crops_the_frame() {
let (p, cid) = adjustment_scene(50.0, 100.0);
let img = render_frame(&p, cid, Tick::ZERO, 1.0);
is(&img, 100, 50, WHITE, "inside the kept centre");
is(&img, 60, 30, WHITE, "inside the kept centre");
for (x, y) in [(5, 5), (10, 10), (195, 95), (45, 50), (100, 20)] {
is(&img, x, y, CLEAR, "outside the kept centre");
}
}
/// The After Effects 2026 frames in #1727: a 1920×1080 red solid under an adjustment layer with
/// Motion Tile (Output 910 × 340 %, then 90 × 340 %) and Transform (Scale 12 %, Position 1361, 540).
/// The tiled frame, 9.1 × 3.4 comps scaled to 12 %, is a 2097 × 441 band centred on (1361, 540):
/// x 313–1920 (cut by the frame), y 320–760; at 90 % a 207-pixel column, x 1257–1465.
#[test]
fn adjustment_motion_tile_matches_the_after_effects_frames() {
for (out_w, x0, x1) in [(910.0, 313.0, 1920.0), (90.0, 1257.0, 1465.0)] {
let mut p = Project::default();
p.settings.bit_depth = effectcraft_project::BitDepth::Bpc32;
let comp = Comp::new(1920, 1080, FrameRate::FPS_30, Tick::from_seconds_f64(1.0));
let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into()));
let red = solid(&mut p, &comp, [1.0, 0.0, 0.0], 1920, 1080, [960.0, 540.0]);
let mut adj = solid(&mut p, &comp, [1.0, 1.0, 1.0], 1920, 1080, [960.0, 540.0]);
adj.switches.adjustment = true;
for (id, vals) in [
("ec.stylize.motiontile", vec![("outputWidth", Value::Scalar(out_w)), ("outputHeight", Value::Scalar(340.0))]),
(
"ec.distort.transform",
vec![("scaleHeight", Value::Scalar(12.0)), ("scaleWidth", Value::Scalar(12.0)), ("position", Value::Vec2([1361.0, 540.0]))],
),
] {
let spec = effectcraft_effects::find(id).unwrap();
let mut next = p.next_id;
let mut g = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), spec.name, [1920.0, 1080.0]);
p.next_id = next;
for (k, v) in vals {
g.prop_mut(k).unwrap().value = v;
}
adj.props.sub_mut("effects").unwrap().children.push(g.into());
}
p.comp_mut(cid).unwrap().layers = vec![adj, red];
// Quarter resolution: comp pixel (x, y) is output pixel (x / 4, y / 4).
let img = render_frame(&p, cid, Tick::ZERO, 0.25);
let at = |x: f64, y: f64| img.get((x / 4.0) as i64, (y / 4.0) as i64);
let what = format!("Output {out_w} %");
for (x, y) in [(x0 + 8.0, 540.0), (x1 - 8.0, 540.0), (1361.0, 328.0), (1361.0, 752.0)] {
assert_eq!(at(x, y), RED, "{what}: ({x}, {y}) is red");
}
for (x, y) in [(x0 - 8.0, 540.0), (1361.0, 312.0), (1361.0, 768.0), (100.0, 100.0)] {
assert_eq!(at(x, y), CLEAR, "{what}: ({x}, {y}) is empty");
}
if x1 < 1920.0 {
assert_eq!(at(x1 + 8.0, 540.0), CLEAR, "{what}: right of the column");
}
}
}