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.
+1
View File
@@ -10290,6 +10290,7 @@ mod tests {
("panels/comp_settings.rs", include_str!("../panels/comp_settings.rs")),
("panels/content_fill_panel.rs", include_str!("../panels/content_fill_panel.rs")),
("panels/delete_items.rs", include_str!("../panels/delete_items.rs")),
("panels/unprecompose.rs", include_str!("../panels/unprecompose.rs")),
("panels/gradient_editor.rs", include_str!("../panels/gradient_editor.rs")),
("panels/dialogs.rs", include_str!("../panels/dialogs.rs")),
("panels/dialogs_3d.rs", include_str!("../panels/dialogs_3d.rs")),
+2
View File
@@ -156,6 +156,8 @@ pub enum Dialog {
/// Import an After Effects project although some of it cannot be imported exactly?
/// (`panels::aep_losses`).
AepLosses,
/// Un-precompose although it changes the picture or drops something? (`panels::unprecompose`).
Unprecompose,
/// A gradient property's Gradient Editor (`panels::gradient_editor`, `prop.editGradient`).
GradientEditor,
/// Delete a user template from Home? (`panels::templates`).
+4
View File
@@ -284,6 +284,10 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V
if crate::panels::delete_items::guard(app, id, &params) {
return Ok(json!({"dialog": "deleteItems"}));
}
// Un-precompose that would change the picture lists why and asks before unpacking anyway.
if crate::panels::unprecompose::guard(app, id, &params)? {
return Ok(json!({"dialog": "unprecompose"}));
}
// An After Effects import with a path that loses something asks first (the menu's file
// dialog asks in `run_with_picked`).
if params.get("path").is_some() && crate::panels::aep_losses::guard(app, id, &params)? {
+1
View File
@@ -56,6 +56,7 @@ pub mod text_panels;
pub mod text_range_handles;
pub mod timeline;
pub mod tracker;
pub mod unprecompose;
pub mod unsaved;
pub mod viewer;
pub mod viewer_drop;
+3
View File
@@ -71,6 +71,8 @@ pub struct DialogState {
/// Deleting Project items in use: the deletion waiting on the prompt.
pub delete_items: super::delete_items::Pending,
pub aep_losses: super::aep_losses::Pending,
/// Un-precompose with reasons: the unpack waiting on the prompt.
pub unprecompose: super::unprecompose::Pending,
/// The Gradient Editor's draft and the property it edits.
pub gradient: super::gradient_editor::Draft,
/// Preview of the settings dialog being edited (Solid, Camera, Light, Composition Settings).
@@ -323,6 +325,7 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context) {
Dialog::UnsavedChanges => super::unsaved::show(app, ctx, &t),
Dialog::DeleteItems => super::delete_items::show(app, ctx, &t),
Dialog::AepLosses => super::aep_losses::show(app, ctx, &t),
Dialog::Unprecompose => super::unprecompose::show(app, ctx, &t),
Dialog::GradientEditor => super::gradient_editor::show(app, ctx, &t),
Dialog::DeleteTemplate => super::templates::show_delete(app, ctx, &t),
}
+181
View File
@@ -0,0 +1,181 @@
//! Layer ▸ Un-precompose from the menu: when expanding the pre-comp layer would change the picture
//! or drop something (the command's dry run lists why), the reasons are shown first and the user
//! chooses to cancel or to unpack anyway (`force`). Cancel changes nothing; a pre-comp layer
//! without reasons is unpacked at once, and one that cannot be unpacked at all (locked, not a
//! pre-comp) reports its error without the prompt. Agents running `layer.unprecompose` with
//! `engine.execute` are never asked: they pass `force` or get the error listing the reasons.
//!
//! Automation ids: `dialog.unprecompose.force`, `dialog.unprecompose.cancel`.
use crate::i18n::tr;
use egui::vec2;
use serde_json::{Value, json};
use crate::theme::Tokens;
use crate::{Dialog, EffectcraftApp};
const COMMAND: &str = "layer.unprecompose";
/// The unpack waiting on the prompt: its params and the dry run's reasons.
#[derive(Clone, Debug, Default)]
pub struct Pending {
pub command: Option<(Value, Vec<String>)>,
}
/// Whether command `id` with `params` needs the prompt first. When it does, the prompt opens
/// holding the command and the caller must not run it. `Err` when the dry run fails (a locked
/// layer, a layer that is not a pre-comp, several layers), with the message.
pub fn guard(app: &mut EffectcraftApp, id: &str, params: &Value) -> Result<bool, String> {
if id != COMMAND || params.get("force") == Some(&Value::Bool(true)) || params.get("dryRun") == Some(&Value::Bool(true)) {
return Ok(false);
}
let mut dry = params.as_object().cloned().unwrap_or_default();
dry.insert("dryRun".into(), Value::Bool(true));
let r = app.session.execute(COMMAND, Value::Object(dry)).map_err(|e| e.to_string())?;
let warnings: Vec<String> = r["warnings"].as_array().map(|w| w.iter().filter_map(Value::as_str).map(str::to_string).collect()).unwrap_or_default();
if warnings.is_empty() {
return Ok(false);
}
// The layer the warnings are about, whatever is selected by the time the prompt is answered.
let mut pinned = params.as_object().cloned().unwrap_or_default();
if !pinned.contains_key("layer") && !pinned.contains_key("layers") {
pinned.insert("layers".into(), json!(app.session.state.selected_layers.iter().map(|l| l.0).collect::<Vec<_>>()));
}
if let Some(comp) = app.session.active_comp_id().filter(|_| !pinned.contains_key("comp")) {
pinned.insert("comp".into(), json!(comp.0));
}
app.dialog_state.unprecompose.command = Some((Value::Object(pinned), warnings));
app.dialog = Some(Dialog::Unprecompose);
Ok(true)
}
pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
let Some((_, warnings)) = app.dialog_state.unprecompose.command.clone() else {
app.dialog = None;
return;
};
let mut answer: Option<bool> = None;
super::dialogs::modal_vertical(ctx, "Un-precompose", vec2(520.0, 300.0), t, |ui| {
ui.label(egui::RichText::new(tr("Unpacking this pre-comp layer changes the picture or drops something:")).color(t.tab_text_active));
ui.add_space(6.0);
egui::ScrollArea::vertical().max_height(160.0).show(ui, |ui| {
for w in &warnings {
ui.label(egui::RichText::new(format!("• {w}")).color(t.tab_text_active));
}
});
ui.add_space(4.0);
ui.label(egui::RichText::new(tr("What the pre-comp layer itself carries is dropped. You can undo this.")).color(t.text_dim).size(11.0));
ui.add_space(14.0);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let label = tr("Unpack Anyway");
let r = ui.button(label);
app.auto.add("dialog.unprecompose.force", r.rect, label);
if r.clicked() {
answer = Some(true);
}
let r = ui.button(tr("Cancel"));
app.auto.add("dialog.unprecompose.cancel", r.rect, "Cancel");
if r.clicked() {
answer = Some(false);
}
});
});
if answer.is_none() && ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
answer = Some(false);
}
if let Some(go) = answer {
finish(app, ctx, go);
}
}
/// The prompt's answer: Cancel (`false`) drops the unpack, Unpack Anyway runs it with `force`.
fn finish(app: &mut EffectcraftApp, ctx: &egui::Context, go: bool) {
app.dialog = None;
let Some((params, _)) = app.dialog_state.unprecompose.command.take() else { return };
if go {
// An error is already in the status bar.
let _ = crate::menus::run_engine(app, ctx, COMMAND, forced(&params));
}
}
/// `params` with the user's consent to the reasons the prompt listed.
fn forced(params: &Value) -> Value {
let mut p = params.as_object().cloned().unwrap_or_default();
p.insert("force".into(), json!(true));
Value::Object(p)
}
#[cfg(test)]
mod tests {
use super::*;
use effectcraft_engine::Session;
/// A comp with two solids nested into a pre-comp; the pre-comp layer's id.
fn scene(app: &mut EffectcraftApp) -> u64 {
let s = &mut app.session;
s.execute("comp.new", json!({"name": "Main", "width": 640, "height": 360, "duration": 4.0, "fps": 24.0})).unwrap();
let a = s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap()["layer"].as_u64().unwrap();
let b = s.execute("layer.newSolid", json!({"color": "#00ff00"})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.precompose", json!({"layers": [a, b], "name": "Inner"})).unwrap()["layer"].as_u64().unwrap()
}
fn layer_count(s: &Session) -> usize {
s.active_comp().map_or(0, |c| c.layers.len())
}
fn fresh() -> (egui::Context, EffectcraftApp) {
let ctx = egui::Context::default();
let app = EffectcraftApp::new(Session::default());
crate::theme::install(&ctx, &app.tokens, crate::i18n::language(&app));
(ctx, app)
}
#[test]
fn the_menu_asks_and_unpack_anyway_unpacks() {
let (ctx, mut app) = fresh();
let pre = scene(&mut app);
app.session.execute("prop.set", json!({"layer": pre, "path": "transform/opacity", "value": 50})).unwrap();
let params = json!({"layers": [pre]});
let r = crate::menus::invoke(&mut app, &ctx, COMMAND, params.clone()).unwrap();
assert_eq!(r, json!({"dialog": "unprecompose"}));
assert_eq!(app.dialog, Some(Dialog::Unprecompose));
let (_, warnings) = app.dialog_state.unprecompose.command.clone().unwrap();
assert!(warnings.iter().any(|w| w.contains("Opacity")), "{warnings:?}");
// Drawn, the prompt offers both answers under their automation ids.
crate::panels::dialogs::press_key(&mut app, &ctx, egui::Key::A, egui::Modifiers::NONE);
assert!(app.auto.find("dialog.unprecompose.force").is_some() && app.auto.find("dialog.unprecompose.cancel").is_some());
// Escape is Cancel: nothing changes.
crate::panels::dialogs::press_key(&mut app, &ctx, egui::Key::Escape, egui::Modifiers::NONE);
assert!(app.dialog.is_none() && app.dialog_state.unprecompose.command.is_none());
assert_eq!(layer_count(&app.session), 1, "Cancel left the pre-comp layer");
// Unpack Anyway unpacks.
crate::menus::invoke(&mut app, &ctx, COMMAND, params).unwrap();
assert_eq!(app.dialog, Some(Dialog::Unprecompose));
finish(&mut app, &ctx, true);
assert!(app.dialog.is_none());
assert_eq!(layer_count(&app.session), 2, "the two solids are back");
}
#[test]
fn a_clean_precomp_unpacks_without_asking_and_a_locked_one_is_an_error() {
let (ctx, mut app) = fresh();
let pre = scene(&mut app);
assert_eq!(guard(&mut app, COMMAND, &json!({"layers": [pre]})), Ok(false));
crate::menus::invoke(&mut app, &ctx, COMMAND, json!({"layers": [pre]})).unwrap();
assert!(app.dialog.is_none());
assert_eq!(layer_count(&app.session), 2);
let (_, mut app) = fresh();
let pre = scene(&mut app);
app.session.execute("prop.set", json!({"layer": pre, "path": "transform/opacity", "value": 50})).unwrap();
app.session.execute("layer.setSwitch", json!({"layers": [pre], "switch": "lock", "value": true})).unwrap();
let e = guard(&mut app, COMMAND, &json!({"layers": [pre]})).unwrap_err();
assert!(e.contains("locked"), "{e}");
assert!(app.dialog.is_none());
assert_eq!(layer_count(&app.session), 1);
// Consented and dry-run calls, and other commands, are not asked.
assert_eq!(guard(&mut app, COMMAND, &json!({"layers": [pre], "force": true})), Ok(false));
assert_eq!(guard(&mut app, COMMAND, &json!({"layers": [pre], "dryRun": true})), Ok(false));
assert_eq!(guard(&mut app, "layer.precompose", &json!({})), Ok(false));
}
}
+24
View File
@@ -261,6 +261,30 @@ build real projects through these interfaces; they are worked examples of everyt
`layer.centerAnchor` skip them and fail when every target is locked ("… is locked"); Select
All and `layer.selectNext` / `selectPrevious` step over them. Unlock with
`layer.setSwitch {"layers": [...], "switch": "lock", "value": false}` or `layer.unlockAll`.
* **Un-precompose**: `layer.unprecompose {layer?, force?, dryRun?}` (Layer ▸ Un-precompose) expands the
selected pre-comp layer into copies of its composition's layers at its place (order, keyframes,
effects, masks, parenting, mattes, markers kept; timing moved by the layer's Start and trimmed to
what it showed; Position minus Anchor Point added to the unparented ones). One undo step; the
nested comp stays in the project; the nested comp's frame no longer clips what its layers draw
outside it. Only a **locked** pre-comp layer is refused (with or without `force`), changing
nothing. Everything that would change the picture or drop something is a warning: the pre-comp
layer has a parent or matte, effects, masks, layer styles, a non-default Scale / Rotation /
Opacity, an animated transform, time remap or stretch, a blending mode, Video or Audio off, Solo
or Guide on, the 3D / collapse / adjustment switch or Preserve Transparency, or is used by another
layer (parent, matte or a layer property), or an expression of another layer names it (by name in
quotes or as `layer(<index>)`; listed per property, the expression is left as written and will
no longer find it); or the nested comp holds a camera, light, adjustment,
3D, collapsing or soloed layer, or a layer with a blending mode or Preserve Transparency, preserves
a frame rate other than the comp's, has a motion-blur shutter other than the comp's (with motion
blur layers), or has an unparented layer with a Position expression while the pre-comp layer's
Position differs from its Anchor Point (the move is added to stored values only). Without
`force` such a call is an error listing every reason and nothing changes; `dryRun: true` returns
`{dryRun, warnings}` without changing anything. With `force: true` it unpacks anyway and returns
`{layers, warnings}`: what the pre-comp layer itself carried (effects, masks, styles, transform
beyond Position, time remap, blending mode, switches, its parent and matte) is dropped, and other
layers' parent, track matte and layer-property links to it are removed. The menu shows the
reasons and asks (Unpack Anyway / Cancel, `dialog.unprecompose.force` /
`dialog.unprecompose.cancel`); a layer without reasons unpacks without asking.
* **Quick Set Anchor**: `layer.quickSetAnchor {point: topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight, layers?}`
(Layer ▸ Transform ▸ Quick Set Anchor) moves the Anchor Point of 2D layers to that point of their
content and shifts Position and its keys so the layers don't move, in one undo step. 3D and