mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-24 10:09:32 -06:00
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.
This commit is contained in:
@@ -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)?;
|
||||
@@ -1628,6 +1725,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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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; };
|
||||
})();
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user