From 285297ed938f8a618662a98ac8fea4fcd375fe7c Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 8 Oct 2026 19:30:55 +0700 Subject: [PATCH 1/2] Timeline: Puppet pin keyframes show in the Timeline (#273) Moving a puppet pin keys its Position, but the Timeline didn't show those keys where people look for them. U revealed every pin's Position as one more flat "Position" row under the layer, indistinguishable from the layer's own Position and from each other, and the reveal was a snapshot: a pin placed or moved after U (or with the layer twirled open and Effects closed) got keys that no row showed. As in After Effects, revealed properties inside an effect now show under the effect and its groups (Puppet > Mesh 1 > Deform > Puppet Pin 1 > Position), twirled open by the reveal and closable like any group; Transform and mask properties stay as they were. Placing, moving or recording a pin in the viewer keeps its Position in view: on a twirled-open layer its groups open, and a U reveal takes it in. A collapsed layer stays collapsed. The keys select, move and delete like any others. Test: puppet_pin_keys_show_under_their_pins_in_the_timeline, placing_a_pin_opens_its_groups_on_a_twirled_open_layer (ui_viewer). Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/menus.rs | 7 +- crates/ui-egui/src/panels/puppet_tool.rs | 10 ++ crates/ui-egui/src/panels/timeline.rs | 123 +++++++++++++++++++++-- crates/ui-egui/src/panels/viewer.rs | 20 +++- crates/ui-egui/tests/ui_viewer.rs | 90 +++++++++++++++++ 5 files changed, 235 insertions(+), 15 deletions(-) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 1cef8b57..7cf16411 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -173,7 +173,8 @@ pub fn reveal(app: &mut EffectcraftApp, kind: &str, now: f64, add: bool) { } else { kinds = vec![kind.to_string()]; } - tl.apply_reveal(&targets, kinds); + tl.apply_reveal(&targets, kinds.clone()); + crate::panels::timeline::open_revealed(app, &targets, &kinds); } /// Show Time Remap on the layers of a `layer.enableTimeRemap` (`layers` by id, else the @@ -1073,6 +1074,10 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu if !shown.is_empty() { tl.apply_reveal(&shown, vec!["props".into()]); } + // Properties inside effects (puppet pins…) show under their effect, twirled open. + for (layer, prop) in props.iter().filter_map(|x| Some((x.get("layer")?.as_u64()?, x.get("prop")?.as_u64()?))) { + crate::panels::timeline::open_effect_paths(app, layer, &[prop]); + } json!({"revealed": found.len()}) } _ => return Err(format!("`{id}` is not a frontend command")), diff --git a/crates/ui-egui/src/panels/puppet_tool.rs b/crates/ui-egui/src/panels/puppet_tool.rs index 9fa8ac6a..4b0b8e9a 100644 --- a/crates/ui-egui/src/panels/puppet_tool.rs +++ b/crates/ui-egui/src/panels/puppet_tool.rs @@ -82,6 +82,16 @@ pub fn overlay(app: &EffectcraftApp, ctx: &egui::Context, cid: ItemId, layer: &L Some(overlay) } +/// Keep a pin's keyframed Position in view in the Timeline after placing, moving or recording the +/// pin in the viewer (#273). +pub fn reveal_pin(app: &mut EffectcraftApp, layer: LayerId, pin: u64) { + let comp = app.session.active_comp(); + let pos = comp.and_then(|c| c.layer(layer)?.props.find_group(pin)?.get("position")).filter(|p| p.is_animated()).map(|p| p.uid); + if let Some(pos) = pos { + super::timeline::keep_in_view(app, layer, pos); + } +} + /// Where a pin sits now (layer space): its Position, or (Bend / Starch / Overlap pins) its rest /// point carried along by the deformation. pub fn pin_position(mesh: &Mesh, def: &[[f64; 2]], p: &Pin) -> [f64; 2] { diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 26ec3435..8bdacc0a 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -685,8 +685,11 @@ fn build_rows(app: &EffectcraftApp, comp: &Comp) -> Vec { fn reveal_rows(app: &EffectcraftApp, l: &Layer, kinds: &[String], rows: &mut Vec) { let tl = &app.ui.timeline; let has = |k: &str| kinds.iter().any(|r| r == k); - let group_rows = |rows: &mut Vec, g: &PropGroup, fx: bool| { + // Groups shown with their whole contents: the revealed properties inside aren't listed again. + let mut shown_groups: Vec = vec![]; + let mut group_rows = |rows: &mut Vec, g: &PropGroup, fx: bool| { for g in g.groups() { + shown_groups.push(g.uid); rows.push(Row { layer: l.id, depth: 1, @@ -751,11 +754,6 @@ fn reveal_rows(app: &EffectcraftApp, l: &Layer, kinds: &[String], rows: &mut Vec wanted.extend(found); } } - if !wanted.is_empty() { - let mut found = vec![]; - collect_props(&l.props, &mut found, &|p| wanted.contains(&p.uid)); - rows.extend(found.into_iter().map(|uid| Row { layer: l.id, depth: 1, kind: RowKind::Prop { uid } })); - } // SS: the selected properties and groups (as Animation ▸ Reveal Properties shows them). let selected: std::collections::BTreeSet = app.session.state.selected_props.iter().filter(|(lid, _)| *lid == l.id).map(|(_, u)| *u).collect(); if has("props") || has("selected") { @@ -784,17 +782,124 @@ fn reveal_rows(app: &EffectcraftApp, l: &Layer, kinds: &[String], rows: &mut Vec if open { push_group(rows, l, g, 2, &tl.open_groups); } + shown_groups.push(g.uid); } - let mut found = vec![]; - collect_props(&l.props, &mut found, &|p| prop_visible(p, l) && reveal_props.contains(&p.uid) && !wanted.contains(&p.uid)); - rows.extend(found.into_iter().map(|uid| Row { layer: l.id, depth: 1, kind: RowKind::Prop { uid } })); + wanted.extend(picked.iter().copied()); } + revealed_rows(rows, l, &wanted, &shown_groups, &tl.open_groups); if has("waveform") && let Some(item) = super::waveform::audio_item(&app.session.project, l) { rows.push(Row { layer: l.id, depth: 1, kind: RowKind::Waveform { item: item.0 } }); } } + +/// Rows for the revealed properties `shown` of `l`, in tree order. Those inside an effect show +/// under the effect and its groups, as in After Effects (Puppet ▸ Mesh 1 ▸ Deform ▸ Puppet +/// Pin 1 ▸ Position: every pin's Position is told apart, #273); the others (Transform, masks…) +/// on their own. Groups in `skip` already show with their contents. +fn revealed_rows(rows: &mut Vec, l: &Layer, shown: &std::collections::BTreeSet, skip: &[u64], open: &std::collections::BTreeSet) { + struct Tree<'a> { + l: &'a Layer, + wanted: &'a dyn Fn(&Property) -> bool, + skip: &'a [u64], + open: &'a std::collections::BTreeSet, + } + fn holds(g: &PropGroup, wanted: &dyn Fn(&Property) -> bool) -> bool { + g.children.iter().any(|c| match c { + Node::Prop(p) => wanted(p), + Node::Group(sg) => holds(sg, wanted), + }) + } + // A group holding revealed properties (twirled as the user left it), then those inside. + fn group(rows: &mut Vec, t: &Tree<'_>, g: &PropGroup, depth: usize) { + if t.skip.contains(&g.uid) || !holds(g, t.wanted) { + return; + } + let o = t.open.contains(&g.uid); + let fx = matches!(g.kind, GroupKind::Effect { .. }).then_some(g.enabled); + rows.push(Row { layer: t.l.id, depth, kind: RowKind::Group { uid: g.uid, name: g.name.clone(), open: o, has_children: true, fx, eye: None } }); + if !o { + return; + } + for c in &g.children { + match c { + Node::Prop(p) if (t.wanted)(p) => rows.push(Row { layer: t.l.id, depth: depth + 1, kind: RowKind::Prop { uid: p.uid } }), + Node::Group(sg) => group(rows, t, sg, depth + 1), + Node::Prop(_) => {} + } + } + } + let wanted = |p: &Property| shown.contains(&p.uid) && prop_visible(p, l); + let tree = Tree { l, wanted: &wanted, skip, open }; + for c in &l.props.children { + match c { + Node::Group(fx) if fx.match_id == "effects" => { + for e in fx.groups() { + group(rows, &tree, e, 1); + } + } + Node::Group(g) => { + let mut found = vec![]; + collect_props(g, &mut found, &wanted); + rows.extend(found.into_iter().map(|uid| Row { layer: l.id, depth: 1, kind: RowKind::Prop { uid } })); + } + Node::Prop(p) if wanted(p) => rows.push(Row { layer: l.id, depth: 1, kind: RowKind::Prop { uid: p.uid } }), + Node::Prop(_) => {} + } + } +} + +/// Uids of the groups that hold property `uid` of `l`, outermost first. +fn enclosing_groups(l: &Layer, uid: u64) -> Vec { + let path = l.props.path_of(uid).unwrap_or_default(); + let mut ids: Vec = path.split('/').filter_map(|s| s.strip_prefix('@')?.parse().ok()).collect(); + ids.pop(); + ids +} + +/// Twirl open the groups holding `props` of `layer` that sit inside effects, so their rows show +/// (a U reveal, an EE reveal, a puppet pin placed or moved in the viewer). +pub(crate) fn open_effect_paths(app: &mut EffectcraftApp, layer: u64, props: &[u64]) { + let Some(l) = app.session.active_comp().and_then(|c| c.layer(LayerId(layer))) else { return }; + let Some(fx) = l.effects().map(|g| g.uid) else { return }; + let mut open = vec![]; + for p in props { + let path = enclosing_groups(l, *p); + if path.first() == Some(&fx) { + open.extend(path); + } + } + app.ui.timeline.open_groups.extend(open); +} + +/// After a reveal shortcut that picks properties by state (EE expressions): those inside effects +/// on `layers` show under their effect, twirled open. +pub(crate) fn open_revealed(app: &mut EffectcraftApp, layers: &[u64], kinds: &[String]) { + for kind in kinds.iter().filter(|k| matches!(k.as_str(), "animated" | "expressions")) { + for id in layers { + let Some(l) = app.session.active_comp().and_then(|c| c.layer(LayerId(*id))) else { continue }; + let mut found = vec![]; + collect_props(&l.props, &mut found, &|p| prop_visible(p, l) && reveal_matches(p, &[], kind)); + open_effect_paths(app, *id, &found); + } + } +} + +/// After the viewer edits a property (a puppet pin placed, moved or recorded): when `layer` is +/// twirled open, its groups open, and a U reveal of the layer takes it in, so its keyframes show +/// (#273). A collapsed layer stays collapsed, as in After Effects. +pub fn keep_in_view(app: &mut EffectcraftApp, layer: LayerId, prop: u64) { + if !app.ui.timeline.open_layers.contains(&layer.0) { + return; + } + open_effect_paths(app, layer.0, &[prop]); + let tl = &mut app.ui.timeline; + if tl.layer_reveal.get(&layer.0).is_some_and(|k| k.iter().any(|k| k == "props")) { + tl.reveal_props.insert(prop); + } +} + fn collect_props(g: &PropGroup, out: &mut Vec, f: &dyn Fn(&Property) -> bool) { for c in &g.children { match c { diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index 4d75b8b1..b2107570 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -1456,10 +1456,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { Gesture::PuppetRecord { layer, pin, others, cti, samples, .. } => { let pts: Vec = samples.iter().map(|(t, p)| json!([t, p[0], p[1]])).collect(); let q = json!({"layer": layer.0, "pin": pin, "pins": others, "samples": pts, "start": cti.seconds()}); - if let Err(e) = app.session.execute("puppet.recordPin", q) { - app.ui.status = e.to_string(); + match app.session.execute("puppet.recordPin", q) { + Ok(_) => { + for p in std::iter::once(pin).chain(others) { + super::puppet_tool::reveal_pin(app, layer, p); + } + } + Err(e) => app.ui.status = e.to_string(), } } + Gesture::PuppetPin { layer, pin, .. } => super::puppet_tool::reveal_pin(app, layer, pin), Gesture::Create { tool, start } => { let end = map.to_comp(pos); create_shape(app, tool, start, end, mods.shift); @@ -1555,9 +1561,13 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { && let Some(inv) = l2c(&ectx, l).0.inverse() { let lp = inv.apply(gv2(cpt[0], cpt[1])); - let r = app.session.execute("puppet.addPin", json!({"layer": l.id.0, "kind": t.puppet_kind(), "position": [lp.x, lp.y]})); - if let Err(e) = r { - app.ui.status = e.to_string(); + match app.session.execute("puppet.addPin", json!({"layer": l.id.0, "kind": t.puppet_kind(), "position": [lp.x, lp.y]})) { + Ok(r) => { + if let Some(pin) = r["pin"].as_u64() { + super::puppet_tool::reveal_pin(app, l.id, pin); + } + } + Err(e) => app.ui.status = e.to_string(), } } } diff --git a/crates/ui-egui/tests/ui_viewer.rs b/crates/ui-egui/tests/ui_viewer.rs index 4a014ba7..61ea76ef 100644 --- a/crates/ui-egui/tests/ui_viewer.rs +++ b/crates/ui-egui/tests/ui_viewer.rs @@ -801,6 +801,96 @@ fn puppet_pin_click_selects_and_delete_removes_only_the_pins() { assert!(h.state().session.active_comp().unwrap().layer(LayerId(id)).is_some()); } +/// A pin's Position property and its keys' times (seconds). +fn pin_keys(h: &Harness<'_, EffectcraftApp>, layer: u64, pin: u64) -> (u64, Vec) { + let l = h.state().session.active_comp().unwrap().layer(LayerId(layer)).unwrap().clone(); + let pos = l.props.find_group(pin).unwrap().get("position").unwrap().clone(); + (pos.uid, pos.keys.iter().map(|k| k.time.seconds()).collect()) +} + +/// The keys the Timeline draws for a property, left to right. +fn timeline_keys(h: &Harness<'_, EffectcraftApp>, prop: u64) -> Vec { + let mut ks: Vec = + h.state().auto.query(&format!("timeline.key.{prop}.")).iter().map(|e| pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0)).collect(); + ks.sort_by(|a, b| a.x.total_cmp(&b.x)); + ks +} + +/// The pin the last click placed (placing a pin selects it). +fn placed_pin(h: &Harness<'_, EffectcraftApp>) -> u64 { + h.state().session.state.selected_props.last().map(|(_, u)| *u).unwrap() +} + +/// #273: U shows each pin's keyframed Position under its pin (Puppet ▸ Mesh 1 ▸ Deform ▸ Puppet +/// Pin 1 ▸ Position) rather than as one more "Position"; a pin moved or placed in the viewer +/// afterwards shows its keys there too (they were made but not shown); and pin keys select, move +/// and delete like any others. +#[test] +fn puppet_pin_keys_show_under_their_pins_in_the_timeline() { + let (mut h, id, bend) = puppet_harness(); + h.state_mut().ui.tool = Tool::Puppet; + let pins: Vec = h.state().auto.previous.iter().filter_map(|e| e.id.strip_prefix("viewer.puppetPin.")?.parse().ok()).filter(|p| *p != bend).collect(); + let ctx = h.ctx.clone(); + effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "anim.reveal", json!({"kind": "keyframes"})).unwrap(); + h.run_steps(3); + assert_eq!(pins.len(), 2); + for pin in &pins { + let row = h.state().auto.find(&format!("timeline.group.{pin}.name")).map(|e| e.label.clone()).unwrap_or_default(); + assert!(row.starts_with("Puppet Pin"), "the pin's row: {row:?}"); + assert_eq!(timeline_keys(&h, pin_keys(&h, id, *pin).0).len(), 1, "its key at 0 s"); + } + // Moved at 1 s: keyed there, in view. + h.state_mut().session.set_time(effectcraft_engine::time::Tick::from_seconds_f64(1.0)); + h.run_steps(2); + let pa = rect(&h, &format!("viewer.puppetPin.{}", pins[0])).center(); + drag(&mut h, pa, pa + vec2(20.0, 10.0)); + let (pos, times) = pin_keys(&h, id, pins[0]); + assert_eq!(times.len(), 2); + assert_eq!(timeline_keys(&h, pos).len(), 2, "both keys drawn"); + // A pin placed after U shows with its key too. + let at = screen(&h, [320.0, 210.0]); + click(&mut h, at); + let new = placed_pin(&h); + assert!(!pins.contains(&new) && new != bend); + assert!(h.state().auto.find(&format!("timeline.group.{new}.name")).is_some(), "the new pin's row"); + assert_eq!(timeline_keys(&h, pin_keys(&h, id, new).0).len(), 1, "and its key"); + // Click the 1 s key, drag it half a second later, delete it. + let ks = timeline_keys(&h, pos); + click(&mut h, ks[1]); + assert_eq!(h.state().session.state.selected_keys.iter().map(|k| k.prop).collect::>(), [pos]); + let half = (ks[1].x - ks[0].x) / 2.0; + drag(&mut h, ks[1], ks[1] + vec2(half, 0.0)); + let t = pin_keys(&h, id, pins[0]).1; + assert!((t[1] - 1.5).abs() < 0.05, "moved: {t:?}"); + h.input_mut().events.push(Event::Key { key: egui::Key::Delete, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }); + h.run_steps(2); + assert_eq!(pin_keys(&h, id, pins[0]).1, [0.0], "deleted; the pin stays"); +} + +/// #273: on a layer twirled open in the Timeline, placing a pin twirls open Effects ▸ Puppet ▸ +/// Mesh 1 ▸ Deform ▸ the pin, so its Position key shows; a collapsed layer stays collapsed, as +/// in After Effects. +#[test] +fn placing_a_pin_opens_its_groups_on_a_twirled_open_layer() { + let mut h = harness(); + let s = &mut h.state_mut().session; + s.execute("edit.clear", json!({"layers": ["Plate"]})).unwrap(); + s.execute("layer.select", json!({"layers": ["Box"]})).unwrap(); + let id = s.state.selected_layers[0].0; + h.state_mut().ui.tool = Tool::Puppet; + h.run_steps(2); + let at = screen(&h, [300.0, 160.0]); + click(&mut h, at); + assert!(h.state().ui.timeline.open_groups.is_empty() && h.state().ui.timeline.open_layers.is_empty(), "collapsed stays collapsed"); + let twirl = rect(&h, &format!("timeline.layer.{id}.twirl")).center(); + click(&mut h, twirl); + assert!(h.state().ui.timeline.open_layers.contains(&id)); + let at = screen(&h, [340.0, 200.0]); + click(&mut h, at); + let pin = placed_pin(&h); + assert_eq!(timeline_keys(&h, pin_keys(&h, id, pin).0).len(), 1, "the new pin's Position key shows"); +} + /// Filled circles painted this frame (flattening nested shape lists). fn circles(h: &Harness<'_, EffectcraftApp>) -> Vec { fn walk(s: &egui::Shape, out: &mut Vec) { From 3318cb0da1b4ab4b57e413121318a7b9e0a26cc4 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 8 Oct 2026 19:31:08 +0700 Subject: [PATCH 2/2] Import: dropped and picked files show an Importing card (#270) Files dropped on the window or picked in the Import dialog were probed one after the other on the UI thread: the app froze while a video was read, nothing said what was being imported or how many files were left, and only the last new item was selected. People ended up importing the same file again and again. file.import takes `background`: the files are probed in a background job (Window > Progress, jobs.list / jobs.cancel) that names the file being read and how many there are ("b.png (2 of 3)"); then they are added in one undo step, all selected (the Project panel scrolls to them, a file already in the project arrives again as in After Effects) and a toast reports the count ("Imported 3 items", with the first error). Drops and the Import dialog use it, and an Importing card at the bottom left shows the job with its progress bar and Cancel. Agents and scripts still import at once by default. The toast registers an automation id. Test: background_imports_report_progress_and_select_what_arrived (engine), dropped_files_show_import_progress_then_the_items_they_made (ui_drag_drop). Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands/file.rs | 353 ++++++++++++++-------- crates/engine/src/jobs.rs | 7 +- crates/engine/src/tests.rs | 63 ++++ crates/ui-egui/src/lib.rs | 14 +- crates/ui-egui/src/menus.rs | 4 + crates/ui-egui/src/panels/media_panels.rs | 40 ++- crates/ui-egui/src/prefs_live.rs | 10 +- crates/ui-egui/tests/ui_drag_drop.rs | 94 +++++- 8 files changed, 449 insertions(+), 136 deletions(-) diff --git a/crates/engine/src/commands/file.rs b/crates/engine/src/commands/file.rs index fe32d28a..fd215751 100644 --- a/crates/engine/src/commands/file.rs +++ b/crates/engine/src/commands/file.rs @@ -4,7 +4,7 @@ use effectcraft_color::Label; use effectcraft_project::{FootageKind, ItemId, ItemKind, LayerSource, Project}; use serde_json::{Value, json}; -use super::{CommandSpec, always, bad, str_p}; +use super::{CommandSpec, always, b_p, bad, str_p}; use crate::{EngineError, Result, Session, cmd}; fn has_path(s: &Session) -> std::result::Result<(), String> { @@ -103,16 +103,70 @@ fn revert(s: &mut Session, _: &Value) -> Result { /// File ▸ Import; with `addToComp` (files dropped on the Timeline or the Composition viewer) they /// also become layers of the active comp ([`add_to_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 +/// the file being read and how many are left (Window ▸ Progress, `jobs.list`), then adds them in +/// one undo step, selects them and reports how many arrived ("Imported 3 items"). fn import_cmd(s: &mut Session, p: &Value) -> Result { let target = s.active_comp_id(); - let mut r = import(s, p)?; - let errors = add_to_comp(s, &r, target, p); + let mut pending = prepare_import(s, p)?; + if b_p(p, "background").unwrap_or(false) && !pending.paths.is_empty() { + let paths = std::mem::take(&mut pending.paths); + let total = paths.len(); + let label = match paths.first() { + Some(one) if total == 1 => format!("Importing {}", file_name(one)), + _ => format!("Importing {total} files"), + }; + let p = p.clone(); + return s.spawn_task("import", label, false, move |ctl| { + let mut probed = Vec::with_capacity(total); + for (k, path) in paths.into_iter().enumerate() { + ctl.message(format!("{} ({} of {total})", file_name(&path), k + 1)); + if !ctl.progress(k as u64, total as u64) { + return Err(crate::render_queue::CANCELLED.into()); + } + let r = pending.prober.probe(&path); + probed.push(r); + } + ctl.progress(total as u64, total as u64); + let apply: crate::jobs::Apply = Box::new(move |s: &mut Session| { + let made = pending.comps.len() + probed.iter().filter(|r| r.is_ok()).count(); + let mut r = add_footage(s, pending, probed)?; + finish_import(s, &mut r, target, &p); + let mut toast = format!("Imported {made} item{}", if made == 1 { "" } else { "s" }); + let errors: Vec<&str> = r["errors"].as_array().into_iter().flatten().filter_map(Value::as_str).collect(); + match errors.as_slice() { + [] => {} + [one] => toast = format!("{toast}. {one}"), + [first, rest @ ..] => toast = format!("{toast}. {first} (and {} more)", rest.len()), + } + r["toast"] = json!(toast); + Ok(r) + }); + Ok(apply) + }); + } + let probed = pending.paths.iter().map(|path| pending.prober.probe(path)).collect(); + let mut r = add_footage(s, pending, probed)?; + finish_import(s, &mut r, target, p); + Ok(r) +} + +/// The file name of a path, for labels. +fn file_name(path: &str) -> String { + std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string()) +} + +/// 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. +fn finish_import(s: &mut Session, r: &mut Value, target: Option, p: &Value) { + let errors = add_to_comp(s, r, target, p); if !errors.is_empty() && let Some(e) = r.get_mut("errors").and_then(Value::as_array_mut) { e.extend(errors.into_iter().map(Value::from)); } - Ok(r) } /// With `addToComp`, put what an import (its result `r`) made into `target`, the comp that was @@ -149,7 +203,115 @@ pub(crate) fn add_to_comp(s: &mut Session, r: &Value, target: Option, p: errors } +/// 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 { + let pending = prepare_import(s, p)?; + let probed = pending.paths.iter().map(|path| pending.prober.probe(path)).collect(); + add_footage(s, pending, probed) +} + +/// An import whose layered files are already compositions: the files left to probe as footage, +/// how, and what the import made so far. +struct PendingImport { + paths: Vec, + prober: Prober, + comps: Vec, + items: Vec, + errors: Vec, +} + +/// Everything probing a file needs, apart from the session (it can run on a background thread). +struct Prober { + importer: Option>, + services: std::sync::Arc, + /// Settings ▸ Import ▸ Report Missing Frames. + report_missing_frames: bool, + /// Settings ▸ Import ▸ Interpret Unlabeled Alpha As, for stills and for movies. + still_alpha: Option, + movie_alpha: Option, + /// Settings ▸ Import ▸ Sequence Footage frames per second. + sequence_rate: effectcraft_time::FrameRate, + /// PDF / Illustrator page (0-based). + page: u32, + /// Choose Layer: one layer of a Photoshop document. + psd_layer: Option, +} + +/// A file probed as footage. +struct Probed { + path: String, + footage: effectcraft_project::Footage, + /// Its alpha is unlabeled and Settings say Ask User: open Interpret Footage. + ask_alpha: bool, + warning: Option, +} + +impl Prober { + /// Probe `path` as footage; the error names the file. + fn probe(&self, path: &str) -> std::result::Result { + let fail = |e: String| format!("{path}: {e}"); + // Data files (JSON, CSV, TSV) for data-driven animation: kept as text in the project. + if let Some(f) = data_footage(self.services.as_ref(), path) { + return f.map(|footage| Probed { path: path.to_string(), footage, ask_alpha: false, warning: None }).map_err(fail); + } + let importer = self.importer.as_ref().ok_or_else(|| fail("media import is not available in this build".into()))?; + let mut f = importer.probe(path).map_err(fail)?; + let mut warning = None; + // Settings ▸ Import ▸ Report Missing Frames. + if f.kind == FootageKind::Sequence + && self.report_missing_frames + && let Some(gaps) = missing_frames(&f.sequence) + { + warning = Some(format!("{path}: {gaps}")); + } + // Settings ▸ Import ▸ Interpret Unlabeled Alpha As. + let mut ask_alpha = false; + if f.alpha != effectcraft_project::AlphaMode::Ignore && !alpha_is_labeled(path, &f.codec) { + let setting = if f.kind == FootageKind::Video { self.movie_alpha } else { self.still_alpha }; + match setting { + Some(a) => f.alpha = a, + None => ask_alpha = true, + } + } + // Settings ▸ Import ▸ Sequence Footage frames per second. + if f.kind == FootageKind::Sequence { + let r = self.sequence_rate; + let frames = f.frame_rate.frame_at(f.duration); + f.frame_rate = r; + f.duration = r.tick_of(frames.max(1)); + } + // Page: another page of a PDF / Illustrator file. + if self.page > 0 && matches!(f.codec.as_str(), "PDF" | "AI") { + let doc = self + .services + .read_file(path) + .map_err(|e| e.to_string()) + .and_then(|b| effectcraft_pdf::parse_page(&b, self.page as usize).map_err(|e| e.to_string())) + .map_err(fail)?; + (f.width, f.height) = doc.pixel_size(); + f.page = self.page; + } + // Choose Layer: one layer of a Photoshop document (document-sized). + if let Some(sel) = &self.psd_layer + && f.codec == "PSD" + { + let (index, name) = self + .services + .read_file(path) + .ok() + .and_then(|b| effectcraft_psd::Psd::parse(b).ok()) + .and_then(|d| find_psd_layer(&d, sel)) + .ok_or_else(|| fail(format!("no layer {sel}")))?; + f.layer = Some(effectcraft_project::SourceLayer { index: index as u32, name, layer_size: false, embedded: None, placed: false }); + } + Ok(Probed { path: path.to_string(), footage: f, ask_alpha, warning }) + } +} + +/// The first part of an import: read the parameters, note the files as recent footage, and +/// import layered files (Photoshop, PDF, Illustrator, EPS) as compositions when `importAs` (or, +/// for dropped files, Default Drag Import As) asks for that. The other files are left to probe. +fn prepare_import(s: &mut Session, p: &Value) -> Result { let paths: Vec = match p.get("paths").or(p.get("path")) { Some(Value::Array(a)) => a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect(), Some(Value::String(x)) => vec![x.clone()], @@ -178,8 +340,8 @@ pub(crate) fn import(s: &mut Session, p: &Value) -> Result { }, }; let mut paths = paths; - let mut out_comps = vec![]; - let mut ids = vec![]; + let mut comps = vec![]; + let mut items = vec![]; let mut errors = vec![]; if let Some(retain) = retain { let mut rest = vec![]; @@ -189,9 +351,9 @@ pub(crate) fn import(s: &mut Session, p: &Value) -> Result { Ok(b) if effectcraft_pdf::sniff(&b).is_some() && !path.to_ascii_lowercase().ends_with(".svg") => { // PDF / Illustrator / EPS: one layer per file layer. match import_vector_comp(s, &path, &b, page) { - Ok((comp, items)) => { - out_comps.push(comp); - ids.extend(items); + Ok((comp, made)) => { + comps.push(comp); + items.extend(made); } Err(e) => errors.push(e.to_string()), } @@ -203,127 +365,76 @@ pub(crate) fn import(s: &mut Session, p: &Value) -> Result { } }; match import_psd_comp(s, &path, bytes, retain) { - Ok((comp, items, warnings)) => { - out_comps.push(comp); - ids.extend(items); + Ok((comp, made, warnings)) => { + comps.push(comp); + items.extend(made); errors.extend(warnings); } Err(e) => errors.push(format!("{path}: {e}")), } } paths = rest; - if paths.is_empty() { - if let Some(c) = out_comps.first() { - s.open_comp(effectcraft_project::ItemId(*c)); - } - return Ok(json!({"items": ids, "comps": out_comps, "errors": errors})); - } } - let mut probed = vec![]; - let psd_layer = p.get("layer").cloned(); - let seq_rate = effectcraft_time::FrameRate::from_f64(s.prefs.import.sequence_fps); - let mut ask_alpha = vec![]; + let prober = Prober { + importer: s.importer.clone(), + services: s.services.clone(), + report_missing_frames: s.prefs.import.report_missing_frames, + still_alpha: s.prefs.unlabeled_alpha(false), + movie_alpha: s.prefs.unlabeled_alpha(true), + sequence_rate: effectcraft_time::FrameRate::from_f64(s.prefs.import.sequence_fps), + page, + psd_layer: p.get("layer").cloned(), + }; + Ok(PendingImport { paths, prober, comps, items, errors }) +} + +/// The last part of an import: add the probed files to the project (one undo step) and select +/// everything the import made, so the Project panel shows it. +fn add_footage(s: &mut Session, pending: PendingImport, probed: Vec>) -> Result { + let PendingImport { comps, mut items, mut errors, .. } = pending; + let mut found = vec![]; let mut warnings = vec![]; - for path in &paths { - // Data files (JSON, CSV, TSV) for data-driven animation: kept as text in the project. - if let Some(f) = data_footage(s, path) { - match f { - Ok(f) => probed.push((path.clone(), f)), - Err(e) => errors.push(format!("{path}: {e}")), + for r in probed { + match r { + Ok(f) => { + warnings.extend(f.warning.clone()); + found.push(f); } - continue; - } - let Some(importer) = s.importer.clone() else { - errors.push(format!("{path}: media import is not available in this build")); - continue; - }; - match importer.probe(path) { - Ok(mut f) => { - // Settings ▸ Import ▸ Report Missing Frames. - if f.kind == FootageKind::Sequence - && s.prefs.import.report_missing_frames - && let Some(gaps) = missing_frames(&f.sequence) - { - warnings.push(format!("{path}: {gaps}")); - } - // Settings ▸ Import ▸ Interpret Unlabeled Alpha As. - if f.alpha != effectcraft_project::AlphaMode::Ignore && !alpha_is_labeled(path, &f.codec) { - match s.prefs.unlabeled_alpha(f.kind == FootageKind::Video) { - Some(a) => f.alpha = a, - None => ask_alpha.push(paths.iter().position(|x| x == path).unwrap_or(0)), - } - } - // Settings ▸ Import ▸ Sequence Footage frames per second. - if f.kind == FootageKind::Sequence { - let r = seq_rate; - let frames = f.frame_rate.frame_at(f.duration); - f.frame_rate = r; - f.duration = r.tick_of(frames.max(1)); - } - // Page: another page of a PDF / Illustrator file. - if page > 0 && matches!(f.codec.as_str(), "PDF" | "AI") { - match s - .services - .read_file(path) - .map_err(|e| e.to_string()) - .and_then(|b| effectcraft_pdf::parse_page(&b, page as usize).map_err(|e| e.to_string())) - { - Ok(doc) => { - (f.width, f.height) = doc.pixel_size(); - f.page = page; - } - Err(e) => { - errors.push(format!("{path}: {e}")); - continue; - } - } - } - // Choose Layer: one layer of a Photoshop document (document-sized). - if let Some(sel) = &psd_layer - && f.codec == "PSD" - { - match s.services.read_file(path).ok().and_then(|b| effectcraft_psd::Psd::parse(b).ok()).and_then(|d| find_psd_layer(&d, sel)) { - Some((index, name)) => { - f.layer = Some(effectcraft_project::SourceLayer { index: index as u32, name, layer_size: false, embedded: None, placed: false }) - } - None => { - errors.push(format!("{path}: no layer {sel}")); - continue; - } - } - } - probed.push((path.clone(), f)) - } - Err(e) => errors.push(format!("{path}: {e}")), + Err(e) => errors.push(e), } } let mut asked: Vec = vec![]; - s.edit("Import", None, |proj, st| { - for (path, f) in probed { - let name = std::path::Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or(path.clone()); - let name = match &f.layer { - Some(l) => format!("{}/{name}", l.name), - None if f.page > 0 => format!("{name} (Page {})", f.page + 1), - None => name, - }; - let label = match f.kind { - FootageKind::Still | FootageKind::Sequence => Label::Lavender, - FootageKind::Audio => Label::SeaFoam, - FootageKind::Video | FootageKind::Model => Label::Aqua, - FootageKind::Data => Label::Sandstone, - }; - let ask = ask_alpha.contains(&paths.iter().position(|x| *x == path).unwrap_or(usize::MAX)); - let id = proj.add_item(&name, label, None, ItemKind::Footage(f)); - ids.push(id.0); - if ask { - asked.push(id.0); + let made: Vec = comps.iter().map(|c| ItemId(*c)).collect(); + if !found.is_empty() { + s.edit("Import", None, |proj, st| { + let mut selection = made; + for f in found { + let name = file_name(&f.path); + let footage = f.footage; + let name = match &footage.layer { + Some(l) => format!("{}/{name}", l.name), + None if footage.page > 0 => format!("{name} (Page {})", footage.page + 1), + None => name, + }; + let label = match footage.kind { + FootageKind::Still | FootageKind::Sequence => Label::Lavender, + FootageKind::Audio => Label::SeaFoam, + FootageKind::Video | FootageKind::Model => Label::Aqua, + FootageKind::Data => Label::Sandstone, + }; + let id = proj.add_item(&name, label, None, ItemKind::Footage(footage)); + items.push(id.0); + if f.ask_alpha { + asked.push(id.0); + } + selection.push(id); } - st.project_selection = vec![id]; - } - Ok(()) - })?; - if let Some(c) = out_comps.first() { - s.open_comp(effectcraft_project::ItemId(*c)); + st.project_selection = selection; + Ok(()) + })?; + } + if let Some(c) = comps.first() { + s.open_comp(ItemId(*c)); } for w in &warnings { s.events.push(crate::Event::Toast { message: w.clone(), error: true }); @@ -332,7 +443,7 @@ pub(crate) fn import(s: &mut Session, p: &Value) -> Result { if let Some(first) = asked.first() { s.events.push(crate::Event::Frontend { command: "file.interpretFootage".into(), params: json!({"item": first, "reason": "unlabeledAlpha"}) }); } - Ok(json!({"items": ids, "comps": out_comps, "errors": errors, "warnings": warnings, "unlabeledAlpha": asked})) + Ok(json!({"items": items, "comps": comps, "errors": errors, "warnings": warnings, "unlabeledAlpha": asked})) } /// Whether a file format states how its alpha is stored (PNG, GIF, WebP and PSD are straight by @@ -416,13 +527,13 @@ fn import_vector_comp(s: &mut Session, path: &str, bytes: &[u8], page: u32) -> R pub const DATA_EXTENSIONS: &[&str] = &["json", "csv", "tsv"]; /// A data footage item for `path` (JSON / CSV / TSV), `None` for other files. -pub(crate) fn data_footage(s: &Session, path: &str) -> Option> { +pub(crate) fn data_footage(services: &dyn crate::Services, path: &str) -> Option> { let ext = std::path::Path::new(path).extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase(); if !DATA_EXTENSIONS.contains(&ext.as_str()) { return None; } Some((|| { - let bytes = s.services.read_file(path).map_err(|e| e.to_string())?; + let bytes = services.read_file(path).map_err(|e| e.to_string())?; let text = String::from_utf8(bytes).map_err(|_| "data files must be UTF-8 text".to_string())?; if ext == "json" { serde_json::from_str::(text.trim_start_matches('\u{feff}')).map_err(|e| format!("invalid JSON: {e}"))?; @@ -585,7 +696,7 @@ pub fn specs() -> Vec { "File...", ["File", "Import"], Some("Cmd+I"), - "{paths: [string], 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), addToComp?: bool (also add them to the active comp, at time?, index?, position? as in layer.addItem)}", + "{paths: [string], 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), 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})}", always, import_cmd ), diff --git a/crates/engine/src/jobs.rs b/crates/engine/src/jobs.rs index f022a312..229d1950 100644 --- a/crates/engine/src/jobs.rs +++ b/crates/engine/src/jobs.rs @@ -1,6 +1,7 @@ //! Background jobs: one list (Window ▸ Progress, `jobs.list`, `jobs.cancel`) over the render //! queue, the analyses (tracker, mask tracker, Warp Stabilizer, 3D Camera Tracker, Roto Brush) and -//! the generic background tasks started here (Content-Aware Fill, Scene Edit Detection). +//! the generic background tasks started here (Content-Aware Fill, Scene Edit Detection, footage +//! checks and imports). //! //! A generic task runs a closure on a background thread (inline on wasm32 or when the caller //! waits). The closure reports progress through [`TaskCtl`] and returns an [`Apply`]: a function @@ -162,7 +163,8 @@ impl Session { let cancelled = t.ctl.cancelled(); let (status, message, result) = match r { Some(Ok(apply)) if !cancelled => match apply(self) { - Ok(v) => ("done", String::new(), v), + // A task may say how it went (`toast`: "Imported 3 items"). + Ok(v) => ("done", v.get("toast").and_then(Value::as_str).unwrap_or_default().to_string(), v), Err(e) => ("failed", e.to_string(), Value::Null), }, Some(Ok(_)) => ("cancelled", String::new(), Value::Null), @@ -180,6 +182,7 @@ impl Session { seconds: t.started.elapsed().as_secs_f64(), }; let toast = match status { + "done" if !message.is_empty() => message.clone(), "done" => format!("{} finished", t.label), "cancelled" => format!("{} cancelled", t.label), _ => format!("{} failed: {message}", t.label), diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ef467b93..0fa0fd0f 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -96,6 +96,69 @@ fn imported_files_go_into_the_comp_where_they_were_dropped() { assert!(s.execute("layer.addItem", json!({"item": "a.png", "position": [1.0]})).is_err(), "position needs x and y"); } +/// #270: files dropped or picked in the Import dialog (`file.import {background}`) are probed in +/// a background job that says which file it is reading and how many are left; then they arrive +/// in one undo step, all selected, with a count. The same file imported again arrives again. +#[test] +fn background_imports_report_progress_and_select_what_arrived() { + use effectcraft_project::{Footage, FootageKind, ItemId}; + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + /// Probes file k once `allow` > k (a slow video; 5 s at most, so an import that waits for it + /// fails the test instead of hanging it); `.xyz` files fail. + struct Gated { + started: AtomicUsize, + allow: Arc, + } + impl crate::Importer for Gated { + fn probe(&self, path: &str) -> Result { + let k = self.started.fetch_add(1, Ordering::SeqCst); + let until = std::time::Instant::now() + std::time::Duration::from_secs(5); + while self.allow.load(Ordering::SeqCst) <= k && std::time::Instant::now() < until { + std::thread::sleep(std::time::Duration::from_millis(1)); + } + 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 allow = Arc::new(AtomicUsize::new(1)); + let mut s = Session { importer: Some(Arc::new(Gated { started: AtomicUsize::new(0), allow: allow.clone() })), ..Default::default() }; + s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "frameRate": 30, "duration": 4})).unwrap(); + let undo = s.history.undo.len(); + let r = s.execute("file.import", json!({"paths": ["/drop/a.png", "/drop/b.png", "/drop/a.png"], "background": true})).unwrap(); + let job = r["job"].as_str().unwrap().to_string(); + // The first file is through; the second is being read. + let running = |s: &mut Session| s.execute("jobs.list", json!({})).unwrap()["running"].as_array().unwrap().clone(); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + while running(&mut s)[0]["done"] != json!(1) && std::time::Instant::now() < deadline { + std::thread::sleep(std::time::Duration::from_millis(2)); + } + let row = running(&mut s)[0].clone(); + assert_eq!(row["id"], json!(job)); + assert_eq!(row["label"], json!("Importing 3 files")); + assert_eq!(row["message"], json!("b.png (2 of 3)")); + assert_eq!((row["done"].as_u64(), row["total"].as_u64()), (Some(1), Some(3))); + assert!(s.project.items.values().all(|i| i.name != "a.png"), "nothing is added before the import finishes"); + allow.store(usize::MAX, Ordering::SeqCst); + s.execute("jobs.wait", json!({})).unwrap(); + let names = |s: &Session, ids: &[ItemId]| ids.iter().map(|i| s.project.item(*i).unwrap().name.clone()).collect::>(); + assert_eq!(names(&s, &s.state.project_selection), ["a.png", "b.png", "a.png"], "everything imported is selected, the same file twice too"); + assert_eq!(s.history.undo.len(), undo + 1, "one undo step"); + let rec = s.job_log.last().unwrap(); + assert_eq!((rec.status.as_str(), rec.message.as_str()), ("done", "Imported 3 items")); + assert!(s.drain_events().iter().any(|e| matches!(e, crate::Event::Toast { message, .. } if message == "Imported 3 items"))); + // Dropped on the viewer: they become layers too; a file that can't be read is reported. + s.execute("file.import", json!({"paths": ["/drop/c.png", "/drop/d.xyz"], "background": true, "addToComp": true, "position": [10.0, 20.0]})).unwrap(); + s.execute("jobs.wait", json!({})).unwrap(); + assert_eq!(s.active_comp().unwrap().layers.iter().map(|l| l.name.as_str()).collect::>(), ["c.png"]); + assert_eq!(s.job_log.last().unwrap().message, "Imported 1 item. /drop/d.xyz: unsupported file"); + // Agents and scripts import at once by default. + let r = s.execute("file.import", json!({"paths": ["/drop/e.png"]})).unwrap(); + assert_eq!(r["items"].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() { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index d35630b8..691a3385 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1383,7 +1383,7 @@ impl EffectcraftApp { self.session.poll_mask_track(); ctx.request_repaint_after(std::time::Duration::from_millis(50)); } - // Background tasks (Content-Aware Fill, Scene Edit Detection). + // Background tasks (Content-Aware Fill, Scene Edit Detection, imports). if !self.session.tasks.is_empty() { self.session.poll_jobs(); ctx.request_repaint_after(std::time::Duration::from_millis(100)); @@ -1451,10 +1451,13 @@ impl EffectcraftApp { panels::scriptui_view::sync_panels(self); panels::scriptui_view::show_windows(self, &ctx); panels::learn::coach(self, &ctx); - self.draw_toast(ui, full); + let lift = panels::media_panels::import_card(self, ui, full); + self.draw_toast(ui, full, lift); } - fn draw_toast(&mut self, ui: &mut egui::Ui, full: egui::Rect) { + /// The status message or the latest toast, bottom left (`lift` points higher while the + /// Importing card is there). + fn draw_toast(&mut self, ui: &mut egui::Ui, full: egui::Rect, lift: f32) { let now = ui.input(|i| i.time); let msg = if !self.ui.status.is_empty() { Some(self.ui.status.clone()) @@ -1463,11 +1466,12 @@ impl EffectcraftApp { }; if let Some(m) = msg { let t = &self.tokens; - let galley = ui.painter().layout_no_wrap(m, Tokens::ui(12.0), t.text); - let r = egui::Rect::from_min_size(egui::pos2(full.min.x + 16.0, full.max.y - 44.0), galley.size() + egui::vec2(24.0, 14.0)); + let galley = ui.painter().layout_no_wrap(m.clone(), Tokens::ui(12.0), t.text); + let r = egui::Rect::from_min_size(egui::pos2(full.min.x + 16.0, full.max.y - 44.0 - lift), galley.size() + egui::vec2(24.0, 14.0)); ui.painter().rect_filled(r, 6.0, t.panel_bg); ui.painter().rect_stroke(r, 6.0, egui::Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside); ui.painter().galley(r.min + egui::vec2(12.0, 7.0), galley, t.text); + self.auto.add("toast", r, &m); let resp = ui.interact(r, egui::Id::new("toast"), egui::Sense::click()); if resp.clicked() { self.ui.status.clear(); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 7cf16411..388658aa 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1158,6 +1158,10 @@ fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option f32 { + if app.session.tasks.is_empty() { + return 0.0; + } + let t = app.tokens; + let w = 320.0_f32.min(full.width() - 32.0).max(120.0); + let mut bottom = full.max.y - 16.0; + for j in app.session.jobs().into_iter().filter(|j| j.kind == "import" && j.running) { + let r = Rect::from_min_max(pos2(full.min.x + 16.0, bottom - 56.0), pos2(full.min.x + 16.0 + w, bottom)); + let p = ui.painter(); + p.rect_filled(r, 6.0, t.panel_bg); + p.rect_stroke(r, 6.0, Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside); + let x0 = r.min.x + 12.0; + let inner = w - 24.0; + widgets::text_fit(p, pos2(x0, r.min.y + 15.0), Align2::LEFT_CENTER, &j.label, Tokens::medium(12.0), inner - 64.0, t.text); + widgets::text_fit(p, pos2(x0, r.min.y + 32.0), Align2::LEFT_CENTER, &j.message, Tokens::ui(11.5), inner, t.text_dim); + progress_bar(p, Rect::from_min_size(pos2(x0, r.min.y + 42.0), vec2(inner, 6.0)), j.fraction.unwrap_or(0.0) as f32, &t); + app.auto.add(&format!("import.job.{}", j.id), r, &j.message); + if kit::button(app, ui, Rect::from_min_size(pos2(r.max.x - 68.0, r.min.y + 6.0), vec2(56.0, 18.0)), &format!("import.cancel.{}", j.id), "Cancel", false) + { + kit::exec(app, "jobs.cancel", json!({"job": j.id})); + } + bottom = r.min.y - 8.0; + } + full.max.y - 16.0 - bottom +} diff --git a/crates/ui-egui/src/prefs_live.rs b/crates/ui-egui/src/prefs_live.rs index 41a4932b..8627c9c6 100644 --- a/crates/ui-egui/src/prefs_live.rs +++ b/crates/ui-egui/src/prefs_live.rs @@ -17,9 +17,10 @@ pub fn frame(app: &mut EffectcraftApp, ctx: &egui::Context) { video_preview(app, ctx); } -/// Files dropped on the window are imported (layered files as Default Drag Import As says). -/// Dropped on the Composition viewer, they also become layers centred there, as in After -/// Effects (#85), when the platform reports where the pointer is during file drags. +/// Files dropped on the window are imported (layered files as Default Drag Import As says) in +/// the background, with the Importing card showing what is read (#270). Dropped on the +/// Composition viewer, they also become layers centred there, as in After Effects (#85), when the +/// platform reports where the pointer is during file drags. fn dropped_files(app: &mut EffectcraftApp, ctx: &egui::Context) { let paths: Vec = ctx.input(|i| i.raw.dropped_files.iter().map(|f| f.path().to_string_lossy().to_string()).filter(|p| std::path::Path::new(p).is_absolute()).collect()); @@ -39,7 +40,8 @@ fn dropped_files(app: &mut EffectcraftApp, ctx: &egui::Context) { let viewer = app.auto.find("viewer.area").map(|e| Rect::from_min_size(egui::pos2(e.rect[0], e.rect[1]), egui::vec2(e.rect[2], e.rect[3]))); let at = ctx.input(|i| i.pointer.hover_pos()).filter(|p| viewer.is_some_and(|v| v.contains(*p))).and_then(|p| crate::panels::viewer::screen_to_comp(ctx, p)); - let params = json!({"paths": rest, "drag": true, "importAs": app.session.prefs.drag_import_as(), "addToComp": at.is_some(), "position": at}); + let params = + json!({"paths": rest, "drag": true, "importAs": app.session.prefs.drag_import_as(), "addToComp": at.is_some(), "position": at, "background": true}); if let Err(e) = crate::menus::invoke(app, ctx, "file.import", params) { app.ui.status = e; } diff --git a/crates/ui-egui/tests/ui_drag_drop.rs b/crates/ui-egui/tests/ui_drag_drop.rs index b05a235a..7c35f6ec 100644 --- a/crates/ui-egui/tests/ui_drag_drop.rs +++ b/crates/ui-egui/tests/ui_drag_drop.rs @@ -1,7 +1,11 @@ //! Drag and drop between panels (egui_kittest, real pointer drags): Project //! items land in the Timeline where they are dropped, between layers and, over the time graph, //! starting there (#89); dropped on the Composition viewer they are centred where they land -//! (#85); an effect dropped on the viewer goes on the layer under the pointer (#88). +//! (#85); an effect dropped on the viewer goes on the layer under the pointer (#88). Files +//! dropped on the window import with visible progress (#270). + +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; use effectcraft_engine::Session; use effectcraft_engine::keyframe::Value as KV; @@ -165,3 +169,91 @@ fn project_items_dropped_on_new_comp_make_a_composition() { assert!(h.state().auto.find("form.field.single").is_some(), "the New Composition from Selection dialog"); assert_eq!(comps(&h), before + 1, "nothing made yet"); } + +/// A file dropped on the window (egui hands it over as a dropped file). +#[derive(Debug)] +struct Dropped(std::path::PathBuf); + +impl egui::DroppedFile for Dropped { + fn path(&self) -> &std::path::Path { + &self.0 + } + fn bytes(&self) -> Result, String> { + Err("not read".into()) + } +} + +/// Footage probes that hold file k until `allow` > k (slow video decoding), 5 s at most (an +/// import that blocks the app then fails the test instead of hanging it). +struct Gated { + started: AtomicUsize, + allow: Arc, +} + +impl effectcraft_engine::Importer for Gated { + fn probe(&self, path: &str) -> Result { + let k = self.started.fetch_add(1, Ordering::SeqCst); + let until = std::time::Instant::now() + std::time::Duration::from_secs(5); + while self.allow.load(Ordering::SeqCst) <= k && std::time::Instant::now() < until { + std::thread::sleep(std::time::Duration::from_millis(1)); + } + Ok(effectcraft_engine::project::Footage { path: path.into(), width: 64, height: 32, has_video: true, ..Default::default() }) + } +} + +/// Step the app until `done` holds (the import runs on another thread). +fn step_until(h: &mut Harness<'_, EffectcraftApp>, done: impl Fn(&Harness<'_, EffectcraftApp>) -> bool) { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + while !done(h) { + assert!(std::time::Instant::now() < deadline, "timed out"); + std::thread::sleep(std::time::Duration::from_millis(2)); + h.step(); + } +} + +/// #270: files dropped on the window import in the background with an Importing card that names +/// the file being read and how many are left (the app keeps drawing meanwhile); then they are +/// selected and shown in the Project panel, with the count in a toast. The same file dropped +/// again arrives again, selected. +#[test] +fn dropped_files_show_import_progress_then_the_items_they_made() { + let (mut h, _) = harness(); + let allow = Arc::new(AtomicUsize::new(1)); + h.state_mut().session.importer = Some(Arc::new(Gated { started: AtomicUsize::new(0), allow: allow.clone() })); + let file = |n: &str| std::env::temp_dir().join(n); + for n in ["a.png", "b.png", "c.png"] { + h.input_mut().dropped_files.push(Arc::new(Dropped(file(n)))); + } + h.run_steps(2); + let card = |h: &Harness<'_, EffectcraftApp>| h.state().auto.query("import.job.").first().map(|e| e.label.clone()); + step_until(&mut h, |h| card(h).as_deref() == Some("b.png (2 of 3)")); + let r = rect(&h, &card_id(&h)); + assert!(r.min.x < 40.0 && r.max.y > 960.0, "bottom left, over the panels (where toasts show): {r:?}"); + assert!(h.state().auto.find(&card_id(&h).replace("import.job.", "import.cancel.")).is_some(), "with Cancel"); + let items = |h: &Harness<'_, EffectcraftApp>| h.state().session.project.items.values().filter(|i| i.name.ends_with(".png")).count(); + assert_eq!(items(&h), 0, "nothing added until the import finishes"); + allow.store(usize::MAX, Ordering::SeqCst); + step_until(&mut h, |h| card(h).is_none() && items(h) == 3); + h.run_steps(2); + let st = &h.state().session.state; + let names: Vec = st.project_selection.iter().map(|i| h.state().session.project.item(*i).unwrap().name.clone()).collect(); + assert_eq!(names, ["a.png", "b.png", "c.png"], "what arrived is selected"); + let last = st.project_selection[2].0; + assert!(h.state().auto.find(&format!("project.item.{last}")).is_some(), "and in view in the Project panel"); + assert_eq!(h.state().auto.find("toast").map(|e| e.label.as_str()), Some("Imported 3 items")); + + // The same file again: imported again, and that one is selected. + h.input_mut().dropped_files.push(Arc::new(Dropped(file("a.png")))); + h.run_steps(2); + step_until(&mut h, |h| items(h) == 4); + h.run_steps(2); + let sel = &h.state().session.state.project_selection; + assert_eq!(sel.len(), 1); + assert_eq!(h.state().session.project.item(sel[0]).unwrap().name, "a.png"); + assert_eq!(h.state().auto.find("toast").map(|e| e.label.as_str()), Some("Imported 1 item")); +} + +/// The automation id of the Importing card on screen. +fn card_id(h: &Harness<'_, EffectcraftApp>) -> String { + h.state().auto.query("import.job.").first().map(|e| e.id.clone()).unwrap_or_default() +}