Merge #1929: Layer: replace a layer's source with Alt-drag and Ctrl+Alt+/ (#991) (@ki2pixel)

This commit is contained in:
aiongg
2026-10-23 21:26:51 +00:00
8 changed files with 421 additions and 5 deletions
+108 -2
View File
@@ -4,8 +4,8 @@ use effectcraft_color::{BlendMode, Label};
use effectcraft_keyframe::{Justify, TextDoc, Value as KV};
use effectcraft_project::build::{self, Ids};
use effectcraft_project::{
Comp, FrameBlend, GroupKind, ItemId, ItemKind, Layer, LayerId, LayerSource, MaskMode, MatteKind, Node, Project, PropGroup, Quality, Solid, TrackMatte,
limits,
Comp, FootageKind, FrameBlend, GroupKind, ItemId, ItemKind, Layer, LayerId, LayerSource, MaskMode, MatteKind, Node, Project, PropGroup, Quality, Solid,
TrackMatte, limits,
};
use effectcraft_time::Tick;
use serde_json::{Value, json};
@@ -307,6 +307,103 @@ fn add_item(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"layer": id.0}))
}
/// A layer whose source is an item (footage, precomposition or solid), as in After Effects'
/// Replace Source. Text, shape, null, camera, light and 3D model layers have no AVItem source to
/// replace.
fn replaceable_layer(l: &Layer) -> bool {
matches!(l.source, LayerSource::Footage { .. } | LayerSource::Comp { .. } | LayerSource::Solid { .. })
}
/// The source a Project item gives a layer, or why it can't replace one.
fn replacement_source(item: ItemId, it: &effectcraft_project::Item) -> Result<LayerSource> {
const CMD: &str = "layer.replaceSource";
match &it.kind {
ItemKind::Comp(_) => Ok(LayerSource::Comp { item }),
ItemKind::Footage(f) if f.kind == FootageKind::Data => {
Err(bad(CMD, "data files can't be layer sources; read them in expressions with footage(\"name\").sourceData"))
}
ItemKind::Footage(f) if f.kind == FootageKind::Model => Err(bad(CMD, "3D model items can't replace a 2D layer source; add them as 3D layers")),
ItemKind::Footage(_) => Ok(LayerSource::Footage { item }),
ItemKind::Solid(_) => Ok(LayerSource::Solid { item }),
ItemKind::Folder => Err(bad(CMD, "folders can't be layer sources")),
}
}
/// Whether a source brings audio properties (precompositions always can; footage when it has an
/// audio stream).
fn source_has_audio(project: &Project, source: &LayerSource) -> bool {
match source {
LayerSource::Comp { .. } => true,
LayerSource::Footage { item } => project.item(*item).is_some_and(|i| matches!(&i.kind, ItemKind::Footage(f) if f.has_audio)),
_ => false,
}
}
fn has_replace_source(s: &Session) -> std::result::Result<(), String> {
has_comp(s)?;
let c = s.active_comp().ok_or("no composition")?;
if !s.state.selected_layers.iter().filter_map(|l| c.layer(*l)).any(replaceable_layer) {
return Err("select a footage, composition or solid layer".into());
}
if s.state.project_selection.is_empty() {
return Err("select a footage item, a composition or a solid in the Project panel".into());
}
Ok(())
}
/// Replace the source of the selected (or `layers`) AV layers with a Project item, as After
/// Effects' Alt-drag and Ctrl+Alt+/ do. The layer keeps every property: name, timing, transforms,
/// keyframes, expressions, effects, masks, switches, parenting and track matte. Only its source
/// changes; precompositions bring their Essential Properties group through `sync_project`, and a
/// source with audio adds an Audio group when the layer has none.
fn replace_source(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "layer.replaceSource";
let (cid, ids) = layers_p(s, p)?;
let ids = unlocked(s, cid, ids, CMD)?;
if ids.is_empty() {
return Err(bad(CMD, "select a layer first"));
}
let item = match p.get("item") {
Some(Value::Number(n)) => ItemId(n.as_u64().ok_or_else(|| bad(CMD, "item: a project item id"))?),
Some(Value::String(name)) => s.project.find_by_name(name).map(|i| i.id).ok_or_else(|| bad(CMD, format!("no item `{name}`")))?,
_ => *s.state.project_selection.first().ok_or_else(|| bad(CMD, "select a footage item, a composition or a solid in the Project panel"))?,
};
let it = s.project.item(item).ok_or_else(|| bad(CMD, format!("no item {}", item.0)))?;
let source = replacement_source(item, it)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
for lid in &ids {
let l = comp.layer(*lid).ok_or_else(|| bad(CMD, "no such layer"))?;
if !replaceable_layer(l) {
return Err(bad(CMD, format!("“{}” has no source item to replace", l.name)));
}
}
if let LayerSource::Comp { item: new } = &source
&& s.project.comp_contains(*new, cid)
{
return Err(bad(CMD, "a composition can't contain itself"));
}
let has_audio = source_has_audio(&s.project, &source);
let source_for_edit = source.clone();
s.edit("Replace Layer Source", None, |proj, _| {
for lid in &ids {
let needs_audio = has_audio && proj.comp(cid).and_then(|c| c.layer(*lid)).is_some_and(|l| l.props.sub("audio").is_none());
let audio = if needs_audio {
let mut next = Ids(&mut proj.next_id);
Some(build::audio(&mut next))
} else {
None
};
let l = layer_mut(proj, cid, *lid)?;
l.source = source_for_edit.clone();
if let Some(audio) = audio {
l.props.children.push(audio.into());
}
}
Ok(())
})?;
Ok(json!({"layers": ids.iter().map(|l| l.0).collect::<Vec<_>>(), "item": item.0}))
}
fn select(s: &mut Session, p: &Value) -> Result<Value> {
let cid = comp_id(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
@@ -1630,6 +1727,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_comp,
add_item
),
cmd!(
"layer.replaceSource",
"Replace Layer Source",
[],
Some("Cmd+Alt+/"),
"{comp?, layers?, layer?, item?: id|name (default the Project panel selection), fixExpressions?: bool (accepted for After Effects compatibility)} → {layers, item}",
has_replace_source,
replace_source
),
cmd!("layer.select", "Select Layers", [], None, "{layers: [id|name|#n], add?, toggle?}", has_comp, select),
cmd!("layer.selectNext", "Select Next Layer", [], Some("Cmd+ArrowDown"), "{add?}", has_comp, select_next),
cmd!("layer.selectPrevious", "Select Previous Layer", [], Some("Cmd+ArrowUp"), "{add?}", has_comp, select_prev),
+2
View File
@@ -1290,6 +1290,8 @@ mod tests_proxy;
#[cfg(test)]
mod tests_quick_offset;
#[cfg(test)]
mod tests_replace_source;
#[cfg(test)]
mod tests_rig3d;
#[cfg(test)]
mod tests_sequence;
+158
View File
@@ -0,0 +1,158 @@
//! Layer ▸ Replace Source (#991, and the `replaceSource` part of #813): Alt-drag and Ctrl+Alt+/
//! replace an AV layer's source while every other layer property stays.
use std::sync::Arc;
use effectcraft_color::Label;
use effectcraft_project::{Footage, FootageKind, ItemId, ItemKind, LayerSource, Solid};
use serde_json::json;
use crate::Session;
fn comp() -> Session {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Main", "width": 640, "height": 360, "frameRate": 30, "duration": 10})).unwrap();
s
}
fn layer(s: &Session, id: u64) -> effectcraft_project::Layer {
s.active_comp().unwrap().layer(effectcraft_project::LayerId(id)).unwrap().clone()
}
fn solid(s: &mut Session, name: &str) -> u64 {
s.execute("layer.newSolid", json!({"name": name, "color": "#ff0000", "width": 200, "height": 100})).unwrap()["layer"].as_u64().unwrap()
}
fn add_footage(s: &mut Session, name: &str, kind: FootageKind, width: u32, height: u32, has_audio: bool) -> ItemId {
let f = Footage { path: format!("/tmp/{name}"), kind, width, height, has_video: kind != FootageKind::Data, has_audio, ..Default::default() };
Arc::make_mut(&mut s.project).add_item(name, Label::None, None, ItemKind::Footage(f))
}
/// The replacement changes `source` and nothing else: name, label, comment, timing, stretch,
/// switches, blending, parenting, track matte and the whole property tree (keys, expression,
/// effect, mask) stay.
#[test]
fn replace_source_keeps_every_layer_property() {
let mut s = comp();
let a = solid(&mut s, "Hero");
let parent = solid(&mut s, "Parent");
let matte = solid(&mut s, "Matte");
s.execute("layer.timing", json!({"layers": [a], "in": 1.0, "out": 5.0})).unwrap();
s.execute("layer.timeStretch", json!({"layers": [a], "percent": 50})).unwrap();
s.execute("layer.setSwitch", json!({"layers": [a], "switch": "preserveTransparency", "value": true})).unwrap();
s.execute("layer.setSwitch", json!({"layers": [a], "switch": "motionBlur", "value": true})).unwrap();
s.execute("layer.setBlendMode", json!({"layers": [a], "mode": "Multiply"})).unwrap();
s.execute("layer.setParent", json!({"layers": [a], "parent": parent, "compensate": false})).unwrap();
s.execute("layer.setTrackMatte", json!({"layer": a, "matte": matte, "kind": "alpha"})).unwrap();
s.execute("layer.setComment", json!({"layers": [a], "comment": "keep me"})).unwrap();
s.execute("effect.apply", json!({"layer": a, "effect": "Gaussian Blur"})).unwrap();
s.execute("mask.new", json!({"layer": a, "vertices": [[10, 10], [90, 10], [90, 90]], "closed": true})).unwrap();
s.execute("prop.addKey", json!({"layer": a, "path": "transform/opacity", "time": 1.0, "value": 25.0})).unwrap();
s.execute("prop.addKey", json!({"layer": a, "path": "transform/opacity", "time": 2.0, "value": 75.0})).unwrap();
s.execute("prop.setExpression", json!({"layer": a, "path": "transform/rotation", "expression": "time * 90"})).unwrap();
let before = layer(&s, a);
let item = add_footage(&mut s, "new.png", FootageKind::Still, 120, 80, false);
s.execute("layer.replaceSource", json!({"layers": [a], "item": item.0})).unwrap();
let after = layer(&s, a);
assert_eq!(after.source, LayerSource::Footage { item });
assert_eq!(after.name, before.name);
assert_eq!(after.label, before.label);
assert_eq!(after.comment, before.comment);
assert_eq!((after.start_time, after.in_point, after.out_point, after.stretch), (before.start_time, before.in_point, before.out_point, before.stretch));
assert_eq!((after.switches, after.blend_mode, after.preserve_transparency), (before.switches, before.blend_mode, before.preserve_transparency));
assert_eq!((after.parent, after.track_matte), (before.parent, before.track_matte));
assert_eq!(after.props, before.props);
assert_eq!(s.active_comp().unwrap().layers.len(), 3, "no layer is added or removed");
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(layer(&s, a).source, before.source, "one undo step restores the source");
}
/// Composition, footage and solid sources are interchangeable; a precomposition adds its Audio
/// group when the layer has none, and an existing Audio group is kept when the next source has no
/// audio.
#[test]
fn replace_source_crosses_types_and_adds_audio() {
let mut s = comp();
let a = solid(&mut s, "Layer");
let inner = s.execute("comp.new", json!({"name": "Inner", "width": 200, "height": 100, "frameRate": 25, "duration": 3})).unwrap()["comp"].as_u64().unwrap();
let main = s.project.items.values().find(|i| i.name == "Main").unwrap().id;
s.execute("comp.open", json!({"comp": main})).unwrap();
let before = layer(&s, a);
s.execute("layer.replaceSource", json!({"layers": [a], "item": inner})).unwrap();
let after = layer(&s, a);
assert_eq!(after.source, LayerSource::Comp { item: ItemId(inner) });
assert_eq!(after.name, before.name);
assert!(after.props.sub("audio").is_some(), "a precomposition brings Audio");
let footage = add_footage(&mut s, "still.png", FootageKind::Still, 64, 32, false);
s.execute("layer.replaceSource", json!({"layers": [a], "item": footage.0})).unwrap();
let after = layer(&s, a);
assert_eq!(after.source, LayerSource::Footage { item: footage });
assert!(after.props.sub("audio").is_some(), "the Audio group is preserved for the next source");
let new_solid = Arc::make_mut(&mut s.project).add_item(
"New Solid",
Label::None,
None,
ItemKind::Solid(Solid { color: [0.0, 1.0, 0.0], width: 10, height: 10, pixel_aspect: 1.0 }),
);
s.execute("layer.replaceSource", json!({"layers": [a], "item": new_solid.0})).unwrap();
assert_eq!(layer(&s, a).source, LayerSource::Solid { item: new_solid });
assert_eq!(s.active_comp().unwrap().layers.len(), 1);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(layer(&s, a).source, LayerSource::Footage { item: footage });
}
/// Folders, data files, 3D models, non-source layers and composition cycles are refused before
/// anything is edited.
#[test]
fn replace_source_refuses_invalid_sources_targets_and_cycles() {
let mut s = comp();
let a = solid(&mut s, "Layer");
let folder = Arc::make_mut(&mut s.project).add_item("Folder", Label::None, None, ItemKind::Folder);
let data = add_footage(&mut s, "data.txt", FootageKind::Data, 0, 0, false);
let model = add_footage(&mut s, "model.glb", FootageKind::Model, 10, 10, false);
for (item, needle) in [(folder, "folder"), (data, "data"), (model, "3D model")] {
let err = s.execute("layer.replaceSource", json!({"layers": [a], "item": item.0})).unwrap_err().to_string();
assert!(err.contains(needle), "{err}");
}
let footage = add_footage(&mut s, "still.png", FootageKind::Still, 10, 10, false);
let text = s.execute("layer.newText", json!({"text": "Hello"})).unwrap()["layer"].as_u64().unwrap();
let err = s.execute("layer.replaceSource", json!({"layers": [text], "item": footage.0})).unwrap_err().to_string();
assert!(err.contains("no source item"), "{err}");
let inner = s.execute("comp.new", json!({"name": "Inner", "width": 100, "height": 100, "frameRate": 30, "duration": 2})).unwrap()["comp"].as_u64().unwrap();
let main = s.project.items.values().find(|i| i.name == "Main").unwrap().id;
s.execute("comp.open", json!({"comp": main})).unwrap();
let inner_layer = s.execute("layer.addItem", json!({"item": inner})).unwrap()["layer"].as_u64().unwrap();
let err = s.execute("layer.replaceSource", json!({"layers": [inner_layer], "item": main.0})).unwrap_err().to_string();
assert!(err.contains("contain"), "{err}");
}
/// Ctrl+Alt+/ takes the source from the Project panel selection and replaces every selected
/// layer; locked layers are left alone (and an all-locked selection is an error).
#[test]
fn replace_source_uses_the_project_selection_and_skips_locked_layers() {
let mut s = comp();
let a = solid(&mut s, "A");
let b = solid(&mut s, "B");
let footage = add_footage(&mut s, "still.png", FootageKind::Still, 10, 10, false);
s.state.project_selection = vec![footage];
let r = s.execute("layer.replaceSource", json!({"layers": [a, b]})).unwrap();
assert_eq!(r["item"], json!(footage.0));
assert!(matches!(layer(&s, a).source, LayerSource::Footage { item } if item == footage));
assert!(matches!(layer(&s, b).source, LayerSource::Footage { item } if item == footage));
s.execute("edit.undo", json!({})).unwrap();
s.execute("layer.setSwitch", json!({"layers": [b], "switch": "lock", "value": true})).unwrap();
let old_b = layer(&s, b).source;
let r = s.execute("layer.replaceSource", json!({"layers": [a, b], "item": footage.0})).unwrap();
assert_eq!(r["layers"], json!([a]));
assert!(matches!(layer(&s, a).source, LayerSource::Footage { item } if item == footage));
assert_eq!(layer(&s, b).source, old_b);
let err = s.execute("layer.replaceSource", json!({"layers": [b], "item": footage.0})).unwrap_err().to_string();
assert!(err.contains("locked"), "{err}");
}
/// After Effects' Ctrl+Alt+/ default (Cmd+Alt+/ in EffectCraft's shortcut syntax).
#[test]
fn replace_source_is_bound_to_ctrl_alt_slash() {
let b = crate::shortcuts::bindables(&[]).into_iter().find(|b| b.command == "layer.replaceSource").expect("layer.replaceSource is bindable");
assert!(b.defaults.iter().any(|d| d == "Cmd+Alt+/"), "{:?}", b.defaults);
}
+8 -1
View File
@@ -1304,7 +1304,14 @@ AVLayer.prototype.constructor = AVLayer;
}
P.sourcePointToComp = function (p) { return pointIn(this, p, true); };
P.compPointToSource = function (p) { return pointIn(this, p, false); };
P.replaceSource = function () { throw __err("replaceSource is not supported yet"); };
// Replace the layer's source item, keeping every layer property (After Effects'
// AVLayer.replaceSource). `fixExpressions` is accepted for compatibility: only AV sources are
// replaced, so the property tree and the layer's name stay and expressions keep resolving.
P.replaceSource = function (newSource, fixExpressions) {
if (!newSource || newSource.__id === undefined) throw __err("replaceSource needs a project item");
__call("layer.replaceSource", { comp: this.__comp, layers: [this.__id], item: newSource.__id, fixExpressions: !!fixExpressions });
return null;
};
P.openInViewer = function () { __call("layer.openLayer", this.__p1()); return null; };
})();
+31
View File
@@ -963,6 +963,37 @@ fn footage_proxies_sequences_and_conform_rate() {
let _ = std::fs::remove_dir_all(&dir);
}
/// #813: `AVLayer.replaceSource` changes the source item and keeps the layer's name, timing and
/// properties; a folder is not a source.
#[test]
fn av_layer_replace_source_keeps_the_layer() {
let mut s = session();
let o = ok(
&mut s,
r#"
var main = app.project.items.addComp("Main", 100, 100, 1, 5, 25);
var layer = main.layers.addSolid([1, 0, 0], "Old", 100, 100, 1);
layer.transform.position.setValue([10, 20]);
layer.startTime = 1;
var nested = app.project.items.addComp("Nested", 200, 100, 1, 3, 25);
layer.replaceSource(nested, false);
[layer.source.name, layer.source.width, layer.source.height, layer.source instanceof CompItem,
layer.name, layer.transform.position.value[0], layer.startTime, main.numLayers]
"#,
);
assert_eq!(o.result, json!(["Nested", 200, 100, true, "Old", 10, 1, 1]));
let bad = run_code(
&mut s,
r#"
var main = app.project.items.addComp("Other", 100, 100, 1, 2, 25);
var layer = main.layers.addSolid([1, 0, 0], "Layer", 100, 100, 1);
layer.replaceSource(app.project.items.addFolder("F"), false);
"#,
"replace-source.jsx",
);
assert!(bad.error.as_ref().is_some_and(|e| e.message.contains("folder")), "{bad:?}");
}
/// A flat green footage source, so `sampleImage` on a footage layer has known pixels.
struct Flat;
impl effectcraft_engine::render::FootageSource for Flat {
+64 -2
View File
@@ -8,7 +8,9 @@
use effectcraft_engine::color::BlendMode;
use effectcraft_engine::keyframe::{Interp, Value};
use effectcraft_engine::project::{Comp, GroupKind, ItemId, Layer, LayerId, LayerSource, MatteKind, Node, ParamUi, Project, PropGroup, Property};
use effectcraft_engine::project::{
Comp, FootageKind, GroupKind, ItemId, ItemKind, Layer, LayerId, LayerSource, MatteKind, Node, ParamUi, Project, PropGroup, Property,
};
use effectcraft_engine::render::EvalCtx;
use effectcraft_engine::time::Tick;
use egui::{Align2, Color32, Pos2, Rect, Sense, Stroke, StrokeKind, pos2, vec2};
@@ -1424,6 +1426,26 @@ fn layer_drop_id() -> egui::Id {
egui::Id::new("tl-layer-drop")
}
/// Whether a Project item can replace a layer's source (the same set `layer.replaceSource`
/// accepts): compositions, footage and solids; not folders, data files or 3D models.
fn replaceable_item(it: &effectcraft_engine::project::Item) -> bool {
match &it.kind {
ItemKind::Comp(_) | ItemKind::Solid(_) => true,
ItemKind::Footage(f) => !matches!(f.kind, FootageKind::Data | FootageKind::Model),
ItemKind::Folder => false,
}
}
/// The Alt/Option-drag target: the layer under the pointer can take this item as its source, and
/// the item wouldn't put the composition inside itself.
fn replace_source_target(app: &EffectcraftApp, cid: ItemId, item: ItemId, lid: LayerId, comp: &Comp) -> bool {
app.session.project.item(item).is_some_and(replaceable_item)
&& !app.session.project.comp_contains(item, cid)
&& comp
.layer(lid)
.is_some_and(|l| !l.switches.locked && matches!(l.source, LayerSource::Footage { .. } | LayerSource::Comp { .. } | LayerSource::Solid { .. }))
}
pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let t = app.tokens;
let ctx = ui.ctx().clone();
@@ -3448,7 +3470,27 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
&& let Some(ptr) = ctx.input(|i| i.pointer.hover_pos()).filter(|_| ui.rect_contains_pointer(rows_rect))
{
let released = ctx.input(|i| i.pointer.any_released());
if matches!(payload.as_ref(), crate::panels::DragPayload::Effect(_) | crate::panels::DragPayload::Preset { .. }) {
// Alt/Option-drag from the Project panel replaces the source of the layer under the
// pointer (After Effects). The shortcut replaces every selected layer instead.
let alt_item = ctx.input(|i| i.modifiers.alt) && matches!(payload.as_ref(), crate::panels::DragPayload::Item(_));
if alt_item {
if let crate::panels::DragPayload::Item(id) = payload.as_ref() {
let target = hit_rows.iter().find(|(r, _)| ptr.y >= r.min.y && ptr.y < r.max.y).map(|(_, row)| row.layer);
if let Some(l) = target
&& replace_source_target(app, cid, ItemId(*id), l, &comp)
&& let Some(span) = hit_rows.iter().filter(|(_, row)| row.layer == l).map(|(r, _)| *r).reduce(|a, b| a.union(b))
{
let lit = span.intersect(rows_rect);
let rp = p.with_clip_rect(rows_rect);
rp.rect_filled(lit, 0.0, t.accent.gamma_multiply(0.18));
rp.rect_stroke(lit, 0.0, Stroke::new(2.0, t.accent), StrokeKind::Inside);
app.auto.add("timeline.replaceSourceTarget", lit, "Replace the layer's source");
if released {
actions.push(("layer.replaceSource".into(), json!({"layers": [l.0], "item": *id, "fixExpressions": false})));
}
}
}
} else if matches!(payload.as_ref(), crate::panels::DragPayload::Effect(_) | crate::panels::DragPayload::Preset { .. }) {
let target = hit_rows.iter().find(|(r, _)| ptr.y >= r.min.y && ptr.y < r.max.y).map(|(_, row)| row.layer);
// The layer the effect would land on (its row and its open properties) is the one
// that lights up, not the whole timeline.
@@ -4372,6 +4414,26 @@ fn value_editor(
mod tests {
use super::*;
/// Alt/Option-drag takes the same Project items `layer.replaceSource` does: compositions,
/// footage and solids, never folders, data files or 3D models.
#[test]
fn replace_source_takes_visual_items_only() {
use effectcraft_engine::color::Label;
use effectcraft_engine::project::{Comp, Footage, Project, Solid};
use effectcraft_engine::time::{FrameRate, Tick};
let mut p = Project::default();
let l = Label::None;
let comp = p.add_item("C", l, None, ItemKind::Comp(Comp::new(10, 10, FrameRate::FPS_30, Tick::from_seconds_f64(1.0)).into()));
let folder = p.add_item("F", l, None, ItemKind::Folder);
let still = p.add_item("S", l, None, ItemKind::Footage(Footage { kind: FootageKind::Still, ..Default::default() }));
let data = p.add_item("D", l, None, ItemKind::Footage(Footage { kind: FootageKind::Data, ..Default::default() }));
let model = p.add_item("M", l, None, ItemKind::Footage(Footage { kind: FootageKind::Model, ..Default::default() }));
let solid = p.add_item("So", l, None, ItemKind::Solid(Solid { color: [0.0; 3], width: 10, height: 10, pixel_aspect: 1.0 }));
for (id, ok) in [(comp, true), (still, true), (solid, true), (folder, false), (data, false), (model, false)] {
assert_eq!(replaceable_item(p.item(id).unwrap()), ok, "{id:?}");
}
}
/// Alt/Cmd-click twirls Audio's Waveform row (not in the property tree) with the rest.
#[test]
fn the_waveform_row_twirls_with_its_tree() {
+41
View File
@@ -108,6 +108,47 @@ fn project_items_land_where_they_are_dropped_in_the_timeline() {
assert!((in_point(&h, 3) - 1.0).abs() < 1e-9, "Shift starts it at the current time");
}
/// Alt/Option-drag from the Project panel replaces the source of the layer under the pointer
/// instead of adding a layer (#991).
#[test]
fn alt_dragging_a_project_item_replaces_the_layer_source() {
let (mut h, clip) = harness();
let top = layer_id(&h, "Top");
let item = rect(&h, &format!("project.item.{clip}.name")).center();
let row = rect(&h, &format!("timeline.layer.{top}.row"));
let before = stack(&h);
drag(&mut h, item, row.center(), Modifiers::ALT);
assert_eq!(stack(&h), before, "no layer is added");
let layer = h.state().session.active_comp().unwrap().layer(effectcraft_engine::project::LayerId(top)).unwrap();
assert_eq!(layer.source, effectcraft_engine::project::LayerSource::Comp { item: effectcraft_engine::project::ItemId(clip) });
assert_eq!(layer.name, "Top", "the layer keeps its name");
}
/// Ctrl+Alt+/ (Cmd+Opt+/ on macOS) replaces every selected layer's source with the Project panel
/// selection (#991).
#[test]
fn ctrl_alt_slash_replaces_every_selected_layer_source() {
let (mut h, clip) = harness();
let top = layer_id(&h, "Top");
let middle = layer_id(&h, "Middle");
h.state_mut().session.execute("layer.select", json!({"layers": [top, middle]})).unwrap();
h.state_mut().session.state.project_selection = vec![effectcraft_engine::project::ItemId(clip)];
h.run_steps(3);
let modifiers = Modifiers { command: true, alt: true, ..Default::default() };
for pressed in [true, false] {
h.event(Event::Key { key: egui::Key::Slash, physical_key: Some(egui::Key::Slash), pressed, repeat: false, modifiers });
}
h.run_steps(3);
let comp = h.state().session.active_comp().unwrap();
for lid in [top, middle] {
assert_eq!(
comp.layer(effectcraft_engine::project::LayerId(lid)).unwrap().source,
effectcraft_engine::project::LayerSource::Comp { item: effectcraft_engine::project::ItemId(clip) }
);
}
assert_eq!(stack(&h), ["Top", "Middle", "Bottom"], "no layer is added");
}
/// #85: a Project item dropped on the Composition viewer becomes a layer centred where it was
/// dropped (above the selected layer, like any new layer).
#[test]
+9
View File
@@ -120,6 +120,15 @@ vectors, positions and IDs are never the picture. EXtractoR reads every other la
Importing a layered file as a composition refreshes the decoded sources used by its layers,
including files overwritten at the same path and byte size.
- **Replace a layer's source** without rebuilding the layer: Alt-drag (Option-drag on macOS) a
footage item, composition or solid from the Project panel onto a layer in the Timeline. The
layer keeps its name, timing, transforms, keyframes, expressions, effects, masks, switches,
parenting and track matte; only its source changes. A source with audio adds Audio properties
when the layer has none. To replace several selected layers with the selected Project item, press
Ctrl+Alt+/ (Cmd+Opt+/). Compositions, footage and solids are interchangeable; folders, data files
and 3D models are not sources, and a composition can't be nested in itself. Alt-dragging onto a
layer is one layer at a time, as in After Effects.
Agents: `layer.replaceSource {"item": id, "layers": [ids]}`.
- **File ▸ Replace Footage ▸ File…** (Ctrl+H) points the item at another file. Pick a numbered
file to replace it with that file's whole sequence (`file.replaceFootage {"path": …,
"sequence": false}` for one frame).