mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-07 02:51:24 +00:00
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:
11 files changed
+1009
-1
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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}))
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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")),
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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, ¶ms) {
|
||||
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, ¶ms)? {
|
||||
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, ¶ms)? {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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(¶ms));
|
||||
}
|
||||
}
|
||||
|
||||
/// `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));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user