Layer: Un-precompose a selected pre-comp layer back into its parent composition (#3081)

* Layer: Un-precompose a selected pre-comp layer back into its parent composition (#2918)

layer.unprecompose copies the nested composition's layers into the parent at the pre-comp
layer's place with timing and position carried over, and refuses what it can't keep exactly.

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

* Un-precompose: refuse a pre-comp layer with Solo or Guide on

A soloed pre-comp layer hides the comp's other layers and a guide layer is
left out of renders; the expanded copies carry neither switch, so the picture
would change. Listed with the other blockers, in the command description and
the agents.md Cookbook entry; the refusal test covers both switches.

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

* Un-precompose: warn instead of refusing, only a locked layer stops it

A locked pre-comp layer is still refused, with or without `force`. Every
other former refusal reason is a warning: without `force`,
`layer.unprecompose` returns an error that lists the reasons and changes
nothing. `dryRun` returns `{dryRun, warnings}`. With `force` the layer is
unpacked in one undo step and the result carries `warnings`. Whatever the
pre-comp layer itself carried is dropped, and other layers' parent, track
matte and layer-property links to it are removed.

Layer > Un-precompose in the UI lists the reasons in a prompt with
Unpack Anyway / Cancel (`dialog.unprecompose.force` /
`dialog.unprecompose.cancel`). A layer without reasons unpacks at once.

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

* Un-precompose: warn about expressions that refer to the pre-comp layer

An expression on another layer of the comp that names the pre-comp layer
would no longer find it once the layer is unpacked. The expression can
name it in quotes or call `layer(<index>)` with its index, bare or on
thisComp; another comp's comp("…").layer(…) is not counted. Each such
property is now listed once as a warning. Like the other warnings,
`force` unpacks and `dryRun` lists it. The expressions are left as
written.

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

* Un-precompose: warn about lost frame sampling and Position expressions, keep markers in order

Review follow-up (CodeRabbit on #3081):
- A nested comp that preserves a frame rate other than the parent's
  samples its layers on its own frames, and the expanded copies would
  lose that. Motion-blurred nested layers under a different shutter
  would also blur with the parent's shutter. Both are now warnings.
- When the pre-comp layer's Position differs from its Anchor Point, the
  move is added to the stored Position of the unparented nested layers.
  An unparented layer whose Position, or a separated axis, has an
  expression may ignore that move, so it is now a warning.
- Copied comp markers are sorted into time order with the comp's own.
- New tests: a 10 fps nested comp with Preserve Frame Rate in a 30 fps
  comp, a different shutter, a Position expression with and without a
  move, and nested matte and layer-value remapping with shifted markers
  merged in order.

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

* Un-precompose: warn when an expression's layer numbers shift; the prompt keeps its target

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

* Un-precompose: a shorter renumbering warning

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

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-11-07 00:56:54 +01:00
1 parent 52bca964f5
commit bb2d2005ce
11 files changed
+1009 -1

No files matched your search

+10 -1
View File
@@ -31,7 +31,7 @@ use replace::{has_replace_source, replace_source};
pub(crate) use select::select_only;
use select::{select, select_next, select_prev, select_step};
use switches::{blend, rename, set_comment, set_switch, track_matte};
use timing::{arrange, move_frames, precompose, slip, timing};
use timing::{arrange, move_frames, precompose, slip, timing, unprecompose};
use transform::{has_layer_settings, layer_settings, transform_op};
pub(crate) use transform::{offset_position, transform_default, update_position};
@@ -177,6 +177,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_layers,
precompose
),
cmd!(
"layer.unprecompose",
"Un-precompose",
["Layer"],
None,
"{layer?, force?, dryRun?} → {layers, warnings}: expands the selected pre-comp layer into its composition's layers (copies, in order, with timing and position carried over). A locked pre-comp layer is refused, changing nothing. What would change the picture (the pre-comp layer has a parent, matte, effects, masks, layer styles, a non-default Scale/Rotation/Opacity, an animated transform, time remap/stretch, a blending mode, Video/Audio off, Solo/Guide on, is 3D/collapsing/adjustment, is used by another layer or named in another layer's expression; or the nested comp has cameras, lights, adjustment, 3D, collapsing, soloed or blend-mode / Preserve Transparency layers, preserves another frame rate, has another motion-blur shutter, or has Position expressions the move can't reach) is an error listing the reasons, changing nothing, unless `force`: then it unpacks anyway in one undo step, drops what the pre-comp layer carried, removes other layers' parent/matte/property links to it and lists the reasons as `warnings`. `dryRun` returns {dryRun, warnings} and changes nothing",
has_layers,
unprecompose
),
cmd!(
"layer.addMask",
"New Mask",
+316
View File
@@ -1,3 +1,5 @@
use effectcraft_project::TrackMatte;
use super::{
EngineError, ItemKind, Label, Layer, LayerId, LayerSource, Result, Session, Tick, Value, b_p, bad, build, f_p, json, layer_mut, layers_p, merge_p,
resolve_layer, str_p, unlocked, with,
@@ -307,3 +309,317 @@ pub(super) fn precompose(s: &mut Session, p: &Value) -> Result<Value> {
}
Ok(json!({"comp": new.0.0, "layer": new.1.0}))
}
/// Why expanding a pre-comp layer in place would change the picture or drop something, one entry
/// per cause. Un-precompose keeps the picture exactly for a pre-comp layer that only places the
/// nested composition (Position/Anchor Point, timing); anything the layer itself adds to the look,
/// and what only holds inside the nested comp, is listed. These are warnings: `force` unpacks
/// anyway and drops it. A lock is not listed here: it always refuses.
fn unprecompose_warnings(comp: &effectcraft_project::Comp, layer: &Layer, nested: &effectcraft_project::Comp) -> Vec<String> {
let mut why = vec![];
if layer.parent.is_some() {
why.push("the layer has a parent".to_string());
}
if layer.track_matte.is_some() {
why.push("the layer has a track matte".to_string());
}
if comp.layers.iter().any(|o| o.id != layer.id && (o.parent == Some(layer.id) || o.track_matte.is_some_and(|m| m.layer == layer.id))) {
why.push("another layer uses it as its parent or track matte (that link is removed)".to_string());
}
if layer.is_3d() || layer.switches.collapse || layer.switches.adjustment || layer.preserve_transparency {
why.push("the layer is 3D, collapses transformations, is an adjustment layer or preserves transparency".to_string());
}
if !layer.switches.video || !layer.switches.audio {
why.push("the layer's Video or Audio switch is off".to_string());
}
if layer.switches.solo || layer.switches.guide {
// Solo hides the comp's other layers and a guide layer is left out of renders; the
// expanded copies would carry neither, so the picture would change.
why.push("the layer's Solo or Guide switch is on".to_string());
}
if comp.layers.iter().any(|o| o.id != layer.id && refers_to(&o.props, layer.id)) {
why.push("a property of another layer refers to it (that reference is cleared)".to_string());
}
why.extend(expression_warnings(comp, layer, nested.layers.len()));
if layer.blend_mode != effectcraft_color::BlendMode::default() {
why.push("the layer has a blending mode".to_string());
}
if layer.stretch != 100.0 || layer.props.prop("timeRemap").is_some() {
why.push("the layer is time-stretched or time-remapped".to_string());
}
if layer.effects().is_some_and(|e| !e.children.is_empty()) {
why.push("the layer has effects".to_string());
}
if layer.masks().is_some_and(|m| !m.children.is_empty()) {
why.push("the layer has masks".to_string());
}
if layer.styles_change_pixels() {
why.push("the layer has layer styles".to_string());
}
if let Some(tr) = layer.transform() {
let at = |m: &str, d: [f64; 3]| tr.get(m).map(|p| p.value.as_vec3()).unwrap_or(d);
let animated = tr.props().any(|p| !p.keys.is_empty() || p.expr.is_some());
let separated = tr.get("positionX").is_some();
if animated || separated {
why.push("its transform is animated, has an expression or has separated dimensions".to_string());
} else {
let (sc, rot, ori) = (at("scale", [100.0; 3]), at("rotation", [0.0; 3]), at("orientation", [0.0; 3]));
let op = tr.get("opacity").map(|p| p.value.as_f64()).unwrap_or(100.0);
if sc[0] != 100.0 || sc[1] != 100.0 || rot[0] != 0.0 || ori != [0.0; 3] || op != 100.0 {
why.push("its Scale, Rotation or Opacity is not at its default".to_string());
}
}
}
if nested.layers.iter().any(|l| l.is_camera() || l.is_light()) {
why.push("the composition has a camera or light".to_string());
}
// Inside the nested comp these composite against the nested stack only; in the parent they
// would act on everything below.
if nested.layers.iter().any(|l| {
l.switches.adjustment || l.switches.collapse || l.is_3d() || l.preserve_transparency || l.blend_mode != effectcraft_color::BlendMode::default()
}) {
why.push("the composition has adjustment, 3D or collapsing layers or layers with a blending mode or Preserve Transparency".to_string());
}
// A solo hides only the nested comp's other layers; a soloed copy would hide the parent's.
if nested.layers.iter().any(|l| l.switches.solo) {
why.push("the composition has a soloed layer".to_string());
}
why.extend(sampling_warnings(comp, nested));
why.extend(position_expression_warnings(layer, nested));
why
}
/// What the nested comp's own frame sampling does that the expanded layers lose: a preserved
/// frame rate other than the parent's (its layers then change only on its own frames), and
/// motion blur with a shutter other than the parent's.
fn sampling_warnings(comp: &effectcraft_project::Comp, nested: &effectcraft_project::Comp) -> Vec<String> {
let mut why = vec![];
if nested.preserve_frame_rate && nested.frame_rate != comp.frame_rate {
why.push("the composition preserves a frame rate other than this one's (its layers would be sampled at this comp's frames)".to_string());
}
let shutter = nested.shutter_angle != comp.shutter_angle || nested.shutter_phase != comp.shutter_phase;
if shutter && nested.layers.iter().any(|l| l.switches.motion_blur) {
why.push("the composition's motion blur shutter differs from this one's".to_string());
}
why
}
/// The move from the pre-comp layer's Position against its Anchor Point, which expansion adds to
/// the Position of the nested comp's unparented layers.
fn placement_offset(layer: &Layer) -> (f64, f64) {
let tr = layer.transform();
let at = |m: &str| tr.and_then(|t| t.get(m)).map(|p| p.value.as_vec3()).unwrap_or([0.0; 3]);
let (anchor, position) = (at("anchor"), at("position"));
(position[0] - anchor[0], position[1] - anchor[1])
}
/// Unparented nested layers whose Position (or a separated axis) has an expression: the move from
/// [`placement_offset`] is added to the stored value and keys, which an expression can ignore.
fn position_expression_warnings(layer: &Layer, nested: &effectcraft_project::Comp) -> Vec<String> {
let (dx, dy) = placement_offset(layer);
if dx == 0.0 && dy == 0.0 {
return vec![];
}
nested
.layers
.iter()
.filter(|l| l.parent.is_none())
.filter(|l| l.transform().is_some_and(|tr| ["position", "positionX", "positionY"].iter().any(|m| tr.get(m).is_some_and(|p| p.expr.is_some()))))
.map(|l| format!("layer “{}” has a Position expression, so the move to the pre-comp layer's Position may not apply to it", l.name))
.collect()
}
/// One warning per property of another layer in `comp` whose expression names `layer`: by its
/// name in quotes, or as `layer(<index>)` with its 1-based index (not on another comp's
/// `comp("…").layer(…)`), and one per expression naming a layer by a number below it while the
/// `added` layers replacing it renumber those. Detection is by text; the expressions are left as
/// they are.
fn expression_warnings(comp: &effectcraft_project::Comp, layer: &Layer, added: usize) -> Vec<String> {
let index = comp.layers.iter().position(|l| l.id == layer.id).map(|i| i.saturating_add(1));
let quoted = [format!("\"{}\"", layer.name), format!("'{}'", layer.name)];
let names_it = |text: &str| quoted.iter().any(|q| text.contains(q.as_str())) || index.is_some_and(|i| indexed_layers(text).contains(&i));
let renumbered = |text: &str| added != 1 && index.is_some_and(|i| indexed_layers(text).iter().any(|n| *n > i));
let mut out = vec![];
for o in comp.layers.iter().filter(|o| o.id != layer.id) {
o.props.walk("", &mut |_, p| {
if p.expr.as_ref().is_some_and(|e| !names_it(&e.text) && renumbered(&e.text)) {
out.push(format!("layer “{}” {} names a layer by number, and the layers below “{}” are renumbered", o.name, p.name, layer.name));
}
if p.expr.as_ref().is_some_and(|e| names_it(&e.text)) {
out.push(format!("layer “{}” {} has an expression that refers to “{}” (it will no longer find it)", o.name, p.name, layer.name));
}
});
}
out
}
/// The indexes expression `text` calls `layer(<index>)` with (spaces allowed) on this comp: bare,
/// `thisComp.layer(…)`, not `comp("Other").layer(…)` or `thisLayer(…)`.
fn indexed_layers(text: &str) -> Vec<usize> {
text.match_indices("layer(")
.filter_map(|(at, _)| {
let before = text.get(..at).unwrap_or("");
let on_other = before.ends_with(").") || before.chars().next_back().is_some_and(|c| c.is_alphanumeric() || c == '_');
if on_other && !before.ends_with("thisComp.") {
return None;
}
let rest = text.get(at + "layer(".len()..).unwrap_or("").trim_start();
let digits: String = rest.chars().take_while(char::is_ascii_digit).collect();
let after = rest.get(digits.len()..).unwrap_or("").trim_start();
digits.parse::<usize>().ok().filter(|_| after.starts_with(')'))
})
.collect()
}
/// Whether any property (value or key) in `props` holds a reference to layer `id`.
fn refers_to(props: &effectcraft_project::PropGroup, id: LayerId) -> bool {
let is = |v: &effectcraft_keyframe::Value| matches!(v, effectcraft_keyframe::Value::Layer(Some(l)) if *l == id.0);
props.children.iter().any(|c| match c {
effectcraft_project::Node::Prop(p) => is(&p.value) || p.keys.iter().any(|k| is(&k.value)),
effectcraft_project::Node::Group(g) => refers_to(g, id),
})
}
/// Clear every reference to layer `id` (`Value::Layer`, value or key) in `props`; references to
/// other layers stay.
fn clear_layer_refs(props: &mut effectcraft_project::PropGroup, id: LayerId) {
let fix = |v: &mut effectcraft_keyframe::Value| {
if matches!(v, effectcraft_keyframe::Value::Layer(Some(l)) if *l == id.0) {
*v = effectcraft_keyframe::Value::Layer(None);
}
};
for c in &mut props.children {
match c {
effectcraft_project::Node::Prop(p) => {
fix(&mut p.value);
for k in &mut p.keys {
fix(&mut k.value);
}
}
effectcraft_project::Node::Group(g) => clear_layer_refs(g, id),
}
}
}
/// The layers of `comp` other than `id` let go of it: parent, track matte and property references
/// to it are removed, so nothing points at a layer that is gone.
fn detach_from(comp: &mut effectcraft_project::Comp, id: LayerId) {
for l in comp.layers.iter_mut().filter(|l| l.id != id) {
if l.parent == Some(id) {
l.parent = None;
}
if l.track_matte.is_some_and(|m| m.layer == id) {
l.track_matte = None;
}
clear_layer_refs(&mut l.props, id);
}
}
/// Replace layer references (`Value::Layer`) found in `props` by `map` (old id → new id), the
/// way copies of a set of layers keep pointing at each other. A reference to a layer outside the
/// set is cleared: it named a layer of the nested comp that is no longer there.
fn remap_layer_values(props: &mut effectcraft_project::PropGroup, map: &[(LayerId, LayerId)]) {
let fix = |v: &mut effectcraft_keyframe::Value| {
if let effectcraft_keyframe::Value::Layer(Some(id)) = v {
*v = effectcraft_keyframe::Value::Layer(map.iter().find(|(o, _)| o.0 == *id).map(|(_, n)| n.0));
}
};
for c in &mut props.children {
match c {
effectcraft_project::Node::Prop(p) => {
fix(&mut p.value);
for k in &mut p.keys {
fix(&mut k.value);
}
}
effectcraft_project::Node::Group(g) => remap_layer_values(g, map),
}
}
}
/// Layer ▸ Un-precompose: expand a selected pre-comp layer back into its composition. The nested
/// comp's layers become copies in this comp at the pre-comp layer's place, in their order and
/// with their keyframes, effects, masks, parenting, mattes, markers and audio; their timing is
/// moved by the pre-comp layer's Start and trimmed to the span it showed, and an Anchor Point
/// different from Position moves the unparented ones by the difference. The nested comp stays in
/// the project. The nested comp's frame no longer clips what the layers draw outside it.
///
/// A locked pre-comp layer is refused, changing nothing. Anything else that would change the
/// picture or drop something (see [`unprecompose_warnings`]) is an error listing the reasons unless
/// `force` is set; with `force` the layer is expanded the same way, what the pre-comp layer itself
/// carried (effects, masks, styles, transform beyond Position, time remap, blending mode, switches,
/// parent, matte) goes with it, and the other layers' parent, matte and property links to it are
/// removed. The result lists the reasons as `warnings`; `dryRun` only lists them.
pub(super) fn unprecompose(s: &mut Session, p: &Value) -> Result<Value> {
let c = "layer.unprecompose";
let (cid, ids) = layers_p(s, p)?;
let [lid] = ids[..] else { return Err(bad(c, "select exactly one pre-comp layer")) };
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
let layer = comp.layer(lid).ok_or(EngineError::NoComp)?.clone();
let LayerSource::Comp { item } = layer.source else { return Err(bad(c, format!("“{}” is not a pre-comp layer", layer.name))) };
if layer.switches.locked {
return Err(bad(c, format!("can't un-precompose “{}”: the layer is locked (unlock it first)", layer.name)));
}
let nested = s.project.comp(item).ok_or_else(|| bad(c, "the layer's composition is missing"))?.clone();
let warnings = unprecompose_warnings(&comp, &layer, &nested);
if b_p(p, "dryRun").unwrap_or(false) {
return Ok(json!({"dryRun": true, "warnings": warnings}));
}
if !warnings.is_empty() && !b_p(p, "force").unwrap_or(false) {
let why = warnings.join("; ");
return Err(bad(
c,
format!(
"can't un-precompose “{}” without changing it: {why} (nothing changed: pass `force` to unpack anyway, or `dryRun` to list the reasons)",
layer.name
),
));
}
let (dx, dy) = placement_offset(&layer);
let shown = (layer.in_point.max(layer.start_time), layer.out_point.min(layer.start_time + nested.duration));
let new = s.edit("Un-precompose", None, |proj, st| {
let mut next = proj.next_id;
let mut copies: Vec<Layer> = nested.layers.iter().cloned().collect();
let mut map = vec![];
for l in &mut copies {
let old = l.id;
crate::cmd::edit::reid(l, &mut next);
map.push((old, l.id));
}
let link = |id: LayerId| map.iter().find(|(o, _)| *o == id).map(|(_, n)| *n);
for l in &mut copies {
l.parent = l.parent.and_then(link);
l.track_matte = l.track_matte.and_then(|m| link(m.layer).map(|layer| TrackMatte { layer, ..m }));
remap_layer_values(&mut l.props, &map);
l.start_time += layer.start_time;
l.in_point = (l.in_point + layer.start_time).clamp(shown.0, shown.1.max(shown.0));
l.out_point = (l.out_point + layer.start_time).clamp(l.in_point, shown.1.max(l.in_point));
if l.parent.is_none()
&& let Some(tr) = l.transform_mut()
{
super::offset_position(tr, dx, dy);
}
}
proj.next_id = next;
let target = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
detach_from(target, lid);
let at = target.layers.iter().position(|l| l.id == lid).unwrap_or(0);
target.layers.retain(|l| l.id != lid);
let mut made = vec![];
for (k, mut l) in copies.into_iter().enumerate() {
l.name = target.unique_layer_name(&l.name);
made.push(l.id);
target.layers.insert((at + k).min(target.layers.len()), l);
}
for m in &nested.markers {
let mut m = m.clone();
m.time += layer.start_time;
target.markers.push(m);
}
// Comp markers are kept in time order (`thisComp.marker.key(n)`, next/previous marker).
target.markers.sort_by_key(|m| m.time);
st.selected_layers = made.clone();
Ok(made)
})?;
Ok(json!({"layers": new.iter().map(|l| l.0).collect::<Vec<_>>(), "warnings": warnings}))
}
+1
View File
@@ -1018,6 +1018,7 @@ Layer
Duplicate and Assign Material | material.duplicateAssign
Auto-trace... | layer.autoTrace
Pre-compose... | layer.precompose
Un-precompose | layer.unprecompose
Scene Edit Detection... | layer.sceneEditDetection
Effect
Effect Controls | window.panel {"panel":"effectControls"} | F3, Cmd+Shift+T
+466
View File
@@ -2066,6 +2066,472 @@ fn precompose_unlinks_layers_left_behind_and_numbers_default_names() {
assert_eq!(names, ["Pre-comp 1", "Pre-comp 2"]);
}
fn id_named(s: &Session, name: &str) -> u64 {
s.active_comp().unwrap().layers.iter().find(|l| l.name == name).unwrap_or_else(|| panic!("no layer {name}")).id.0
}
/// A comp with solids A (keyed Position, parent of B), B and C.
fn precomp_scene() -> Session {
let mut s = comp();
for (name, color, pos) in [("A", "#ff0000", [150.0, 100.0]), ("B", "#00ff00", [350.0, 220.0]), ("C", "#0000ff", [500.0, 100.0])] {
let l = solid(&mut s, color);
s.execute("layer.rename", json!({"layers": [l], "name": name})).unwrap();
s.execute("prop.set", json!({"layer": l, "path": "transform/position", "value": pos})).unwrap();
}
let (a, b) = (id_named(&s, "A"), id_named(&s, "B"));
s.execute("prop.addKey", json!({"layer": a, "path": "transform/position", "time": 0, "value": [150, 100]})).unwrap();
s.execute("prop.addKey", json!({"layer": a, "path": "transform/position", "time": 1, "value": [300, 200]})).unwrap();
s.execute("layer.setParent", json!({"layers": [b], "parent": a})).unwrap();
s
}
/// Where A, B and C are drawn at a few times.
fn scene_look(s: &mut Session) -> Vec<[f64; 4]> {
let mut out = vec![];
for t in [0.0, 0.5, 1.0, 2.0] {
s.execute("time.set", json!({"time": t})).unwrap();
for n in ["A", "B", "C"] {
let id = id_named(s, n);
out.push(seen_bounds(s, id));
}
}
out
}
/// Un-precompose (#2918) gives the picture back: precompose A and B, expand the pre-comp layer
/// again, and every layer is drawn where it was at every time, parenting and stacking order are
/// kept, as one undo step.
#[test]
fn unprecompose_restores_what_precompose_nested() {
let mut s = precomp_scene();
let before = scene_look(&mut s);
let order: Vec<String> = s.active_comp().unwrap().layers.iter().map(|l| l.name.clone()).collect();
let (a, b) = (id_named(&s, "A"), id_named(&s, "B"));
let r = s.execute("layer.precompose", json!({"layers": [a, b], "name": "Inner"})).unwrap();
let pre = r["layer"].as_u64().unwrap();
assert_eq!(s.active_comp().unwrap().layers.len(), 2);
let undo = s.history.undo.len();
let r = s.execute("layer.unprecompose", json!({"layers": [pre]})).unwrap();
assert_eq!(r["layers"].as_array().unwrap().len(), 2);
assert_eq!(s.history.undo.len(), undo + 1, "one undo step");
let after: Vec<String> = s.active_comp().unwrap().layers.iter().map(|l| l.name.clone()).collect();
assert_eq!(after, order, "the layers are back in their order");
let (a, b) = (id_named(&s, "A"), id_named(&s, "B"));
assert_eq!(layer(&s, b).parent, Some(effectcraft_project::LayerId(a)), "B is still A's child");
for (w, g) in before.iter().zip(scene_look(&mut s)) {
assert!(w.iter().zip(g).all(|(x, y)| (x - y).abs() < 1e-6), "a layer moved: {w:?} → {g:?}");
}
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.active_comp().unwrap().layers.len(), 2, "undo brings the pre-comp layer back");
}
/// A pre-comp layer that was moved or started later places the nested layers the same way after it
/// is expanded: its Position (against the Anchor Point) and its Start carry over.
#[test]
fn unprecompose_carries_the_precomp_layers_position_and_start() {
let mut s = precomp_scene();
let before = scene_look(&mut s);
let (a, b) = (id_named(&s, "A"), id_named(&s, "B"));
let pre = s.execute("layer.precompose", json!({"layers": [a, b], "name": "Inner"})).unwrap()["layer"].as_u64().unwrap();
s.execute("prop.set", json!({"layer": pre, "path": "transform/position", "value": [360, 160]})).unwrap();
s.execute("layer.unprecompose", json!({"layers": [pre]})).unwrap();
// The pre-comp's Anchor Point sat at the comp centre (320, 180): everything moved by (+40, −20).
let after = scene_look(&mut s);
for (i, (w, g)) in before.iter().zip(after).enumerate() {
let n = i % 3;
let (dx, dy) = if n < 2 { (40.0, -20.0) } else { (0.0, 0.0) };
let want = [w[0] + dx, w[1] + dy, w[2] + dx, w[3] + dy];
assert!(want.iter().zip(g).all(|(x, y)| (x - y).abs() < 1e-6), "layer {n}: wanted {want:?}, drawn {g:?}");
}
// Starting later: the nested layers start later by as much, and are trimmed to the span shown.
let mut s = precomp_scene();
let (a, b) = (id_named(&s, "A"), id_named(&s, "B"));
let pre = s.execute("layer.precompose", json!({"layers": [a, b], "name": "Inner"})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.timing", json!({"layers": [pre], "op": "set", "start": 2.0})).unwrap();
let start = layer(&s, pre).start_time;
s.execute("layer.unprecompose", json!({"layers": [pre]})).unwrap();
let a = layer(&s, id_named(&s, "A"));
assert_eq!(a.start_time, start, "the nested layer's time 0 sits at the pre-comp layer's Start");
assert!(a.in_point >= start, "and it doesn't show before it");
}
/// A pre-comp scene: the outer comp, the nested comp and the pre-comp layer (A and B nested).
fn unprecompose_scene() -> (Session, effectcraft_project::ItemId, u64, u64) {
let mut s = precomp_scene();
let (a, b) = (id_named(&s, "A"), id_named(&s, "B"));
let outer = s.active_comp_id().unwrap();
let r = s.execute("layer.precompose", json!({"layers": [a, b], "name": "Inner"})).unwrap();
(s, outer, r["comp"].as_u64().unwrap(), r["layer"].as_u64().unwrap())
}
/// Everything un-precompose could change: every comp (layers and markers) and the item count.
fn whole_project(s: &Session) -> (Vec<effectcraft_project::Comp>, usize) {
(s.project.comps().map(|(_, c)| c.clone()).collect(), s.project.items.len())
}
/// Without `force`, a reason is an error naming it that changes nothing (no undo step), and the
/// dry run lists it; with `force` the layer is unpacked anyway in one undo step, the result lists
/// the reason as a warning, and undo restores the exact project.
fn warned_then_forced(s: &mut Session, pre: u64, what: &str) {
let undo = s.history.undo.len();
let before = whole_project(s);
let e = s.execute("layer.unprecompose", json!({"layers": [pre]})).expect_err(what).to_string();
assert!(e.contains(what) && e.contains("force"), "{what}: {e}");
assert!(whole_project(s) == before, "{what}: nothing changed");
assert_eq!(s.history.undo.len(), undo, "{what}: no undo step");
let dry = s.execute("layer.unprecompose", json!({"layers": [pre], "dryRun": true})).unwrap();
assert!(dry["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains(what)), "{what}: {dry}");
assert!(whole_project(s) == before, "{what}: the dry run changed nothing");
let r = s.execute("layer.unprecompose", json!({"layers": [pre], "force": true})).expect(what);
assert!(r["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains(what)), "{what}: {r}");
assert!(r["layers"].as_array().unwrap().len() >= 2, "{what}: A and B are back");
assert_eq!(s.history.undo.len(), undo + 1, "{what}: one undo step");
let comp = s.active_comp().unwrap();
assert!(comp.layers.iter().all(|l| l.id.0 != pre), "{what}: the pre-comp layer is gone");
for l in &comp.layers {
assert!(l.parent.is_none_or(|p| comp.layer(p).is_some()), "{what}: {} has a dangling parent", l.name);
assert!(l.track_matte.is_none_or(|m| comp.layer(m.layer).is_some()), "{what}: {} has a dangling matte", l.name);
}
scene_look(s);
s.execute("edit.undo", json!({})).unwrap();
assert!(whole_project(s) == before, "{what}: undo restores the exact project");
}
/// What the pre-comp layer itself adds to the look is a warning (#2918): an error listing it
/// without `force`, unpacked anyway with it.
#[test]
fn unprecompose_warns_about_what_the_precomp_layer_itself_adds() {
let fresh = unprecompose_scene;
let (mut s, _, _, pre) = fresh();
s.execute("prop.set", json!({"layer": pre, "path": "transform/opacity", "value": 50})).unwrap();
warned_then_forced(&mut s, pre, "Opacity");
let (mut s, _, _, pre) = fresh();
s.execute("prop.set", json!({"layer": pre, "path": "transform/rotation", "value": 10})).unwrap();
warned_then_forced(&mut s, pre, "Rotation");
let (mut s, _, _, pre) = fresh();
s.execute("prop.addKey", json!({"layer": pre, "path": "transform/scale", "time": 0, "value": [100, 100]})).unwrap();
warned_then_forced(&mut s, pre, "animated");
for switch in ["solo", "guide"] {
let (mut s, _, _, pre) = fresh();
s.execute("layer.setSwitch", json!({"layers": [pre], "switch": switch, "value": true})).unwrap();
warned_then_forced(&mut s, pre, "Solo or Guide");
}
for switch in ["video", "audio"] {
let (mut s, _, _, pre) = fresh();
s.execute("layer.setSwitch", json!({"layers": [pre], "switch": switch, "value": false})).unwrap();
warned_then_forced(&mut s, pre, "Video or Audio");
}
for switch in ["threeD", "collapse", "adjustment", "preserveTransparency"] {
let (mut s, _, _, pre) = fresh();
s.execute("layer.setSwitch", json!({"layers": [pre], "switch": switch, "value": true})).unwrap();
warned_then_forced(&mut s, pre, "3D, collapses");
}
let (mut s, _, _, pre) = fresh();
let c = id_named(&s, "C");
s.execute("layer.setParent", json!({"layers": [pre], "parent": c})).unwrap();
warned_then_forced(&mut s, pre, "has a parent");
let (mut s, _, _, pre) = fresh();
let c = id_named(&s, "C");
s.execute("layer.setTrackMatte", json!({"layer": pre, "matte": c})).unwrap();
warned_then_forced(&mut s, pre, "has a track matte");
let (mut s, _, _, pre) = fresh();
s.execute("effect.apply", json!({"layers": [pre], "effect": "Gaussian Blur"})).unwrap();
warned_then_forced(&mut s, pre, "effects");
let (mut s, _, _, pre) = fresh();
s.execute("layer.addMask", json!({"layer": pre})).unwrap();
warned_then_forced(&mut s, pre, "masks");
let (mut s, _, _, pre) = fresh();
s.execute("layer.style.add", json!({"layers": [pre], "style": "dropShadow"})).unwrap();
warned_then_forced(&mut s, pre, "layer styles");
let (mut s, _, _, pre) = fresh();
s.execute("layer.setBlendMode", json!({"layers": [pre], "mode": "Multiply"})).unwrap();
warned_then_forced(&mut s, pre, "blending mode");
let (mut s, _, _, pre) = fresh();
s.execute("layer.timeStretch", json!({"layers": [pre], "percent": 200.0})).unwrap();
warned_then_forced(&mut s, pre, "time-stretched");
let (mut s, _, _, pre) = fresh();
s.execute("layer.enableTimeRemap", json!({"layers": [pre], "value": true})).unwrap();
warned_then_forced(&mut s, pre, "time-remapped");
// A layer that is not a pre-comp, and a selection of several, are refused, even forced.
let (mut s, _, _, pre) = fresh();
let c = id_named(&s, "C");
assert!(s.execute("layer.unprecompose", json!({"layers": [c], "force": true})).is_err());
assert!(s.execute("layer.unprecompose", json!({"layers": [pre, c], "force": true})).is_err());
}
/// What only holds inside the nested comp (a camera or light, a soloed, 3D, adjustment or
/// Preserve Transparency layer) is a warning too.
#[test]
fn unprecompose_warns_about_what_only_holds_inside_the_nested_comp() {
let inside = |switch: &str, what: &str| {
let (mut s, outer, inner, pre) = unprecompose_scene();
s.execute("comp.open", json!({"comp": inner})).unwrap();
let a = id_named(&s, "A");
s.execute("layer.setSwitch", json!({"layers": [a], "switch": switch, "value": true})).unwrap();
s.execute("comp.open", json!({"comp": outer.0})).unwrap();
warned_then_forced(&mut s, pre, what);
};
inside("solo", "soloed layer");
inside("preserveTransparency", "Preserve Transparency");
inside("adjustment", "adjustment, 3D");
inside("threeD", "adjustment, 3D");
let (mut s, outer, inner, pre) = unprecompose_scene();
s.execute("comp.open", json!({"comp": inner})).unwrap();
s.execute("layer.newLight", json!({})).unwrap();
s.execute("comp.open", json!({"comp": outer.0})).unwrap();
warned_then_forced(&mut s, pre, "camera or light");
}
/// A locked pre-comp layer is the one hard stop: refused with and without `force`, and by the dry
/// run, changing nothing.
#[test]
fn unprecompose_refuses_a_locked_layer_even_forced() {
let (mut s, _, _, pre) = unprecompose_scene();
s.execute("prop.set", json!({"layer": pre, "path": "transform/opacity", "value": 50})).unwrap();
s.execute("layer.setSwitch", json!({"layers": [pre], "switch": "lock", "value": true})).unwrap();
let undo = s.history.undo.len();
let before = whole_project(&s);
for p in [json!({"layers": [pre]}), json!({"layers": [pre], "force": true}), json!({"layers": [pre], "dryRun": true})] {
let e = s.execute("layer.unprecompose", p.clone()).expect_err("locked").to_string();
assert!(e.contains("locked"), "{p}: {e}");
assert!(whole_project(&s) == before, "{p}: nothing changed");
assert_eq!(s.history.undo.len(), undo, "{p}: no undo step");
}
// A clean, locked pre-comp layer is refused too.
let (mut s, _, _, pre) = unprecompose_scene();
s.execute("layer.setSwitch", json!({"layers": [pre], "switch": "lock", "value": true})).unwrap();
assert!(s.execute("layer.unprecompose", json!({"layers": [pre]})).unwrap_err().to_string().contains("locked"));
}
/// Layers that use the pre-comp layer as parent, track matte or in a property lose that link when
/// it is unpacked by force; links to other layers stay, and nothing dangles or crashes.
#[test]
fn unprecompose_forced_drops_the_links_other_layers_had_to_it() {
let (mut s, outer, _, pre) = unprecompose_scene();
let c = id_named(&s, "C");
let d = solid(&mut s, "#ffff00");
s.execute("layer.rename", json!({"layers": [d], "name": "D"})).unwrap();
s.execute("layer.setParent", json!({"layers": [c], "parent": pre})).unwrap();
s.execute("layer.setTrackMatte", json!({"layer": d, "matte": pre})).unwrap();
s.execute("effect.apply", json!({"layers": [c], "effect": "Set Matte"})).unwrap();
s.execute("effect.apply", json!({"layers": [d], "effect": "Set Matte"})).unwrap();
{
let proj = std::sync::Arc::make_mut(&mut s.project);
let comp = proj.comp_mut(outer).unwrap();
let set = |comp: &mut effectcraft_project::Comp, on: u64, to: u64| {
let l = comp.layer_mut(effectcraft_project::LayerId(on)).unwrap();
l.props.walk_mut(&mut |p| {
if matches!(p.value, KV::Layer(_)) {
p.value = KV::Layer(Some(to));
}
});
};
set(comp, c, pre);
set(comp, d, c);
}
let refs = |s: &Session, on: u64| {
let mut out = vec![];
layer(s, on).props.walk("", &mut |_, p| {
if let KV::Layer(v) = p.value {
out.push(v);
}
});
out
};
assert_eq!(refs(&s, c), vec![Some(pre)], "the test set C's reference");
let e = s.execute("layer.unprecompose", json!({"layers": [pre]})).expect_err("used by others").to_string();
assert!(e.contains("parent or track matte") && e.contains("refers to it"), "{e}");
let undo = s.history.undo.len();
let before = whole_project(&s);
let r = s.execute("layer.unprecompose", json!({"layers": [pre], "force": true})).unwrap();
let w = r["warnings"].to_string();
assert!(w.contains("link is removed") && w.contains("reference is cleared"), "{w}");
assert_eq!(layer(&s, c).parent, None, "C lost its parent");
assert!(layer(&s, d).track_matte.is_none(), "D lost its matte");
assert_eq!(refs(&s, c), vec![None], "C's reference to the pre-comp layer is cleared");
assert_eq!(refs(&s, d), vec![Some(c)], "D's reference to C stays");
scene_look(&mut s);
s.execute("edit.undo", json!({})).unwrap();
assert!(whole_project(&s) == before, "undo restores the links");
assert_eq!(s.history.undo.len(), undo);
}
/// Expressions of other layers that name the pre-comp layer (in quotes, or by its index) would no
/// longer find it: one warning per property; an expression naming another layer is not one. Forced,
/// the expressions stay as they are and the comp still renders.
#[test]
fn unprecompose_warns_about_expressions_that_refer_to_it() {
let (mut s, _, _, pre) = unprecompose_scene();
let c = id_named(&s, "C");
let d = solid(&mut s, "#ffff00");
s.execute("layer.rename", json!({"layers": [d], "name": "D"})).unwrap();
let index = s.active_comp().unwrap().layers.iter().position(|l| l.id.0 == pre).unwrap() + 1;
let set = |s: &mut Session, on: u64, path: &str, text: &str| {
s.execute("prop.setExpression", json!({"layer": on, "path": path, "expression": text})).unwrap();
};
set(&mut s, c, "transform/position", "thisComp.layer(\"Inner\").transform.position");
set(&mut s, c, "transform/rotation", &format!("thisComp.layer( {index} ).transform.rotation"));
// Naming another layer, or another comp's layer at the same index, is no reference to it.
set(&mut s, d, "transform/position", "thisComp.layer(\"C\").transform.position");
set(&mut s, d, "transform/rotation", &format!("comp(\"Other\").layer({index}).transform.rotation"));
let dry = s.execute("layer.unprecompose", json!({"layers": [pre], "dryRun": true})).unwrap();
let exprs: Vec<String> =
dry["warnings"].as_array().unwrap().iter().filter_map(|w| w.as_str()).filter(|w| w.contains("expression")).map(str::to_string).collect();
assert_eq!(exprs.len(), 2, "{exprs:?}");
assert!(exprs.iter().all(|w| w.contains("layer “C”") && w.contains("“Inner”") && w.contains("no longer find it")), "{exprs:?}");
assert!(exprs.iter().any(|w| w.contains("Position")) && exprs.iter().any(|w| w.contains("Rotation")), "{exprs:?}");
warned_then_forced(&mut s, pre, "has an expression that refers to “Inner”");
// Forced, the expressions are left as written.
s.execute("layer.unprecompose", json!({"layers": [pre], "force": true})).unwrap();
let text = layer(&s, c).transform().unwrap().get("position").unwrap().expr.clone().unwrap().text;
assert_eq!(text, "thisComp.layer(\"Inner\").transform.position");
scene_look(&mut s);
}
/// An expression naming a layer by a number below the pre-comp layer points at another layer once
/// the nested comp's two layers replace it: a warning. One naming a number above it does not move.
#[test]
fn unprecompose_warns_when_it_renumbers_the_layers_an_expression_names() {
let (mut s, _, _, pre) = unprecompose_scene();
let c = id_named(&s, "C");
let index = s.active_comp().unwrap().layers.iter().position(|l| l.id.0 == pre).unwrap() + 1;
let warned = |s: &mut Session, number: usize| {
let text = format!("thisComp.layer({number}).transform.rotation");
s.execute("prop.setExpression", json!({"layer": c, "path": "transform/rotation", "expression": text})).unwrap();
let dry = s.execute("layer.unprecompose", json!({"layers": [pre], "dryRun": true})).unwrap();
dry["warnings"].as_array().unwrap().iter().filter_map(|w| w.as_str()).any(|w| w.contains("by number"))
};
assert!(warned(&mut s, index + 1), "a number below it");
assert!(!warned(&mut s, index - 1), "a number above it");
}
/// A nested comp that preserves its own frame rate (10 fps in a 30 fps comp) samples its layers on
/// its own frames; expanded, they would change on every frame of the parent: a warning. Without
/// Preserve Frame Rate the nested layers are sampled at the parent's times already: no warning.
#[test]
fn unprecompose_warns_when_the_nested_comp_preserves_another_frame_rate() {
let (mut s, _, inner, pre) = unprecompose_scene();
s.execute("comp.settings", json!({"comp": inner, "frameRate": 10})).unwrap();
let dry = s.execute("layer.unprecompose", json!({"layers": [pre], "dryRun": true})).unwrap();
assert_eq!(dry["warnings"], json!([]), "a different rate alone samples at the parent's times");
s.execute("comp.settings", json!({"comp": inner, "preserveFrameRate": true})).unwrap();
warned_then_forced(&mut s, pre, "preserves a frame rate");
// A motion-blurred nested layer under another shutter would be blurred with the parent's.
let (mut s, outer, inner, pre) = unprecompose_scene();
s.execute("comp.settings", json!({"comp": inner, "shutterAngle": 90})).unwrap();
let dry = s.execute("layer.unprecompose", json!({"layers": [pre], "dryRun": true})).unwrap();
assert_eq!(dry["warnings"], json!([]), "no layer uses motion blur");
s.execute("comp.open", json!({"comp": inner})).unwrap();
let a = id_named(&s, "A");
s.execute("layer.setSwitch", json!({"layers": [a], "switch": "motionBlur", "value": true})).unwrap();
s.execute("comp.open", json!({"comp": outer.0})).unwrap();
warned_then_forced(&mut s, pre, "motion blur shutter");
}
/// The pre-comp layer's move (Position against Anchor Point) is added to the stored Position of
/// unparented nested layers; one whose Position has an expression may ignore it: a warning, only
/// when there is a move.
#[test]
fn unprecompose_warns_about_position_expressions_it_cannot_move() {
let (mut s, outer, inner, pre) = unprecompose_scene();
s.execute("comp.open", json!({"comp": inner})).unwrap();
let a = id_named(&s, "A");
s.execute("prop.setExpression", json!({"layer": a, "path": "transform/position", "expression": "[100, 100]"})).unwrap();
s.execute("comp.open", json!({"comp": outer.0})).unwrap();
let dry = s.execute("layer.unprecompose", json!({"layers": [pre], "dryRun": true})).unwrap();
assert_eq!(dry["warnings"], json!([]), "no move, nothing to lose");
s.execute("prop.set", json!({"layer": pre, "path": "transform/position", "value": [360, 160]})).unwrap();
warned_then_forced(&mut s, pre, "layer “A” has a Position expression");
}
/// Expanded copies keep pointing at each other: a track matte and a layer-valued effect property
/// on a nested layer follow the copy of the layer they named, and the nested comp's markers are
/// moved by the pre-comp layer's Start and merged in time order with the comp's own.
#[test]
fn unprecompose_remaps_mattes_layer_values_and_merges_markers_in_order() {
let (mut s, outer, inner, pre) = unprecompose_scene();
s.execute("comp.open", json!({"comp": inner})).unwrap();
let (a, b) = (id_named(&s, "A"), id_named(&s, "B"));
s.execute("layer.setParent", json!({"layers": [b], "parent": null})).unwrap();
s.execute("layer.setTrackMatte", json!({"layer": b, "matte": a})).unwrap();
s.execute("effect.apply", json!({"layers": [b], "effect": "Set Matte"})).unwrap();
{
let proj = std::sync::Arc::make_mut(&mut s.project);
let l = proj.comp_mut(effectcraft_project::ItemId(inner)).unwrap().layer_mut(effectcraft_project::LayerId(b)).unwrap();
l.props.walk_mut(&mut |p| {
if matches!(p.value, KV::Layer(_)) {
p.value = KV::Layer(Some(a));
}
});
}
for t in [2.5, 0.5] {
s.execute("comp.addMarker", json!({"time": t, "comment": format!("in {t}")})).unwrap();
}
s.execute("comp.open", json!({"comp": outer.0})).unwrap();
s.execute("comp.addMarker", json!({"time": 2.0, "comment": "out"})).unwrap();
s.execute("layer.timing", json!({"layers": [pre], "op": "set", "start": 1.0})).unwrap();
s.execute("layer.unprecompose", json!({"layers": [pre]})).unwrap();
let (a, b) = (layer(&s, id_named(&s, "A")), layer(&s, id_named(&s, "B")));
assert_eq!(b.track_matte.map(|m| m.layer), Some(a.id), "B's matte is the copy of A");
let mut refs = vec![];
b.props.walk("", &mut |_, p| {
if let KV::Layer(v) = p.value {
refs.push(v);
}
});
assert_eq!(refs, vec![Some(a.id.0)], "Set Matte names the copy of A");
let markers: Vec<(f64, String)> = s.active_comp().unwrap().markers.iter().map(|m| (m.time.seconds(), m.comment.clone())).collect();
let want = [(1.5, "in 0.5"), (2.0, "out"), (3.5, "in 2.5")];
assert_eq!(markers.len(), 3, "{markers:?}");
for ((t, c), (wt, wc)) in markers.iter().zip(want) {
assert!((t - wt).abs() < 1e-9 && c == wc, "{markers:?}");
}
scene_look(&mut s);
}
/// Every reason at once, forced: unpacked without a crash, one undo step, undone exactly.
#[test]
fn unprecompose_forced_with_everything_at_once() {
let (mut s, outer, inner, pre) = unprecompose_scene();
let c = id_named(&s, "C");
s.execute("comp.open", json!({"comp": inner})).unwrap();
let a = id_named(&s, "A");
for sw in ["solo", "threeD", "adjustment", "preserveTransparency"] {
s.execute("layer.setSwitch", json!({"layers": [a], "switch": sw, "value": true})).unwrap();
}
s.execute("layer.newCamera", json!({})).unwrap();
s.execute("comp.open", json!({"comp": outer.0})).unwrap();
s.execute("prop.set", json!({"layer": pre, "path": "transform/opacity", "value": 20})).unwrap();
s.execute("prop.addKey", json!({"layer": pre, "path": "transform/rotation", "time": 0, "value": 30})).unwrap();
s.execute("effect.apply", json!({"layers": [pre], "effect": "Gaussian Blur"})).unwrap();
s.execute("layer.addMask", json!({"layer": pre})).unwrap();
s.execute("layer.style.add", json!({"layers": [pre], "style": "dropShadow"})).unwrap();
s.execute("layer.setBlendMode", json!({"layers": [pre], "mode": "Screen"})).unwrap();
s.execute("layer.enableTimeRemap", json!({"layers": [pre], "value": true})).unwrap();
s.execute("layer.timeStretch", json!({"layers": [pre], "percent": -50.0})).unwrap();
for sw in ["solo", "guide", "threeD", "collapse", "adjustment", "preserveTransparency"] {
s.execute("layer.setSwitch", json!({"layers": [pre], "switch": sw, "value": true})).unwrap();
}
s.execute("layer.setSwitch", json!({"layers": [pre], "switch": "video", "value": false})).unwrap();
s.execute("layer.setParent", json!({"layers": [pre], "parent": c})).unwrap();
s.execute("layer.setTrackMatte", json!({"layer": pre, "matte": c})).unwrap();
let undo = s.history.undo.len();
let before = whole_project(&s);
let r = s.execute("layer.unprecompose", json!({"layers": [pre], "force": true})).unwrap();
assert!(r["warnings"].as_array().unwrap().len() >= 10, "{r}");
// The undo history is capped (32 levels by default), so its length may stay; one undo is what
// takes the whole unpack back.
assert!(s.history.undo.len() == undo + 1 || s.history.undo.len() == undo, "one undo step");
assert!(whole_project(&s) != before, "it unpacked");
scene_look(&mut s);
assert!(s.history.redo.is_empty());
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.history.redo.len(), 1, "one undo step took it all back");
assert!(whole_project(&s) == before, "undo restores the exact project");
}
/// Quick Set Anchor (#2967): every one of the nine points moves the Anchor Point of a rotated,
/// scaled layer with keyed Position to that point of its content, and the layer looks the same
/// on every frame (Position and its keys are compensated), in one undo step.