Merge pull request #2615 from arnav-hiwarkar/perf/2573-new-comp-from-footage

Fix #2573: New Composition From Footage creates a composition
This commit is contained in:
BFlat
2026-10-30 07:04:25 +07:00
committed by GitHub
5 changed files with 149 additions and 18 deletions
+56 -17
View File
@@ -125,7 +125,8 @@ fn revert(s: &mut Session, _: &Value) -> Result<Value> {
}
/// File ▸ Import; with `addToComp` (files dropped on the Timeline or the Composition viewer) they
/// also become layers of the active comp ([`add_to_comp`]).
/// also become layers of the active comp ([`add_to_comp`]); with `newComp` (the empty viewer's
/// New Composition From Footage) they become one new composition instead ([`new_comp`]).
///
/// With `background` (files dropped on the window or picked in the Import dialog) the files are
/// probed on a background thread, so decoding a slow video doesn't freeze the app: the job shows
@@ -175,10 +176,10 @@ fn import_cmd(s: &mut Session, p: &Value) -> Result<Value> {
Ok(r)
}
/// After an import (its result `r`): with `addToComp`, the new items become layers of `target`;
/// what couldn't be added joins the result's errors.
/// After an import (its result `r`): with `newComp`, the new items become one new composition;
/// otherwise with `addToComp`, layers of `target`. What couldn't be added joins the result's errors.
fn finish_import(s: &mut Session, r: &mut Value, target: Option<ItemId>, p: &Value) {
let errors = add_to_comp(s, r, target, p);
let errors = if b_p(p, "newComp").unwrap_or(false) { new_comp(s, r) } else { add_to_comp(s, r, target, p) };
if !errors.is_empty()
&& let Some(e) = r.get_mut("errors").and_then(Value::as_array_mut)
{
@@ -192,18 +193,7 @@ fn finish_import(s: &mut Session, r: &mut Value, target: Option<ItemId>, p: &Val
/// Project panel) and the other footage. `target` stays open. Returns what couldn't be added.
pub(crate) fn add_to_comp(s: &mut Session, r: &Value, target: Option<ItemId>, p: &Value) -> Vec<String> {
let Some(cid) = target.filter(|_| p.get("addToComp").and_then(Value::as_bool).unwrap_or(false)) else { return vec![] };
let ids = |k: &str| r.get(k).and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_u64).collect::<Vec<u64>>();
let comps = ids("comps");
let in_comps: std::collections::HashSet<u64> = comps
.iter()
.filter_map(|c| s.project.comp(ItemId(*c)))
.flat_map(|c| c.layers.iter())
.filter_map(|l| match l.source {
LayerSource::Footage { item } | LayerSource::Comp { item } | LayerSource::Solid { item } => Some(item.0),
_ => None,
})
.collect();
let add: Vec<u64> = comps.iter().copied().chain(ids("items").into_iter().filter(|i| !in_comps.contains(i))).collect();
let add = imported(s, r);
let mut errors = vec![];
for (k, item) in add.into_iter().enumerate() {
let mut params = json!({"comp": cid.0, "item": item, "time": p.get("time"), "position": p.get("position")});
@@ -220,6 +210,55 @@ pub(crate) fn add_to_comp(s: &mut Session, r: &Value, target: Option<ItemId>, p:
errors
}
/// What an import (its result `r`) made that belongs in a comp: the comps of layered files
/// (their layers' footage, in their precomps too, stays in the Project panel), then the other items,
/// without those comps again or the files' folders.
fn imported(s: &Session, r: &Value) -> Vec<u64> {
let ids = |k: &str| r.get(k).and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_u64).collect::<Vec<u64>>();
let comps = ids("comps");
let mut in_comps = std::collections::HashSet::new();
let mut todo = comps.clone();
while let Some(c) = todo.pop() {
for l in s.project.comp(ItemId(c)).into_iter().flat_map(|c| c.layers.iter()) {
match l.source {
LayerSource::Comp { item } if in_comps.insert(item.0) => todo.push(item.0),
LayerSource::Footage { item } | LayerSource::Solid { item } => {
in_comps.insert(item.0);
}
_ => {}
}
}
}
let rest = ids("items").into_iter().filter(|i| !in_comps.contains(i) && !comps.contains(i) && !s.project.item(ItemId(*i)).is_some_and(|it| it.is_folder()));
comps.iter().copied().chain(rest).collect()
}
/// With `newComp`, make one composition of the footage and solids an import (its result `r`)
/// made, as New Comp from Selection does with `single` (first item on top, its settings). A comp
/// the import made (a layered file) already is the composition, so it stays out, as does data
/// footage; none left, no new comp. Returns what went wrong.
fn new_comp(s: &mut Session, r: &Value) -> Vec<String> {
let items: Vec<ItemId> = imported(s, r)
.into_iter()
.map(ItemId)
.filter(|i| {
s.project.item(*i).is_some_and(|it| match &it.kind {
ItemKind::Footage(f) => f.kind != FootageKind::Data,
ItemKind::Solid(_) => true,
ItemKind::Comp(_) | ItemKind::Folder => false,
})
})
.collect();
if items.is_empty() {
return vec![];
}
s.state.project_selection = items;
match s.execute("file.newCompFromSelection", json!({"single": true})) {
Ok(_) => vec![],
Err(e) => vec![e.to_string()],
}
}
/// File ▸ Import, all in one go (scripts and commands built on it); see [`import_cmd`].
pub(crate) fn import(s: &mut Session, p: &Value) -> Result<Value> {
let pending = prepare_import(s, p)?;
@@ -746,7 +785,7 @@ pub fn specs() -> Vec<CommandSpec> {
"File...",
["File", "Import"],
Some("Cmd+I"),
"{paths: [string] (files or folders), sequence?: bool (the Sequence checkbox: true imports numbered stills of one run as one image sequence, one picked file bringing its whole run and several picked files that range; default false: picked or dropped files import as stills, as in After Effects; folders' runs import as sequences; false imports stills and a folder's files individually), alphabetical?: bool (Force Alphabetical Order: every image of that type in the folder, by name), frameRate?: fps (image sequences; default Settings ▸ Import ▸ Sequence Footage), importAs?: footage|composition|compositionLayerSizes (Photoshop, PDF, Illustrator and EPS files), layer?: name|index (footage of one Photoshop layer), page?: number from 1 (PDF / Illustrator page), drag?: bool (dropped files: Settings ▸ Import ▸ Default Drag Import As; files import as stills unless sequence), addToComp?: bool (also add them to the active comp, at time?, index?, position? as in layer.addItem), background?: bool (probe the files in a background job, jobs.list / jobs.wait; returns {job})}",
"{paths: [string] (files or folders), sequence?: bool (the Sequence checkbox: true imports numbered stills of one run as one image sequence, one picked file bringing its whole run and several picked files that range; default false: picked or dropped files import as stills, as in After Effects; folders' runs import as sequences; false imports stills and a folder's files individually), alphabetical?: bool (Force Alphabetical Order: every image of that type in the folder, by name), frameRate?: fps (image sequences; default Settings ▸ Import ▸ Sequence Footage), importAs?: footage|composition|compositionLayerSizes (Photoshop, PDF, Illustrator and EPS files), layer?: name|index (footage of one Photoshop layer), page?: number from 1 (PDF / Illustrator page), drag?: bool (dropped files: Settings ▸ Import ▸ Default Drag Import As; files import as stills unless sequence), addToComp?: bool (also add them to the active comp, at time?, index?, position? as in layer.addItem), newComp?: bool (make one new composition of the footage and solids imported, as file.newCompFromSelection with single; a layered file imported as a composition is that composition and gets no other; takes precedence over addToComp), background?: bool (probe the files in a background job, jobs.list / jobs.wait; returns {job})}",
always,
import_cmd
),
+40
View File
@@ -159,6 +159,46 @@ fn background_imports_report_progress_and_select_what_arrived() {
assert_eq!(r["items"].as_array().map(Vec::len), Some(1));
}
/// #2573: New Composition From Footage (`file.import {newComp}`) makes one composition of what
/// the import made, first file on top, at once or when the background job lands; an import that
/// made nothing makes no comp.
#[test]
fn new_comp_from_footage_makes_a_comp_of_what_was_imported() {
use effectcraft_project::{Footage, FootageKind, ItemKind};
struct Probe;
impl crate::Importer for Probe {
fn probe(&self, path: &str) -> Result<Footage, String> {
if path.ends_with(".xyz") {
return Err("unsupported file".into());
}
Ok(Footage { path: path.into(), kind: FootageKind::Still, width: 64, height: 32, has_video: true, ..Default::default() })
}
}
let mut s = Session { importer: Some(std::sync::Arc::new(Probe)), ..Default::default() };
let comps = |s: &Session| s.project.items.values().filter(|i| matches!(i.kind, ItemKind::Comp(_))).count();
let layers = |s: &Session| s.active_comp().unwrap().layers.iter().map(|l| l.name.clone()).collect::<Vec<_>>();
// At once.
s.execute("file.import", json!({"paths": ["/drop/a.png", "/drop/b.xyz"], "newComp": true})).unwrap();
assert_eq!(comps(&s), 1);
assert_eq!(layers(&s), ["a.png"]);
assert_eq!((s.active_comp().unwrap().width, s.active_comp().unwrap().height), (64, 32));
// In the background: the job's own items, whatever is selected or active meanwhile; it takes
// precedence over `addToComp`.
s.execute("file.import", json!({"paths": ["/drop/c.png", "/drop/d.png"], "background": true, "newComp": true, "addToComp": true})).unwrap();
s.state.project_selection.clear();
s.execute("jobs.wait", json!({})).unwrap();
assert_eq!(comps(&s), 2);
assert_eq!(layers(&s), ["c.png", "d.png"]);
// Nothing imported: no comp, and the failure is reported.
s.execute("file.import", json!({"paths": ["/drop/e.xyz"], "background": true, "newComp": true})).unwrap();
s.execute("jobs.wait", json!({})).unwrap();
assert_eq!(comps(&s), 2);
assert!(s.job_log.last().unwrap().message.contains("unsupported file"));
let r = s.execute("file.import", json!({"paths": ["/drop/f.xyz"], "newComp": true})).unwrap();
assert_eq!(comps(&s), 2);
assert_eq!(r["errors"].as_array().map(Vec::len), Some(1));
}
/// Audio, Lock and Shy don't change pixels: toggling them keeps the cached layers (#103).
#[test]
fn audio_lock_and_shy_keep_cached_layers() {
+31
View File
@@ -160,6 +160,37 @@ fn psd_imports_as_composition() {
assert!(s.project.comp(cid).is_none());
}
/// #2573: New Composition From Footage on a Photoshop file imported as a composition opens that
/// composition; it isn't wrapped in another.
#[test]
fn psd_imported_as_composition_with_new_comp_is_the_new_comp() {
let path = tmp("newcomp.psd");
std::fs::write(&path, sample_psd()).unwrap();
let mut s = Session::default();
let r = s.execute_checked("file.import", json!({"paths": [path], "importAs": "composition", "newComp": true})).unwrap();
assert_eq!(r["errors"], json!([]), "{r}");
let cid = effectcraft_project::ItemId(r["comps"][0].as_u64().unwrap());
assert_eq!(s.state.active_comp, Some(cid));
let roots = s.project.items.values().filter(|i| matches!(i.kind, ItemKind::Comp(_)) && i.name.starts_with("newcomp")).count();
assert_eq!(roots, 1, "no wrapper comp");
}
/// A Photoshop file imported as a composition and dropped on the Timeline (`addToComp`) adds
/// its composition once, not its folder too.
#[test]
fn psd_imported_as_composition_into_a_comp_adds_one_layer() {
let path = tmp("dropped.psd");
std::fs::write(&path, sample_psd()).unwrap();
let mut s = Session::default();
let main = s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "frameRate": 30, "duration": 4})).unwrap()["comp"].as_u64().unwrap();
let r = s.execute_checked("file.import", json!({"paths": [path], "importAs": "composition", "addToComp": true})).unwrap();
assert_eq!(r["errors"], json!([]), "{r}");
let psd = r["comps"][0].as_u64().unwrap();
let layers = &s.project.comp(effectcraft_project::ItemId(main)).unwrap().layers;
assert_eq!(layers.len(), 1, "{layers:?}");
assert!(matches!(layers[0].source, LayerSource::Comp { item } if item.0 == psd));
}
#[test]
fn psd_retain_layer_sizes() {
let path = tmp("retain.psd");
+1 -1
View File
@@ -15,7 +15,7 @@ pub(super) fn empty_state(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rec
app.auto.add("viewer.empty.newComp", b1, "New Composition");
let b2 = Rect::from_center_size(c + vec2(0.0, 40.0), vec2(220.0, 30.0));
if widgets::text_button(ui, b2, "New Composition From Footage", false, &t, egui::Id::unique("empty-fromfootage")).clicked() {
let _ = crate::menus::invoke(app, &ui.ctx().clone(), "file.import", json!({}));
let _ = crate::menus::invoke(app, &ui.ctx().clone(), "file.import", json!({"newComp": true}));
}
app.auto.add("viewer.empty.import", b2, "New Composition From Footage");
// A Project item dropped on the empty viewer creates the first composition,
+21
View File
@@ -632,6 +632,27 @@ fn import_dialog_offers_the_image_sequence_option() {
let _ = std::fs::remove_dir_all(&dir);
}
/// #2573: the empty Composition viewer's New Composition From Footage imports the picked file
/// (through the Import Image Sequence question a numbered still asks) and makes a composition
/// of it, as in After Effects.
#[test]
fn new_composition_from_footage_makes_a_comp_of_the_picked_file() {
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(Session::default()));
h.run_steps(3);
let dir = frames("fromfootage", 2);
let pick = dir.join("frame_0002.png").to_string_lossy().to_string();
h.state_mut().session.importer = Some(Arc::new(Stills));
h.state_mut().hooks.pick_files = Some(Box::new(move |_: &[&str]| vec![pick.clone()]));
click_id(&mut h, "viewer.empty.import");
assert_eq!(h.state().dialog, Some(effectcraft_ui_egui::Dialog::Form));
click_id(&mut h, "form.ok");
step_until(&mut h, |h| h.state().session.active_comp().is_some());
let comp = h.state().session.active_comp().unwrap();
assert_eq!((comp.width, comp.height), (64, 32));
assert_eq!(comp.layers.iter().map(|l| l.name.as_str()).collect::<Vec<_>>(), ["frame_0002.png"]);
let _ = std::fs::remove_dir_all(&dir);
}
/// #297: a folder of frames dropped on the window arrives as one image sequence, and Interpret
/// Footage shows its frames and Start Timecode (missing frames always show colour bars, as in
/// After Effects: there is no Missing Frames choice). #431: dropped files import as stills, one