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 1cef8b57..388658aa 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")), @@ -1153,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/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/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() +} 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) {