From 8d887fd6dad5948b5ffe47b87dbfedb57235b1c8 Mon Sep 17 00:00:00 2001 From: Camille Moine Date: Tue, 20 Oct 2026 12:18:46 +0200 Subject: [PATCH] Layer: replace a layer's source with Alt-drag and Ctrl+Alt+/ (#991, #813) Alt/Option-drag a Project item onto a Timeline layer, or press Ctrl+Alt+/ (Cmd+Opt+/) with layers and an item selected, to swap the layer's source. The layer keeps its name, timing, transforms, keyframes, expressions, effects, masks, switches, parenting and track matte; a source with audio adds Audio properties when missing. Precompositions can't be nested inside themselves. AVLayer.replaceSource now runs the same engine command and closes the replaceSource part of #813. --- crates/engine/src/commands/layer.rs | 110 ++++++++++++++- crates/engine/src/lib.rs | 2 + crates/engine/src/tests_replace_source.rs | 158 ++++++++++++++++++++++ crates/script/src/prelude.js | 9 +- crates/script/src/tests.rs | 31 +++++ crates/ui-egui/src/panels/timeline.rs | 64 ++++++++- crates/ui-egui/tests/ui_drag_drop.rs | 41 ++++++ docs/footage.md | 9 ++ 8 files changed, 419 insertions(+), 5 deletions(-) create mode 100644 crates/engine/src/tests_replace_source.rs diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index b798493b..4d55859f 100644 --- a/crates/engine/src/commands/layer.rs +++ b/crates/engine/src/commands/layer.rs @@ -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 { 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 { + 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 { + 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::>(), "item": item.0})) +} + fn select(s: &mut Session, p: &Value) -> Result { let cid = comp_id(s, p)?; let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; @@ -1628,6 +1725,15 @@ pub fn specs() -> Vec { 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), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index cdfc7468..96b3bf67 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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; diff --git a/crates/engine/src/tests_replace_source.rs b/crates/engine/src/tests_replace_source.rs new file mode 100644 index 00000000..a5cb252c --- /dev/null +++ b/crates/engine/src/tests_replace_source.rs @@ -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); +} diff --git a/crates/script/src/prelude.js b/crates/script/src/prelude.js index 2eeca86b..ba5a3af5 100644 --- a/crates/script/src/prelude.js +++ b/crates/script/src/prelude.js @@ -1301,7 +1301,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; }; })(); diff --git a/crates/script/src/tests.rs b/crates/script/src/tests.rs index 6f87f757..0e1e509c 100644 --- a/crates/script/src/tests.rs +++ b/crates/script/src/tests.rs @@ -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 { diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index b34fbd9d..5a7f40d3 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -8,7 +8,7 @@ use effectcraft_engine::color::BlendMode; use effectcraft_engine::keyframe::{Interp, Value}; -use effectcraft_engine::project::{Comp, GroupKind, ItemId, Layer, LayerId, LayerSource, MatteKind, Node, ParamUi, PropGroup, Property}; +use effectcraft_engine::project::{Comp, FootageKind, GroupKind, ItemId, ItemKind, Layer, LayerId, LayerSource, MatteKind, Node, ParamUi, PropGroup, Property}; use effectcraft_engine::render::EvalCtx; use effectcraft_engine::time::Tick; use egui::{Align2, Color32, Pos2, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; @@ -1382,6 +1382,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(); @@ -3356,7 +3376,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. @@ -4264,6 +4304,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() { diff --git a/crates/ui-egui/tests/ui_drag_drop.rs b/crates/ui-egui/tests/ui_drag_drop.rs index 9b66f745..e405bf77 100644 --- a/crates/ui-egui/tests/ui_drag_drop.rs +++ b/crates/ui-egui/tests/ui_drag_drop.rs @@ -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] diff --git a/docs/footage.md b/docs/footage.md index 84d4c49e..e142fc4a 100644 --- a/docs/footage.md +++ b/docs/footage.md @@ -110,6 +110,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).