Files
effectcraft/crates/engine/src/tests/viewer.rs
T
Valentin KleinandClaude Sonnet 5.5 fdcc715829 Viewer exposure on the full float range of 32 bpc frames (#2937)
* Viewer exposure on the full float range of 32 bpc frames (#2797)

32 bpc viewer frames on the desktop are rendered without the HDR step (skip_hdr_step,
#2916) and kept as half floats in the RAM preview. A display pass on egui-wgpu's device
turns each frame into one RGBA8 texture with mips per viewer, in this order: decode,
exposure in linear light, Compand / Tone Map, encode, display colour, Show Channel,
rounding. Before, exposure was an 8-bit lookup applied after tone mapping, clamping and
the display conversion, so -2 EV could not bring values above 1.0 back.

- engine: viewer::DisplayPass describes the pass as data; its pixel() is the CPU
  fallback (no device) and the shader's reference. It replaces exposure_lut /
  display_transform. FrameEncoding records a frame's curve and the HDR step it still
  needs. 32 bpc snapshots are taken without the HDR step; snapshot_rgba8 applies it.
- ui-egui: FrameImage::CpuF16 (half crate, F16C at run time), byte accounting at 8 bytes
  a pixel in the cache and the prefetch estimates; display_gpu.rs + display_gpu.wgsl;
  main viewer, snapshot and passive viewers draw through the pass; the 8-bit image other
  panels read is made on demand with the HDR step; the Info panel shows float values.
- Half-float frames are not written to the disk cache yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Viewer display pass: f16 disk cache entries, window-device gating, review fixes

- Disk cache: half-float frame entries (format byte in the entry header, byte planes +
  LZ4); 8-bit entries keep format 0 and stay valid. 32 bpc viewer frames are written and
  read again, and the timeline's disk bar sees them. A damaged half-float entry is
  deleted.
- The display pass runs on the window's egui-wgpu device, with or without the GPU
  compositor (still building, failed, GL fallback). Its pipelines are made inside error
  scopes; a backend that rejects them gets the CPU pass instead of a blank viewer.
- A new LUT of the same size (another ICC profile, Preserve RGB, another display) runs
  the pass again.
- f32 to f16: one pass per L1-sized block, the NaN / infinity fix-up on the bits, no
  fill of the buffer (4K: 4.7 ms, as the 8-bit conversion).
- Mips of odd-sized levels include the last column / row; the snapshot's half floats
  and texture go with the snapshot; no placeholder texture ids; u64 size checks.
- Tests: LUT profiles and negative values against the shader, Bake reruns, the passive
  viewer's colour, f16 disk round trips; exact comparisons allow 1 LSB on software
  rasterizers. Docs: the display pass and the f16 disk entries in architecture.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Viewer f16 disk-cache test: temp dir removed on failure too, wait for the queued disk write

Review points: the test's directory was only removed by its last statement
(now tempfile::TempDir), and the frame is released before its disk write is
queued, so flush() could return early (now waits for the entry).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Viewer: oversized half-float frames use the CPU display pass, so exposure/HDR come before the averaging down

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Viewer: fmt and a debug assert for the half-float upload path

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Disk cache: persist_missing also writes half-float frames

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Passive viewer: oversized half-float frames take the CPU display pass instead of leaving a stale texture

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 17:37:23 +01:00

923 lines
54 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.
//! Composition viewer parity: snapping maths, channel / exposure display transforms, snapshots,
//! guides, the shape Pen, vertex add / delete / convert, motion-path key and tangent edits and
//! the Graph Editor transform box. Every edit undoes.
use effectcraft_keyframe::Value as KV;
use effectcraft_project::LayerId;
use effectcraft_time::Tick;
use serde_json::json;
use crate::Session;
use crate::viewer::{Channel, DisplayPass, FrameEncoding, PRI_GRID, PRI_GUIDE, PRI_LAYER, PRI_VERTEX, PassCurve, SnapKind, SnapSource, SnapTarget, snap};
fn comp() -> Session {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Main", "width": 640, "height": 360, "frameRate": 30, "duration": 10})).unwrap();
s
}
fn layer(s: &Session, id: u64) -> effectcraft_project::Layer {
s.active_comp().unwrap().layer(LayerId(id)).unwrap().clone()
}
// ---------------------------------------------------------------- snapping
#[test]
fn snap_picks_nearest_target_within_tolerance() {
let t = [SnapTarget::point([100.0, 100.0], PRI_LAYER, SnapSource::Comp), SnapTarget::point([108.0, 100.0], PRI_LAYER, SnapSource::Comp)];
let s = snap(&[[103.0, 101.0]], &t, 10.0).unwrap();
assert_eq!(s.delta, [-3.0, -1.0]);
assert_eq!(s.at, [100.0, 100.0]);
// Out of tolerance: nothing.
assert!(snap(&[[130.0, 100.0]], &t, 10.0).is_none());
// Of several sources, the one nearest a target snaps.
let s = snap(&[[0.0, 0.0], [107.0, 100.0]], &t, 10.0).unwrap();
assert_eq!(s.delta, [1.0, 0.0]);
}
#[test]
fn snap_lines_snap_axes_independently_and_priority_breaks_ties() {
let t = [
SnapTarget::vline(50.0, PRI_GRID, SnapSource::Grid),
SnapTarget::vline(54.0, PRI_GUIDE, SnapSource::Guide),
SnapTarget::hline(20.0, PRI_GRID, SnapSource::Grid),
];
// x snaps to the nearest vertical line (equal distance: the guide wins over the grid), y to
// the horizontal one.
let s = snap(&[[52.0, 23.0]], &t, 5.0).unwrap();
assert_eq!(s.delta, [2.0, -3.0]);
assert_eq!(s.hits.len(), 2);
assert_eq!(s.hits[0].source, SnapSource::Guide);
assert_eq!(s.at, [54.0, 20.0]);
// A point at the same distance as a line beats it (it snaps both axes).
let t2 = [SnapTarget::vline(10.0, PRI_GUIDE, SnapSource::Guide), SnapTarget::point([10.0, 12.0], PRI_VERTEX, SnapSource::Vertex(LayerId(1)))];
let s = snap(&[[12.0, 12.0]], &t2, 5.0).unwrap();
assert_eq!(s.hits[0].kind, SnapKind::Point);
assert_eq!(s.delta, [-2.0, 0.0]);
// Equal point distances: lower priority value (vertex) wins over a layer feature.
let t3 =
[SnapTarget::point([0.0, 3.0], PRI_LAYER, SnapSource::Layer(LayerId(2))), SnapTarget::point([3.0, 0.0], PRI_VERTEX, SnapSource::Vertex(LayerId(3)))];
assert_eq!(snap(&[[0.0, 0.0]], &t3, 5.0).unwrap().hits[0].source, SnapSource::Vertex(LayerId(3)));
}
/// #2855: an edge midpoint lies on its edge line, so one line nearer than the point must not win
/// and leave the other axis free. Two lines do win when together they are nearer.
#[test]
fn snap_point_beats_one_line_and_loses_only_to_two_nearer_lines() {
let src = SnapSource::Layer(LayerId(1));
let mid = SnapTarget::point([100.0, 50.0], PRI_LAYER, src);
let top = SnapTarget::hline(50.0, PRI_LAYER, src);
// The line alone is nearer (1 against √10), and the point still snaps both axes.
let s = snap(&[[103.0, 51.0]], &[top, mid], 5.0).unwrap();
assert_eq!(s.at, [100.0, 50.0]);
assert_eq!(s.delta, [-3.0, -1.0]);
assert_eq!(s.hits, [mid]);
// Lines through the point itself tie with it: the point.
let centre = SnapTarget::vline(100.0, PRI_LAYER, src);
assert_eq!(snap(&[[103.0, 51.0]], &[top, centre, mid], 5.0).unwrap().hits, [mid]);
// Two lines together nearer than the point: both snap.
let far = SnapTarget::point([101.0, 55.0], PRI_LAYER, src);
let v = SnapTarget::vline(102.0, PRI_GUIDE, SnapSource::Guide);
let h = SnapTarget::hline(52.0, PRI_GUIDE, SnapSource::Guide);
let s = snap(&[[103.0, 51.0]], &[v, h, far], 5.0).unwrap();
assert_eq!(s.at, [102.0, 52.0]);
assert_eq!(s.hits, [v, h]);
}
/// #2855: an unrotated layer's centre lines follow its edges' span rule, reach the outer comp
/// through a collapsed precomp layer, and a rotated layer keeps its points only.
#[test]
fn layer_centre_lines_follow_the_edges() {
use crate::viewer::{SnapFeatures, SnapOptions, targets};
let mut s = comp();
// A 100×50 solid moved off the comp's centre lines: x 70–170, y 55–105, centre (120, 80).
let a = s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 100, "height": 50})).unwrap()["layer"].as_u64().unwrap();
s.execute("prop.set", json!({"layer": a, "path": "transform/position", "value": [120, 80, 0]})).unwrap();
let cid = s.active_comp_id().unwrap();
let at = |s: &Session, f: SnapFeatures| {
let comp = s.project.comp(cid).unwrap().clone();
let ctx = effectcraft_render::EvalCtx::new(&s.project, cid, &comp, Tick::ZERO);
targets(&ctx, &[], SnapOptions { layers: true, features: f, guides: false, grid: false, grid_spacing: 0.0 })
};
let lines = |t: &[SnapTarget], l: LayerId| {
let of = |kind: SnapKind, i: usize, c: f64| t.iter().find(|t| t.kind == kind && t.source == SnapSource::Layer(l) && t.pos[i] == c).map(|t| t.span);
(of(SnapKind::VLine, 0, 120.0), of(SnapKind::HLine, 1, 80.0))
};
assert_eq!(lines(&at(&s, SnapFeatures::default()), LayerId(a)), (Some(None), Some(None)));
let off = SnapFeatures { edges_extended: false, ..Default::default() };
assert_eq!(lines(&at(&s, off), LayerId(a)), (Some(Some([55.0, 105.0])), Some(Some([70.0, 170.0]))));
// Through a collapsed precomp layer (it fills the comp, unmoved, so the numbers stay).
s.execute("layer.precompose", json!({"layers": [a], "name": "Inner"})).unwrap();
let pre = s.active_comp().unwrap().layers[0].id;
s.execute("layer.setSwitch", json!({"layers": [pre.0], "switch": "collapse", "value": true})).unwrap();
assert_eq!(lines(&at(&s, off), pre), (Some(Some([55.0, 105.0])), Some(Some([70.0, 170.0]))));
// Rotated: no lines at all.
s.execute("prop.set", json!({"layer": pre.0, "path": "transform/rotation", "value": 30})).unwrap();
assert_eq!(lines(&at(&s, off), pre), (None, None));
}
#[test]
fn snap_targets_cover_layers_comp_guides_and_grid() {
let mut s = comp();
let a = s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 100, "height": 50})).unwrap()["layer"].as_u64().unwrap();
let b = s.execute("layer.newSolid", json!({"color": "#00ff00", "width": 40, "height": 40})).unwrap()["layer"].as_u64().unwrap();
s.execute("view.addGuide", json!({"orientation": "vertical", "position": 77})).unwrap();
let cid = s.active_comp_id().unwrap();
let comp = s.project.comp(cid).unwrap().clone();
let ctx = effectcraft_render::EvalCtx::new(&s.project, cid, &comp, Tick::ZERO);
let opts = crate::viewer::SnapOptions { layers: true, features: Default::default(), guides: true, grid: true, grid_spacing: 100.0 };
let t = crate::viewer::targets(&ctx, &[LayerId(b)], opts);
// Layer a's corner (270, 155) is a target; layer b (dragged) is not.
assert!(t.iter().any(|x| x.source == SnapSource::Layer(LayerId(a)) && x.kind == SnapKind::Point && x.pos == [270.0, 155.0]));
assert!(!t.iter().any(|x| x.source == SnapSource::Layer(LayerId(b))));
assert!(t.iter().any(|x| x.source == SnapSource::Guide && x.pos[0] == 77.0));
assert!(t.iter().any(|x| x.source == SnapSource::Grid && x.kind == SnapKind::HLine && x.pos[1] == 300.0));
assert!(t.iter().any(|x| x.source == SnapSource::Comp && x.kind == SnapKind::Point && x.pos == [320.0, 180.0]));
// Dragging a corner near layer a's corner lands on it (edges snap x and y).
let hit = snap(&[[272.0, 157.0]], &t, 6.0).unwrap();
assert_eq!(hit.at, [270.0, 155.0]);
assert_eq!(hit.delta, [-2.0, -2.0]);
}
/// Tools bar ▸ Snapping options (#253): Snap Edges Extended off keeps a layer's edges to its
/// extent, every feature of a layer snaps, hidden layers are no targets, and with Snapping off
/// only guides and the grid are left.
#[test]
fn snapping_options_pick_the_targets() {
use crate::viewer::{SnapFeatures, SnapOptions, layer_features, targets};
let mut s = comp();
// A 100×50 solid at the comp centre: x 270–370, y 155–205.
let a = s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 100, "height": 50})).unwrap()["layer"].as_u64().unwrap();
let hidden = s.execute("layer.newSolid", json!({"color": "#00ff00", "width": 40, "height": 40})).unwrap()["layer"].as_u64().unwrap();
s.execute("prop.set", json!({"layer": hidden, "path": "transform/position", "value": [50, 50, 0]})).unwrap();
s.execute("layer.setSwitch", json!({"layers": [hidden], "switch": "video", "value": false})).unwrap();
s.execute("view.addGuide", json!({"orientation": "vertical", "position": 77})).unwrap();
let cid = s.active_comp_id().unwrap();
let comp = s.project.comp(cid).unwrap().clone();
let ctx = effectcraft_render::EvalCtx::new(&s.project, cid, &comp, Tick::ZERO);
let opts = |features: SnapFeatures| SnapOptions { layers: true, features, guides: false, grid: false, grid_spacing: 0.0 };
let all = targets(&ctx, &[], opts(SnapFeatures::default()));
assert!(!all.iter().any(|t| t.source == SnapSource::Layer(LayerId(hidden))), "hidden layers are no targets");
// Extended: a point far below layer a snaps to its left edge's line.
assert_eq!(snap(&[[268.0, 340.0]], &all, 4.0).unwrap().at, [270.0, 340.0]);
// Not extended: only along the edge.
let own = targets(&ctx, &[], opts(SnapFeatures { edges_extended: false, ..Default::default() }));
assert!(snap(&[[268.0, 340.0]], &own, 4.0).is_none_or(|h| h.hits.iter().all(|t| t.source != SnapSource::Layer(LayerId(a)))));
assert_eq!(snap(&[[268.0, 190.0]], &own, 4.0).unwrap().at, [270.0, 190.0]);
// Corners, edge midpoints and the centre of a are targets.
for p in [[270.0, 155.0], [320.0, 155.0], [320.0, 180.0]] {
assert!(all.iter().any(|t| t.source == SnapSource::Layer(LayerId(a)) && t.pos == p), "{p:?}");
}
// The dragged layer's features: corners, edge midpoints, centre and anchor point.
let l = comp.layer(LayerId(a)).unwrap();
let f = layer_features(&ctx, l);
assert_eq!(f[..4], [[270.0, 155.0], [370.0, 155.0], [370.0, 205.0], [270.0, 205.0]]);
assert_eq!(f.len(), 10, "{f:?}");
// Snapping off: guides only.
let guides = targets(&ctx, &[], SnapOptions { layers: false, guides: true, ..opts(SnapFeatures::default()) });
assert!(!guides.is_empty() && guides.iter().all(|t| t.source == SnapSource::Guide));
}
/// Snap to Features in Collapsed Compositions and Text Layers: the layers inside a collapsed
/// precomp layer are targets (highlighting the precomp layer), only while the option is on and
/// the precomp layer collapses transformations.
#[test]
fn collapsed_precomp_features_snap() {
use crate::viewer::{SnapFeatures, SnapOptions, targets};
let mut s = comp();
// A 40×20 solid at (100, 50) in a 640×360 precomp.
let inner = s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 40, "height": 20})).unwrap()["layer"].as_u64().unwrap();
s.execute("prop.set", json!({"layer": inner, "path": "transform/position", "value": [100, 50, 0]})).unwrap();
s.execute("layer.precompose", json!({"layers": [inner], "name": "Inner"})).unwrap();
let pre = s.active_comp().unwrap().layers[0].id;
// Moved by (10, 20) in the outer comp: the inner solid's corner (80, 40) lands at (90, 60).
s.execute("prop.set", json!({"layer": pre.0, "path": "transform/position", "value": [330, 200, 0]})).unwrap();
let cid = s.active_comp_id().unwrap();
let at = |s: &Session, f: SnapFeatures| {
let comp = s.project.comp(cid).unwrap().clone();
let ctx = effectcraft_render::EvalCtx::new(&s.project, cid, &comp, Tick::ZERO);
targets(&ctx, &[], SnapOptions { layers: true, features: f, guides: false, grid: false, grid_spacing: 0.0 })
};
let corner = |t: &[crate::viewer::SnapTarget]| t.iter().any(|t| t.kind == SnapKind::Point && t.pos == [90.0, 60.0] && t.source == SnapSource::Layer(pre));
assert!(!corner(&at(&s, SnapFeatures::default())), "not collapsed");
s.execute("layer.setSwitch", json!({"layers": [pre.0], "switch": "collapse", "value": true})).unwrap();
let on = at(&s, SnapFeatures::default());
assert!(corner(&on), "collapsed");
assert_eq!(snap(&[[93.0, 63.0]], &on, 5.0).unwrap().at, [90.0, 60.0]);
assert!(!corner(&at(&s, SnapFeatures { collapsed_features: false, ..Default::default() })), "option off");
}
#[test]
fn snapping_options_command_sets_and_toggles() {
let mut s = comp();
assert_eq!(s.state.snap_features, crate::viewer::SnapFeatures { edges_extended: true, collapsed_features: true });
let r = s.execute("view.snappingOptions", json!({"edgesExtended": false})).unwrap();
assert_eq!(r, json!({"edgesExtended": false, "collapsedFeatures": true}));
s.execute("view.snappingOptions", json!({"toggle": "collapsedFeatures"})).unwrap();
assert!(!s.state.snap_features.collapsed_features);
s.execute("view.snappingOptions", json!({"toggle": "collapsedFeatures"})).unwrap();
assert!(s.state.snap_features.collapsed_features);
// The per-feature toggles are gone, as in After Effects' menu.
for k in ["nope", "corners", "anchorPoints", "paths"] {
assert!(s.execute("view.snappingOptions", json!({"toggle": k})).is_err(), "{k}");
}
}
// ---------------------------------------------------------------- channels, exposure, snapshot
/// The display pass on 8-bit frames (sRGB, no HDR step): Show Channel and exposure.
fn display_rgba8(px: [u8; 4], channel: Channel, colorized: bool, stops: f32) -> [u8; 4] {
let encoding = FrameEncoding { curve: PassCurve::Srgb, hdr: effectcraft_project::HdrMode::Clip };
let mut v = [px];
DisplayPass::new(channel, colorized, stops, encoding, None).apply_rgba8(&mut v);
v[0]
}
#[test]
fn channel_and_exposure_pixel_transforms() {
// Premultiplied: straight (200, 100, 50) at 50% alpha.
let px = [100u8, 50, 25, 128];
let run = |c: Channel, col: bool, stops: f32| display_rgba8(px, c, col, stops);
assert_eq!(run(Channel::Rgb, false, 0.0), px);
assert_eq!(run(Channel::Red, false, 0.0), [100, 100, 100, 255]);
assert_eq!(run(Channel::Green, true, 0.0), [0, 50, 0, 255]);
assert_eq!(run(Channel::Blue, false, 0.0), [25, 25, 25, 255]);
assert_eq!(run(Channel::Alpha, false, 0.0), [128, 128, 128, 255]);
let st = run(Channel::RgbStraight, false, 0.0);
assert!((st[0] as i32 - 199).abs() <= 1 && (st[1] as i32 - 100).abs() <= 1 && st[3] == 255, "{st:?}");
// +1 stop doubles linear light: sRGB 50% gray → about 69%; -1 stop darkens; 0 is identity.
let g = display_rgba8([128, 128, 128, 255], Channel::Rgb, false, 1.0);
assert!((174..=178).contains(&g[0]), "{g:?}");
let g = display_rgba8([128, 128, 128, 255], Channel::Rgb, false, -1.0);
assert!((92..=96).contains(&g[0]), "{g:?}");
for v in 0..=255u8 {
assert_eq!(display_rgba8([v, v, v, 255], Channel::Rgb, false, 0.0), [v, v, v, 255]);
}
// Exposure applies to a single channel too, and keeps alpha untouched in RGB.
assert!(run(Channel::Red, false, 1.0)[0] > 100);
assert_eq!(run(Channel::Rgb, false, 2.0)[3], 128);
}
#[test]
fn channel_exposure_and_fast_preview_commands() {
let mut s = comp();
s.execute("view.channel", json!({"channel": "alpha"})).unwrap();
assert_eq!(s.state.viewer.channel, Channel::Alpha);
s.execute("view.channel", json!({"channel": "alpha", "toggle": true})).unwrap();
assert_eq!(s.state.viewer.channel, Channel::Rgb);
s.execute("view.channel", json!({"channel": "RGB Straight", "colorized": true})).unwrap();
assert_eq!(s.state.viewer.channel, Channel::RgbStraight);
assert!(s.state.viewer.colorized);
s.execute("view.exposure", json!({"stops": 1.5})).unwrap();
s.execute("view.exposure", json!({"delta": -0.5})).unwrap();
assert_eq!(s.state.viewer.exposure, 1.0);
s.execute("view.resetExposure", json!({})).unwrap();
assert_eq!(s.state.viewer.exposure, 0.0);
s.execute("view.fastPreviewMode", json!({"mode": "fastDraft"})).unwrap();
assert_eq!(s.state.viewer.fast_previews, crate::commands::viewer_cmds::FastPreviews::FastDraft);
assert!(s.execute("view.fastPreviewMode", json!({"mode": "nope"})).is_err());
let was = s.state.snapping;
s.execute("view.snapping", json!({})).unwrap();
assert_eq!(s.state.snapping, !was);
}
#[test]
fn snapshot_store_and_restore() {
let mut s = comp();
assert!(!s.is_enabled("view.showSnapshot"));
s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 640, "height": 360})).unwrap();
let r = s.execute("view.takeSnapshot", json!({"scale": 0.25})).unwrap();
assert_eq!((r["width"].as_u64(), r["height"].as_u64()), (Some(160), Some(90)));
// The project changes; the snapshot keeps the old frame.
s.execute("layer.newSolid", json!({"color": "#0000ff", "width": 640, "height": 360})).unwrap();
let snap = s.snapshot.clone().unwrap();
assert_eq!(snap.image.get(10, 10), [1.0, 0.0, 0.0, 1.0]);
let now = s.render(s.active_comp_id().unwrap(), Tick::ZERO, effectcraft_render::RenderOpts { scale: 0.25, ..Default::default() });
assert_eq!(now.get(10, 10), [0.0, 0.0, 1.0, 1.0]);
s.execute("view.showSnapshot", json!({"value": true})).unwrap();
assert!(s.state.viewer.show_snapshot);
let (w, h, rgba) = crate::commands::viewer_cmds::snapshot_rgba8(&s).unwrap();
assert_eq!((w, h), (160, 90));
assert_eq!(&rgba[0..4], &[255, 0, 0, 255]);
s.execute("edit.purge", json!({"what": "snapshot"})).unwrap();
assert!(s.snapshot.is_none() && !s.state.viewer.show_snapshot);
}
#[test]
fn guides_move_and_remove_with_undo() {
let mut s = comp();
s.execute("view.addGuide", json!({"orientation": "horizontal", "position": 40})).unwrap();
s.execute("view.moveGuide", json!({"index": 0, "position": 90, "merge": "g"})).unwrap();
s.execute("view.moveGuide", json!({"index": 0, "position": 95, "merge": "g"})).unwrap();
assert_eq!(s.active_comp().unwrap().guides[0].position, 95.0);
s.undo();
assert_eq!(s.active_comp().unwrap().guides[0].position, 40.0);
s.execute("view.removeGuide", json!({"index": 0})).unwrap();
assert!(s.active_comp().unwrap().guides.is_empty());
assert!(s.execute("view.removeGuide", json!({"index": 0})).is_err());
s.undo();
assert_eq!(s.active_comp().unwrap().guides.len(), 1);
}
#[test]
fn region_of_interest_renders_and_crops() {
let mut s = comp();
s.execute("layer.newSolid", json!({"color": "#ff8000", "width": 200, "height": 100})).unwrap();
s.execute("view.setRegionOfInterest", json!({"rect": [200, 100, 240, 160]})).unwrap();
let cid = s.active_comp_id().unwrap();
let full = s.render(cid, Tick::ZERO, Default::default());
let roi = s.render(cid, Tick::ZERO, effectcraft_render::RenderOpts { roi: s.state.region_of_interest, ..Default::default() });
assert_eq!((roi.width, roi.height), (240, 160));
for (x, y) in [(0, 0), (30, 50), (239, 159), (119, 79)] {
assert_eq!(roi.get(x, y), full.get(x + 200, y + 100));
}
s.execute("comp.cropToRegionOfInterest", json!({})).unwrap();
let c = s.active_comp().unwrap();
assert_eq!((c.width, c.height), (240, 160));
let cropped = s.render(cid, Tick::ZERO, Default::default());
assert_eq!(cropped.get(30, 50), roi.get(30, 50));
assert_eq!(cropped.get(30, 50)[3], 1.0);
}
// ---------------------------------------------------------------- shape pen and vertices
fn shape_path(s: &Session, lid: u64, uid: u64) -> effectcraft_keyframe::ShapePath {
let l = layer(s, lid);
l.props.find_group(uid).unwrap().get("path").unwrap().value.as_path().unwrap().clone()
}
#[test]
fn shape_pen_builds_shape_group_and_renders() {
let mut s = comp();
// Nothing selected: a new shape layer; vertices in comp space.
let r = s
.execute("shape.newPath", json!({"vertices": [[100, 100], [300, 100], [300, 250], [100, 250]], "closed": true, "fill": [0, 1, 0], "strokeWidth": 0}))
.unwrap();
let (lid, group, path) = (r["layer"].as_u64().unwrap(), r["group"].as_u64().unwrap(), r["path"].as_u64().unwrap());
let l = layer(&s, lid);
assert!(matches!(l.source, effectcraft_project::LayerSource::Shape));
let g = l.props.find_group(group).unwrap();
assert_eq!(g.name, "Shape 1");
let items: Vec<&str> = g.sub("contents").unwrap().groups().map(|x| x.match_id.as_str()).collect();
assert_eq!(items, vec!["path", "fill"]);
assert_eq!(g.sub("contents").unwrap().groups().next().unwrap().name, "Path 1");
// Layer space: the layer sits at the comp centre.
assert_eq!(shape_path(&s, lid, path).vertices[0], [100.0 - 320.0, 100.0 - 180.0]);
let img = s.render(s.active_comp_id().unwrap(), Tick::ZERO, Default::default());
assert_eq!(img.get(200, 170), [0.0, 1.0, 0.0, 1.0]);
assert_eq!(img.get(50, 50)[3], 0.0);
// With the shape layer selected the Pen adds a second group (with a stroke) to it.
let r2 = s.execute("shape.newPath", json!({"vertices": [[0, 0], [10, 10]], "space": "layer", "strokeWidth": 2})).unwrap();
assert_eq!(r2["layer"].as_u64(), Some(lid));
let l = layer(&s, lid);
let names: Vec<String> = l.props.sub("contents").unwrap().groups().map(|g| g.name.clone()).collect();
assert_eq!(names, vec!["Shape 2", "Shape 1"]);
let g2 = l.props.find_group(r2["group"].as_u64().unwrap()).unwrap();
let items: Vec<&str> = g2.sub("contents").unwrap().groups().map(|x| x.match_id.as_str()).collect();
assert_eq!(items, vec!["path", "stroke", "fill"]);
s.undo();
assert_eq!(layer(&s, lid).props.sub("contents").unwrap().groups().count(), 1);
// Not a shape layer → error.
let sol = s.execute("layer.newSolid", json!({"color": "#ffffff"})).unwrap()["layer"].as_u64().unwrap();
assert!(s.execute("shape.newPath", json!({"layer": sol, "vertices": [[0, 0]]})).is_err());
}
/// The shape tools draw into the selected shape layer's Contents as a new group, as After
/// Effects does, and a new shape layer only with none selected (#227).
#[test]
fn shape_tools_draw_into_the_selected_shape_layer() {
let mut s = comp();
// Nothing selected: a new shape layer centred on the shape.
let r = s.execute("shape.newShape", json!({"kind": "rect", "size": [100, 60], "position": [200, 100], "fill": [1, 0, 0]})).unwrap();
let lid = r["layer"].as_u64().unwrap();
let l = layer(&s, lid);
assert_eq!(l.name, "Shape Layer 1");
assert_eq!(l.props.prop("transform/position").unwrap().value.components()[..2], [200.0, 100.0]);
// Selected (and scaled 200%): the next shape goes on top of its Contents, in layer space.
s.execute("prop.set", json!({"layer": lid, "path": "transform/scale", "value": [200, 200, 100]})).unwrap();
let n = s.active_comp().unwrap().layers.len();
let r = s.execute("shape.newShape", json!({"kind": "ellipse", "size": [40, 20], "position": [300, 160]})).unwrap();
assert_eq!(r["layer"].as_u64(), Some(lid));
assert_eq!(s.active_comp().unwrap().layers.len(), n, "no new layer");
let l = layer(&s, lid);
let contents = l.props.sub("contents").unwrap();
let names: Vec<&str> = contents.groups().map(|g| g.name.as_str()).collect();
assert_eq!(names, ["Ellipse 1", "Rectangle 1"]);
let g = l.props.find_group(r["group"].as_u64().unwrap()).unwrap();
assert_eq!(g.sub("transform").unwrap().get("position").unwrap().value.components(), [50.0, 30.0]);
assert_eq!(g.sub("contents").unwrap().groups().next().unwrap().get("size").unwrap().value.components(), [20.0, 10.0]);
// A second rectangle is "Rectangle 2"; it renders where it was drawn.
s.execute("shape.newShape", json!({"kind": "rect", "size": [40, 40], "position": [500, 300], "fill": [0, 1, 0]})).unwrap();
let names: Vec<String> = layer(&s, lid).props.sub("contents").unwrap().groups().map(|g| g.name.clone()).collect();
assert_eq!(names, ["Rectangle 2", "Ellipse 1", "Rectangle 1"]);
let img = s.render(s.active_comp_id().unwrap(), Tick::ZERO, Default::default());
assert_eq!(img.get(500, 300), [0.0, 1.0, 0.0, 1.0]);
// Each shape is one undo step.
s.undo();
s.undo();
assert_eq!(layer(&s, lid).props.sub("contents").unwrap().groups().count(), 1);
// A locked shape layer isn't drawn into; a solid is refused when named.
s.execute("layer.setSwitch", json!({"layers": [lid], "switch": "locked", "value": true})).unwrap();
s.state.selected_layers = vec![LayerId(lid)];
let r = s.execute("shape.newShape", json!({"kind": "star"})).unwrap();
assert_ne!(r["layer"].as_u64(), Some(lid));
let sol = s.execute("layer.newSolid", json!({"color": "#ffffff"})).unwrap()["layer"].as_u64().unwrap();
assert!(s.execute("shape.newShape", json!({"layer": sol})).is_err());
assert!(s.execute("shape.newShape", json!({"kind": "blob"})).is_err());
}
/// The Tools bar's Fill / Stroke Options (None, Solid Color, Linear and Radial Gradient, blend
/// mode, opacity) and Stroke Width paint new shapes and Pen paths; a command's own paint
/// parameters win (#227).
#[test]
fn shape_tool_options_paint_new_shapes() {
use crate::commands::shape_tool::ShapeTool;
let mut s = comp();
let o = s.execute("shape.toolOptions", json!({})).unwrap();
assert_eq!((o["createsMask"].clone(), o["fill"]["kind"].clone(), o["strokeWidth"].clone()), (json!(false), json!("solid"), json!(0.0)));
// A radial gradient fill at 50% in Multiply and a red 4 px stroke.
let o = s
.execute("shape.toolOptions", json!({"fillType": "Radial Gradient", "fillBlend": "multiply", "fillOpacity": 50, "stroke": [1, 0, 0], "strokeWidth": 4}))
.unwrap();
assert_eq!(o["fill"]["kind"], json!("radial"));
let r = s.execute("shape.newShape", json!({"kind": "ellipse", "size": [100, 60]})).unwrap();
let lid = r["layer"].as_u64().unwrap();
let items = |s: &Session, g: u64| layer(s, lid).props.find_group(g).unwrap().sub("contents").unwrap().clone();
let g = items(&s, r["group"].as_u64().unwrap());
assert_eq!(g.groups().map(|x| x.match_id.as_str()).collect::<Vec<_>>(), ["ellipse", "stroke", "gfill"]);
let at = |g: &effectcraft_project::PropGroup, item: &str, prop: &str| g.groups().find(|x| x.match_id == item).unwrap().get(prop).unwrap().value.clone();
let multiply = effectcraft_color::BlendMode::ALL.iter().position(|m| *m == effectcraft_color::BlendMode::Multiply).unwrap() as u32;
assert_eq!(at(&g, "gfill", "type"), KV::Enum(1));
assert_eq!(at(&g, "gfill", "blend"), KV::Enum(multiply));
assert_eq!(at(&g, "gfill", "opacity").as_f64(), 50.0);
assert_eq!(at(&g, "gfill", "end").components(), [50.0, 0.0], "out from the centre");
assert_eq!(at(&g, "stroke", "color").components(), [1.0, 0.0, 0.0, 1.0]);
assert_eq!(at(&g, "stroke", "width").as_f64(), 4.0);
// No fill and a linear gradient stroke, across a Pen path's points.
s.execute("shape.toolOptions", json!({"fill": false, "strokeType": "linear"})).unwrap();
let r = s.execute("shape.newPath", json!({"layer": lid, "vertices": [[10, 20], [110, 40]], "space": "layer"})).unwrap();
let g = items(&s, r["group"].as_u64().unwrap());
assert_eq!(g.groups().map(|x| x.match_id.as_str()).collect::<Vec<_>>(), ["path", "gstroke"]);
assert_eq!((at(&g, "gstroke", "start").components(), at(&g, "gstroke", "end").components()), (vec![10.0, 30.0], vec![110.0, 30.0]));
// A command's own paint wins over the Tools bar's.
let r = s.execute("shape.newShape", json!({"kind": "rect", "fill": "#00ff00", "strokeWidth": 0})).unwrap();
let g = items(&s, r["group"].as_u64().unwrap());
assert_eq!(g.groups().map(|x| x.match_id.as_str()).collect::<Vec<_>>(), ["rect", "fill"]);
assert_eq!(at(&g, "fill", "color").components(), [0.0, 1.0, 0.0, 1.0]);
// Bad values are errors that leave the options as they were; reset restores the defaults.
let before = s.state.shape_tool.clone();
for bad in [json!({"fillType": "plaid"}), json!({"strokeBlend": "nope"}), json!({"fill": true}), json!({"strokeWidth": "wide"})] {
assert!(s.execute("shape.toolOptions", bad.clone()).is_err(), "{bad}");
}
assert_eq!(s.state.shape_tool, before);
s.execute("shape.toolOptions", json!({"reset": true, "createsMask": true})).unwrap();
assert_eq!(s.state.shape_tool, ShapeTool { creates_mask: true, ..ShapeTool::default() });
}
/// Tool Creates Mask: every shape tool draws its mask (#227).
#[test]
fn masks_of_every_shape_tool_kind() {
let mut s = comp();
let sol = s.execute("layer.newSolid", json!({"color": "#ffffff"})).unwrap()["layer"].as_u64().unwrap();
for (kind, n) in [("rect", 4), ("ellipse", 4), ("rounded", 8), ("polygon", 5), ("star", 10)] {
let m = s.execute("layer.addMask", json!({"layer": sol, "shape": kind, "rect": [100, 100, 80, 80]})).unwrap()["mask"].as_u64().unwrap();
let path = layer(&s, sol).props.find_group(m).unwrap().get("path").unwrap().value.as_path().unwrap().clone();
assert!(path.closed, "{kind}");
assert!(if kind == "rounded" { path.vertices.len() >= n } else { path.vertices.len() == n }, "{kind}: {}", path.vertices.len());
assert!(path.vertices.iter().all(|v| (100.0..=180.0).contains(&v[0]) && (100.0..=180.0).contains(&v[1])), "{kind}: inside the box");
}
assert!(s.execute("layer.addMask", json!({"layer": sol, "shape": "blob"})).is_err());
}
#[test]
fn vertex_add_delete_convert_on_shape_paths_and_masks_undo() {
let mut s = comp();
let r = s.execute("shape.newPath", json!({"vertices": [[0, 0], [100, 0], [100, 100]], "space": "layer"})).unwrap();
let (lid, path) = (r["layer"].as_u64().unwrap(), r["path"].as_u64().unwrap());
// Pen continues the path: add a vertex, move it, close.
s.execute("mask.addVertex", json!({"layer": lid, "mask": path, "point": [0, 100]})).unwrap();
s.execute("mask.setClosed", json!({"layer": lid, "mask": path, "closed": true})).unwrap();
let sp = shape_path(&s, lid, path);
assert_eq!(sp.vertices.len(), 4);
assert!(sp.closed);
// Add Vertex on segment 0 at t=0.5 keeps the line and inserts its midpoint.
let i = s.execute("mask.insertVertex", json!({"layer": lid, "mask": path, "segment": 0})).unwrap();
assert_eq!(i.as_u64(), Some(1));
assert_eq!(shape_path(&s, lid, path).vertices[1], [50.0, 0.0]);
// Convert Vertex: corner → smooth → corner.
let c = s.execute("mask.convertVertex", json!({"layer": lid, "mask": path, "index": 2})).unwrap();
assert_eq!(c["smooth"], json!(true));
let sp = shape_path(&s, lid, path);
// Neighbours (50,0) and (100,100): tangent along them, a sixth of the way.
assert_eq!(sp.out_tangents[2], [50.0 / 6.0, 100.0 / 6.0]);
assert_eq!(sp.in_tangents[2], [-50.0 / 6.0, -100.0 / 6.0]);
s.execute("mask.convertVertex", json!({"layer": lid, "mask": path, "index": 2})).unwrap();
assert_eq!(shape_path(&s, lid, path).out_tangents[2], [0.0, 0.0]);
s.undo();
assert_ne!(shape_path(&s, lid, path).out_tangents[2], [0.0, 0.0]);
// Delete Vertex (selection-based).
s.execute("mask.selectVertices", json!({"vertices": [{"layer": lid, "mask": path, "index": 1}]})).unwrap();
s.execute("mask.deleteVertices", json!({})).unwrap();
assert_eq!(shape_path(&s, lid, path).vertices.len(), 4);
s.undo();
assert_eq!(shape_path(&s, lid, path).vertices.len(), 5);
// Free transform of the whole shape path (scale 200% about the bbox centre).
s.execute("mask.selectVertices", json!({"vertices": []})).unwrap();
s.execute("path.freeTransform", json!({"layer": lid, "mask": path, "scale": 200})).unwrap();
assert_eq!(shape_path(&s, lid, path).vertices[0], [-50.0, -50.0]);
// Masks work through the same commands.
let sol = s.execute("layer.newSolid", json!({"color": "#ffffff"})).unwrap()["layer"].as_u64().unwrap();
let m = s.execute("mask.new", json!({"layer": sol, "vertices": [[0, 0], [100, 0], [100, 100]], "closed": true})).unwrap()["mask"].as_u64().unwrap();
s.execute("mask.insertVertex", json!({"layer": sol, "mask": m, "segment": 2, "t": 0.5})).unwrap();
let l = layer(&s, sol);
let mp = l.masks().unwrap().find_group(m).unwrap().get("path").unwrap().value.as_path().unwrap().clone();
assert_eq!(mp.vertices.len(), 4);
assert_eq!(mp.vertices[3], [50.0, 50.0]);
}
#[test]
fn split_segment_keeps_curve_shape() {
use effectcraft_keyframe::ShapePath;
let mut sp = ShapePath {
vertices: vec![[0.0, 0.0], [100.0, 0.0]],
in_tangents: vec![[0.0; 2], [0.0, -50.0]],
out_tangents: vec![[30.0, 60.0], [0.0; 2]],
closed: false,
feather: Vec::new(),
};
let seg = |sp: &ShapePath, i: usize, t: f64| {
let (a, d) = (sp.vertices[i], sp.vertices[i + 1]);
let b = [a[0] + sp.out_tangents[i][0], a[1] + sp.out_tangents[i][1]];
let c = [d[0] + sp.in_tangents[i + 1][0], d[1] + sp.in_tangents[i + 1][1]];
let u = 1.0 - t;
let f = |k: usize| u * u * u * a[k] + 3.0 * u * u * t * b[k] + 3.0 * u * t * t * c[k] + t * t * t * d[k];
[f(0), f(1)]
};
let before: Vec<[f64; 2]> = (0..=2000).map(|i| seg(&sp, 0, i as f64 / 2000.0)).collect();
crate::commands::split_segment_for_tests(&mut sp, 0, 0.3);
assert_eq!(sp.vertices.len(), 3);
for i in 0..2 {
for j in 0..=50 {
let p = seg(&sp, i, j as f64 / 50.0);
let d = before.iter().map(|q| ((p[0] - q[0]).powi(2) + (p[1] - q[1]).powi(2)).sqrt()).fold(f64::MAX, f64::min);
assert!(d < 0.2, "{p:?} {d}");
}
}
}
// ---------------------------------------------------------------- motion paths and graph box
fn animated_position(s: &mut Session) -> u64 {
let lid = s.execute("layer.newSolid", json!({"color": "#ffffff", "width": 50, "height": 50})).unwrap()["layer"].as_u64().unwrap();
for (t, v) in [(0.0, [100.0, 100.0]), (1.0, [300.0, 100.0]), (2.0, [300.0, 300.0])] {
s.execute("prop.addKey", json!({"layer": lid, "path": "transform/position", "time": t, "value": [v[0], v[1], 0.0]})).unwrap();
}
lid
}
#[test]
fn motion_path_key_drag_edits_value_and_tangents() {
let mut s = comp();
let lid = animated_position(&mut s);
// Dragging the middle key's dot sets that key's value (merged drag = one undo step).
s.execute("keys.set", json!({"layer": lid, "path": "transform/position", "time": 1.0, "value": [310, 120, 0], "merge": "mp"})).unwrap();
s.execute("keys.set", json!({"layer": lid, "path": "transform/position", "time": 1.0, "value": [320, 140, 0], "merge": "mp"})).unwrap();
let pos = |s: &Session| layer(s, lid).props.prop("transform/position").unwrap().clone();
assert_eq!(pos(&s).keys[1].value, KV::Vec3([320.0, 140.0, 0.0]));
assert_eq!(pos(&s).keys[0].value, KV::Vec3([100.0, 100.0, 0.0]));
// Tangent drag: out handle set, in mirrored, no longer auto.
s.execute("keys.setSpatialTangents", json!({"layer": lid, "path": "transform/position", "time": 1.0, "out": [40, 0], "merge": "tg"})).unwrap();
let k = pos(&s).keys[1].clone();
assert_eq!(k.spatial_out, [40.0, 0.0, 0.0]);
assert_eq!(k.spatial_in, [-40.0, 0.0, 0.0]);
assert!(!k.spatial_auto);
// The motion path passes through the key and bulges along the tangent.
let mid = pos(&s).value_at(Tick::from_seconds_f64(1.0)).as_vec3();
assert_eq!([mid[0], mid[1]], [320.0, 140.0]);
// Alt-drag (break): only the in handle changes.
s.execute("keys.setSpatialTangents", json!({"layer": lid, "path": "transform/position", "time": 1.0, "in": [0, -30], "break": true})).unwrap();
let k = pos(&s).keys[1].clone();
assert_eq!((k.spatial_in, k.spatial_out), ([0.0, -30.0, 0.0], [40.0, 0.0, 0.0]));
s.undo();
s.undo();
assert!(pos(&s).keys[1].spatial_auto);
s.undo();
assert_eq!(pos(&s).keys[1].value, KV::Vec3([300.0, 100.0, 0.0]));
}
#[test]
fn graph_transform_box_scales_key_times_and_values() {
let mut s = comp();
let lid = s.execute("layer.newSolid", json!({"color": "#ffffff"})).unwrap()["layer"].as_u64().unwrap();
for (t, v) in [(0.0, 0.0), (1.0, 50.0), (2.0, 100.0)] {
s.execute("prop.addKey", json!({"layer": lid, "path": "transform/opacity", "time": t, "value": v})).unwrap();
}
let uid = layer(&s, lid).props.prop("transform/opacity").unwrap().uid;
let keys: Vec<_> = [0.0, 1.0, 2.0].iter().map(|t| json!({"layer": lid, "prop": uid, "time": t})).collect();
s.execute("keys.select", json!({"keys": keys})).unwrap();
// Time: scale 2× about 0 s; value: scale 0.5 about 100.
s.execute("keys.transform", json!({"timeScale": 2.0, "timeAnchor": 0.0, "valueScale": 0.5, "valueAnchor": 100.0})).unwrap();
let p = layer(&s, lid).props.prop("transform/opacity").unwrap().clone();
let got: Vec<(f64, f64)> = p.keys.iter().map(|k| (k.time.seconds(), k.value.as_f64())).collect();
assert_eq!(got, vec![(0.0, 50.0), (2.0, 75.0), (4.0, 100.0)]);
assert_eq!(s.state.selected_keys.len(), 3);
// Alt-drag of the last key in the timeline: scale the group about the first key; times
// land on frames.
s.execute("keys.transform", json!({"timeScale": 0.501, "timeAnchor": 0.0})).unwrap();
let p = layer(&s, lid).props.prop("transform/opacity").unwrap().clone();
let got: Vec<f64> = p.keys.iter().map(|k| k.time.seconds()).collect();
assert_eq!(got, vec![0.0, 1.0, 2.0]);
s.undo();
s.undo();
let p = layer(&s, lid).props.prop("transform/opacity").unwrap().clone();
assert_eq!(p.keys.iter().map(|k| k.time.seconds()).collect::<Vec<_>>(), vec![0.0, 1.0, 2.0]);
assert_eq!(p.keys[1].value.as_f64(), 50.0);
assert!(s.execute("keys.transform", json!({"timeScale": 0})).is_err());
}
// ---------------------------------------------------------------- resolution
#[test]
fn each_comp_keeps_its_resolution_outside_the_undo_history_and_in_the_file() {
use effectcraft_project::Resolution;
let mut s = comp();
let a = s.active_comp_id().unwrap();
s.execute("layer.newSolid", json!({"name": "S", "color": "#ff0000", "width": 10, "height": 10})).unwrap();
let path = std::env::temp_dir().join(format!("ec-resolution-{}.ecproj", std::process::id()));
s.execute("file.saveAs", json!({"path": path.to_string_lossy()})).unwrap();
assert_eq!(s.active_comp().unwrap().resolution, Resolution::Auto);
let (steps, pixels) = (s.history.undo.len(), s.active_comp_arc().unwrap());
let r = s.execute_checked("view.res.half", json!({})).unwrap();
assert_eq!((r["resolution"].clone(), r["factor"].clone()), (json!("Half"), json!(2)));
// No undo step, but the project is modified (it's saved with it).
assert_eq!(s.history.undo.len(), steps);
assert!(s.is_dirty());
assert!(s.active_comp().unwrap().same_pixels(&pixels));
// Another comp has its own; switching back shows comp A's again.
s.execute("comp.new", json!({"name": "B", "width": 320, "height": 180, "frameRate": 30, "duration": 5})).unwrap();
let b = s.active_comp_id().unwrap();
assert_ne!(a, b);
assert_eq!(s.active_comp().unwrap().resolution, Resolution::Auto);
s.execute_checked("view.res.custom", json!({"factor": 6})).unwrap();
s.open_comp(a);
assert_eq!(s.active_comp().unwrap().resolution, Resolution::Half);
assert_eq!(s.execute("comp.info", json!({})).unwrap()["resolution"], json!("Half"));
assert_eq!(crate::menus::checked(&s, "view.res.half", &json!({})), Some(true));
assert_eq!(crate::menus::checked(&s, "view.res.full", &json!({})), Some(false));
// Undo and redo restore the comps, not their resolutions.
s.execute("edit.undo", json!({})).unwrap();
assert!(s.project.comp(b).is_none(), "undid New Composition");
assert_eq!(s.project.comp(a).unwrap().resolution, Resolution::Half);
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(s.project.comp(b).unwrap().resolution, Resolution::Custom(6));
s.execute("edit.undo", json!({})).unwrap();
s.execute("edit.undo", json!({})).unwrap();
assert!(s.active_comp().unwrap().layers.is_empty(), "undid the solid");
assert_eq!(s.project.comp(a).unwrap().resolution, Resolution::Half);
s.execute("edit.redo", json!({})).unwrap();
s.execute("edit.redo", json!({})).unwrap();
// The comp's explicit `comp` parameter; the same value again changes nothing.
s.execute_checked("view.res.quarter", json!({"comp": b.0})).unwrap();
let rev = s.revision;
s.execute_checked("view.res.quarter", json!({"comp": b.0})).unwrap();
assert_eq!(s.revision, rev);
// Saved with the project and read back.
s.execute("file.save", json!({})).unwrap();
assert!(!s.is_dirty());
let mut o = Session::default();
o.execute("file.open", json!({"path": path.to_string_lossy()})).unwrap();
assert_eq!(o.project.comp(a).unwrap().resolution, Resolution::Half);
assert_eq!(o.project.comp(b).unwrap().resolution, Resolution::Quarter);
let _ = std::fs::remove_file(path);
// Custom without a factor asks the frontend (the Custom Resolution dialog).
s.drain_events();
s.execute_checked("view.res.custom", json!({})).unwrap();
assert!(s.drain_events().iter().any(|e| matches!(e, crate::Event::Frontend { command, .. } if command == "view.res.custom")));
assert!(s.execute_checked("view.res.custom", json!({"factor": "big"})).is_err());
s.execute_checked("view.res.auto", json!({})).unwrap();
assert_eq!(s.active_comp().unwrap().resolution, Resolution::Auto);
}
/// #1685: changing Type updates the property tree in the same undo step, including when
/// addressed by uid (as in the UI) rather than by path (scripts/control channel).
#[test]
fn polystar_type_switch_updates_inner_controls_and_undoes() {
let mut s = comp();
let lid = s.execute("layer.newShape", json!({"kind": "polygon"})).unwrap()["layer"].as_u64().unwrap();
let group = |s: &Session| layer(s, lid).props.group("contents/group/contents/star").unwrap().clone();
let polygon = group(&s);
let type_uid = polygon.get("type").unwrap().uid;
assert!(polygon.get("innerRadius").is_none());
s.execute("prop.set", json!({"layer": lid, "prop": type_uid, "value": 0})).unwrap();
let star = group(&s);
assert_eq!(star.get("innerRadius").unwrap().value, KV::Scalar(50.0));
assert_eq!(star.get("innerRoundness").unwrap().value, KV::Scalar(0.0));
assert_eq!(star.get("innerRadius").unwrap().ui, effectcraft_project::ParamUi::Pixels);
assert_eq!(star.get("innerRoundness").unwrap().ui, effectcraft_project::ParamUi::Percent);
let names: Vec<_> = star.children.iter().filter_map(|n| n.as_prop()).map(|p| p.match_id.as_str()).collect();
assert_eq!(names, ["direction", "type", "points", "position", "rotation", "innerRadius", "outerRadius", "innerRoundness", "outerRoundness"]);
assert_eq!(star.get("outerRadius"), polygon.get("outerRadius"));
assert!(s.undo());
assert_eq!(group(&s), polygon);
assert!(s.redo());
assert_eq!(group(&s), star);
// Reapplying the same Type must not reset custom inner values or allocate new ids.
s.execute("prop.set", json!({"layer": lid, "path": "contents/group/contents/star/innerRadius", "value": 37})).unwrap();
let before = group(&s);
let next = s.project.next_id;
s.execute("prop.set", json!({"layer": lid, "path": "contents/group/contents/star/type", "value": 0})).unwrap();
assert_eq!(group(&s), before);
assert_eq!(s.project.next_id, next);
let inner_uid = before.get("innerRadius").unwrap().uid;
s.state.selected_props = vec![(LayerId(lid), inner_uid)];
s.state.selected_keys = vec![crate::KeyRef { layer: LayerId(lid), prop: inner_uid, time: Tick::ZERO }];
s.execute("prop.set", json!({"layer": lid, "path": "contents/group/contents/star/type", "value": 1})).unwrap();
assert_eq!(group(&s).get("type").unwrap().value, KV::Enum(1));
assert!(group(&s).get("innerRadius").is_none());
assert!(group(&s).get("innerRoundness").is_none());
assert!(s.state.selected_props.is_empty());
assert!(s.state.selected_keys.is_empty());
assert!(s.undo());
assert_eq!(group(&s), before, "undo restores the original inner values and ids");
assert!(s.redo());
assert!(group(&s).get("innerRadius").is_none());
s.execute("prop.set", json!({"layer": lid, "prop": type_uid, "value": 0})).unwrap();
assert_eq!(group(&s).get("innerRadius").unwrap().value, KV::Scalar(50.0));
}
#[test]
fn polystar_animated_type_keeps_the_inner_controls_needed_by_other_frames() {
let mut s = comp();
let lid = s.execute("layer.newShape", json!({"kind": "star"})).unwrap()["layer"].as_u64().unwrap();
s.execute("prop.set", json!({"layer": lid, "path": "contents/group/contents/star/innerRadius", "value": 27})).unwrap();
s.execute("prop.toggleAnimation", json!({"layer": lid, "path": "contents/group/contents/star/type", "value": true})).unwrap();
s.execute("prop.set", json!({"layer": lid, "path": "contents/group/contents/star/type", "time": 1, "value": 1})).unwrap();
let star = layer(&s, lid).props.group("contents/group/contents/star").unwrap().clone();
assert_eq!(star.get("type").unwrap().value_at(Tick::ZERO), KV::Enum(0));
assert_eq!(star.get("type").unwrap().value_at(Tick::from_seconds_f64(1.0)), KV::Enum(1));
assert_eq!(star.get("innerRadius").unwrap().value, KV::Scalar(27.0));
}
#[test]
fn polystar_expression_routes_synchronize_controls_in_the_same_undo_step() {
for explicit in [false, true] {
let mut s = comp();
let lid = s.execute("layer.newShape", json!({"kind": "polygon"})).unwrap()["layer"].as_u64().unwrap();
let group = |s: &Session| layer(s, lid).props.group("contents/group/contents/star").unwrap().clone();
let before = group(&s);
let uid = before.get("type").unwrap().uid;
s.state.selected_props = vec![(LayerId(lid), uid)];
let params = if explicit { json!({"layer": lid, "prop": uid, "expression": "1"}) } else { json!({"expression": "1"}) };
s.execute("prop.setExpression", params).unwrap();
let animated = group(&s);
assert!(animated.get("innerRadius").is_some() && animated.get("innerRoundness").is_some());
assert!(s.undo());
assert_eq!(group(&s), before);
assert!(s.redo());
assert_eq!(group(&s), animated);
let inner = animated.get("innerRadius").unwrap().uid;
s.state.selected_props = vec![(LayerId(lid), inner)];
s.execute("prop.setExpression", json!({"layer": lid, "prop": uid, "expression": ""})).unwrap();
assert!(group(&s).get("innerRadius").is_none());
assert!(s.state.selected_props.is_empty());
assert!(s.undo());
assert_eq!(group(&s), animated);
assert!(s.redo());
assert!(group(&s).get("innerRadius").is_none());
}
}
#[test]
fn polystar_animation_menu_adds_inner_controls_and_undoes() {
let mut s = comp();
let lid = s.execute("layer.newShape", json!({"kind": "polygon"})).unwrap()["layer"].as_u64().unwrap();
let group = |s: &Session| layer(s, lid).props.group("contents/group/contents/star").unwrap().clone();
let before = group(&s);
s.state.selected_props = vec![(LayerId(lid), before.get("type").unwrap().uid)];
s.execute("anim.addKeyframe", json!({})).unwrap();
let animated = group(&s);
assert_eq!(animated.get("type").unwrap().keys.len(), 1);
assert!(animated.get("innerRadius").is_some() && animated.get("innerRoundness").is_some());
assert!(s.undo());
assert_eq!(group(&s), before);
assert!(s.redo());
assert_eq!(group(&s), animated);
}
#[test]
fn deleting_the_last_polystar_type_key_removes_stale_controls_and_selection() {
for command in ["keys.delete", "edit.clear"] {
let mut s = comp();
let lid = s.execute("layer.newShape", json!({"kind": "polygon"})).unwrap()["layer"].as_u64().unwrap();
let group = |s: &Session| layer(s, lid).props.group("contents/group/contents/star").unwrap().clone();
let type_uid = group(&s).get("type").unwrap().uid;
s.execute("prop.addKey", json!({"layer": lid, "prop": type_uid, "time": 0, "value": 1})).unwrap();
let animated = group(&s);
let inner = animated.get("innerRadius").unwrap().uid;
s.state.selected_props = vec![(LayerId(lid), inner)];
s.state.selected_keys = vec![crate::KeyRef { layer: LayerId(lid), prop: type_uid, time: Tick::ZERO }];
s.execute(command, json!({})).unwrap();
let polygon = group(&s);
assert!(polygon.get("innerRadius").is_none() && polygon.get("innerRoundness").is_none());
assert!(s.state.selected_props.is_empty() && s.state.selected_keys.is_empty());
assert!(s.undo());
assert_eq!(group(&s), animated);
assert!(s.redo());
assert_eq!(group(&s), polygon);
}
}
#[test]
fn polystar_sync_cleans_the_edited_compositions_parked_selection() {
let mut s = comp();
let first = s.active_comp_id().unwrap();
let lid = s.execute("layer.newShape", json!({"kind": "polygon"})).unwrap()["layer"].as_u64().unwrap();
let path = "contents/group/contents/star";
let type_uid = layer(&s, lid).props.group(path).unwrap().get("type").unwrap().uid;
s.execute("prop.addKey", json!({"layer": lid, "prop": type_uid, "time": 0, "value": 1})).unwrap();
let before = layer(&s, lid).props.group(path).unwrap().clone();
let inner = before.get("innerRadius").unwrap().uid;
s.state.selected_props = vec![(LayerId(lid), inner)];
s.state.selected_keys = vec![crate::KeyRef { layer: LayerId(lid), prop: inner, time: Tick::ZERO }];
s.execute("comp.new", json!({"width": 100, "height": 100, "duration": 2})).unwrap();
let second = s.active_comp_id().unwrap();
let other = s.execute("layer.newSolid", json!({})).unwrap()["layer"].as_u64().unwrap();
let other_prop = layer(&s, other).props.prop("transform/rotation").unwrap().uid;
s.execute("prop.addKey", json!({"layer": other, "prop": other_prop, "time": 0, "value": 15})).unwrap();
s.state.selected_props = vec![(LayerId(other), other_prop)];
s.state.selected_keys = vec![crate::KeyRef { layer: LayerId(other), prop: other_prop, time: Tick::ZERO }];
let live = (s.state.selected_props.clone(), s.state.selected_keys.clone());
s.execute("prop.toggleAnimation", json!({"comp": first.0, "layer": lid, "prop": type_uid, "value": false})).unwrap();
assert_eq!(s.active_comp_id(), Some(second));
assert_eq!((s.state.selected_props.clone(), s.state.selected_keys.clone()), live);
let parked = s.state.comp_selections.get(&first).unwrap();
assert!(parked.props.is_empty() && parked.keys.is_empty(), "the inactive composition must not keep removed control references");
let after = s.project.comp(first).unwrap().layer(LayerId(lid)).unwrap().props.group(path).unwrap().clone();
assert!(after.get("innerRadius").is_none());
assert!(s.undo());
assert_eq!(s.project.comp(first).unwrap().layer(LayerId(lid)).unwrap().props.group(path).unwrap(), &before);
assert!(s.redo());
assert_eq!(s.project.comp(first).unwrap().layer(LayerId(lid)).unwrap().props.group(path).unwrap(), &after);
}
#[test]
fn polystar_multi_property_expression_clear_resolves_targets_before_removing_controls() {
let mut s = comp();
let lid = s.execute("layer.newShape", json!({"kind": "polygon"})).unwrap()["layer"].as_u64().unwrap();
let group = |s: &Session| layer(s, lid).props.group("contents/group/contents/star").unwrap().clone();
let type_uid = group(&s).get("type").unwrap().uid;
s.execute("prop.setExpression", json!({"layer": lid, "prop": type_uid, "expression": "1"})).unwrap();
let inner = group(&s).get("innerRadius").unwrap().uid;
s.execute("prop.setExpression", json!({"layer": lid, "prop": inner, "expression": "37"})).unwrap();
let before = group(&s);
s.state.selected_props = vec![(LayerId(lid), type_uid), (LayerId(lid), inner)];
assert_eq!(s.execute("prop.setExpression", json!({"expression": ""})).unwrap(), json!([null, null]));
let after = group(&s);
assert!(after.get("type").unwrap().expr.is_none());
assert!(after.get("innerRadius").is_none());
assert_eq!(s.state.selected_props, vec![(LayerId(lid), type_uid)]);
assert!(s.undo());
assert_eq!(group(&s), before);
assert!(s.redo());
assert_eq!(group(&s), after);
}
/// Undo must discard removed controls before their UIDs can name newly allocated properties.
#[test]
fn polystar_undo_sanitizes_live_and_parked_property_selections_before_uid_reuse() {
for parked in [false, true] {
let mut s = comp();
let cid = s.active_comp_id().unwrap();
let lid = s.execute("layer.newShape", json!({"kind": "polygon"})).unwrap()["layer"].as_u64().unwrap();
let polygon = layer(&s, lid).props.group("contents/group/contents/star").unwrap().clone();
let type_uid = polygon.get("type").unwrap().uid;
s.execute("comp.new", json!({"width": 100, "height": 100, "duration": 2})).unwrap();
let other = s.active_comp_id().unwrap();
s.activate_comp(cid);
s.state.selected_props = vec![(LayerId(lid), type_uid)];
s.execute("anim.addKeyframe", json!({})).unwrap();
let inner = layer(&s, lid).props.group("contents/group/contents/star").unwrap().get("innerRadius").unwrap().uid;
let valid = vec![(LayerId(lid), type_uid), (LayerId(lid), polygon.uid)];
s.state.selected_props = valid.iter().copied().chain([(LayerId(lid), inner)]).collect();
s.state.selected_keys = vec![crate::KeyRef { layer: LayerId(lid), prop: inner, time: Tick::ZERO }];
if parked {
s.activate_comp(other);
}
assert!(s.undo());
let (props, keys) = if parked {
let selection = s.state.comp_selections.get(&cid).unwrap();
(&selection.props, &selection.keys)
} else {
(&s.state.selected_props, &s.state.selected_keys)
};
assert_eq!(props, &valid, "remove stale leaves while preserving property and group selections");
assert!(keys.is_empty());
s.activate_comp(cid);
s.execute("prop.separateDimensions", json!({"layer": lid, "value": true})).unwrap();
let position_z = layer(&s, lid).props.prop("transform/positionZ").unwrap().clone();
assert_eq!(position_z.uid, inner, "the removed control's UID was actually reused");
s.execute("prop.setExpression", json!({"expression": "1"})).unwrap();
assert_eq!(layer(&s, lid).props.prop("transform/positionZ").unwrap(), &position_z, "the reused UID must not become an implicit expression target");
}
}