Merge pull request #282 from storytold/fix/270-273-import-puppet-keys

Puppet pin keys show in the Timeline; imports show an Importing card (#273, #270)
This commit is contained in:
BFlat authored and GitHub committed 2026-10-08 20:44:26 +07:00
commit b0fa9f1943
12 files changed
+684 -151

No files matched your search

+232 -121
View File
@@ -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<Value> {
/// File ▸ Import; with `addToComp` (files dropped on the Timeline or the Composition viewer) they
/// also become layers of the active comp ([`add_to_comp`]).
///
/// 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<Value> {
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<ItemId>, 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<ItemId>, 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<Value> {
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<String>,
prober: Prober,
comps: Vec<u64>,
items: Vec<u64>,
errors: Vec<String>,
}
/// Everything probing a file needs, apart from the session (it can run on a background thread).
struct Prober {
importer: Option<std::sync::Arc<dyn crate::Importer>>,
services: std::sync::Arc<dyn crate::Services>,
/// Settings ▸ Import ▸ Report Missing Frames.
report_missing_frames: bool,
/// Settings ▸ Import ▸ Interpret Unlabeled Alpha As, for stills and for movies.
still_alpha: Option<effectcraft_project::AlphaMode>,
movie_alpha: Option<effectcraft_project::AlphaMode>,
/// 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<Value>,
}
/// 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<String>,
}
impl Prober {
/// Probe `path` as footage; the error names the file.
fn probe(&self, path: &str) -> std::result::Result<Probed, String> {
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<PendingImport> {
let paths: Vec<String> = 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<Value> {
},
};
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<Value> {
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<Value> {
}
};
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<std::result::Result<Probed, String>>) -> Result<Value> {
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<u64> = 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<ItemId> = 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<Value> {
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<std::result::Result<effectcraft_project::Footage, String>> {
pub(crate) fn data_footage(services: &dyn crate::Services, path: &str) -> Option<std::result::Result<effectcraft_project::Footage, String>> {
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::<Value>(text.trim_start_matches('\u{feff}')).map_err(|e| format!("invalid JSON: {e}"))?;
@@ -585,7 +696,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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
),
+5 -2
View File
@@ -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),
+63
View File
@@ -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<AtomicUsize>,
}
impl crate::Importer for Gated {
fn probe(&self, path: &str) -> Result<Footage, String> {
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::<Vec<_>>();
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::<Vec<_>>(), ["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() {
+9 -5
View File
@@ -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();
+10 -1
View File
@@ -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<Res
};
let Some(v) = picked else { return Some(Ok(Value::Null)) };
p.insert(key.to_string(), v);
// Picked files import in the background, with the Importing card showing progress (#270).
if matches!(id, "file.import" | "file.importMultiple") {
p.insert("background".into(), Value::Bool(true));
}
// Photoshop files ask how to import them first.
if id == "file.import" && crate::panels::dialogs::open_form(app, id, &Value::Object(p.clone())) {
return Some(Ok(json!({"dialog": id})));
+37 -3
View File
@@ -342,9 +342,7 @@ pub fn progress(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let frac = j.fraction.unwrap_or(0.0) as f32;
let pct = j.fraction.map(|f| format!("{:.0} %", f * 100.0)).unwrap_or_else(|| "…".into());
p.text(pos2(x0 + w - 70.0, y + 8.0), Align2::RIGHT_CENTER, format!("{pct} {}", j.message), Tokens::ui(11.0), t.text_dim);
let bar = Rect::from_min_size(pos2(x0, y + 20.0), vec2(w - 76.0, 6.0));
p.rect_filled(bar, 3.0, t.field_bg);
p.rect_filled(Rect::from_min_size(bar.min, vec2(bar.width() * frac.clamp(0.0, 1.0), bar.height())), 3.0, t.accent);
progress_bar(&p, Rect::from_min_size(pos2(x0, y + 20.0), vec2(w - 76.0, 6.0)), frac, &t);
app.auto.add(&format!("progress.job.{}", j.id), Rect::from_min_size(pos2(x0, y), vec2(w, 28.0)), &j.label);
if j.cancellable
&& kit::button(app, ui, Rect::from_min_size(pos2(x0 + w - 64.0, y + 12.0), vec2(64.0, 18.0)), &format!("progress.cancel.{}", j.id), "Cancel", false)
@@ -374,3 +372,39 @@ pub fn progress(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
}
}
/// A job's progress bar (`frac` 0…1).
fn progress_bar(p: &egui::Painter, bar: Rect, frac: f32, t: &Tokens) {
p.rect_filled(bar, 3.0, t.field_bg);
p.rect_filled(Rect::from_min_size(bar.min, vec2(bar.width() * frac.clamp(0.0, 1.0), bar.height())), 3.0, t.accent);
}
/// The Importing card, bottom left over the panels while files import in the background (dropped
/// or picked in the Import dialog): the file being read, how many are left, and Cancel (#270).
/// Returns the height it takes, so toasts sit above it.
pub fn import_card(app: &mut EffectcraftApp, ui: &mut egui::Ui, full: Rect) -> 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
}
+10
View File
@@ -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] {
+114 -9
View File
@@ -685,8 +685,11 @@ fn build_rows(app: &EffectcraftApp, comp: &Comp) -> Vec<Row> {
fn reveal_rows(app: &EffectcraftApp, l: &Layer, kinds: &[String], rows: &mut Vec<Row>) {
let tl = &app.ui.timeline;
let has = |k: &str| kinds.iter().any(|r| r == k);
let group_rows = |rows: &mut Vec<Row>, g: &PropGroup, fx: bool| {
// Groups shown with their whole contents: the revealed properties inside aren't listed again.
let mut shown_groups: Vec<u64> = vec![];
let mut group_rows = |rows: &mut Vec<Row>, 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<u64> = 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<Row>, l: &Layer, shown: &std::collections::BTreeSet<u64>, skip: &[u64], open: &std::collections::BTreeSet<u64>) {
struct Tree<'a> {
l: &'a Layer,
wanted: &'a dyn Fn(&Property) -> bool,
skip: &'a [u64],
open: &'a std::collections::BTreeSet<u64>,
}
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<Row>, 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<u64> {
let path = l.props.path_of(uid).unwrap_or_default();
let mut ids: Vec<u64> = 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<u64>, f: &dyn Fn(&Property) -> bool) {
for c in &g.children {
match c {
+15 -5
View File
@@ -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<serde_json::Value> = 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(),
}
}
}
+6 -4
View File
@@ -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<String> =
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;
}
+93 -1
View File
@@ -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<Vec<u8>, 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<AtomicUsize>,
}
impl effectcraft_engine::Importer for Gated {
fn probe(&self, path: &str) -> Result<effectcraft_engine::project::Footage, String> {
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<String> = 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()
}
+90
View File
@@ -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<f64>) {
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<Pos2> {
let mut ks: Vec<Pos2> =
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<u64> = 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::<Vec<_>>(), [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<egui::epaint::CircleShape> {
fn walk(s: &egui::Shape, out: &mut Vec<egui::epaint::CircleShape>) {