diff --git a/README.md b/README.md index b9dff872..a96364df 100644 --- a/README.md +++ b/README.md @@ -210,6 +210,9 @@ cargo run --release -p effectcraft-cli -- render --out intro.mp4 # render the cargo xtask web --serve 8765 # the browser build on http://127.0.0.1:8765/ (docs/web.md) ``` +New to it? [docs/footage.md](docs/footage.md) covers importing footage and image sequences and +Interpret Footage. + To work on it: `cargo test --workspace`, and `cargo xtask ci` before every commit (format, lints, tests, layering, asset attribution and the WebAssembly build). See [CONTRIBUTING.md](CONTRIBUTING.md), [docs/architecture.md](docs/architecture.md) and [docs/testing.md](docs/testing.md). diff --git a/apps/effectcraft-web/src/files.rs b/apps/effectcraft-web/src/files.rs index ca6699ea..76b79912 100644 --- a/apps/effectcraft-web/src/files.rs +++ b/apps/effectcraft-web/src/files.rs @@ -138,6 +138,15 @@ impl Services for WebServices { } Ok(()) } + /// The table's files in `dir` (picked and dropped files sit in `/`): picking or dropping a + /// numbered file finds the other frames of its image sequence loaded before. + fn list_dir(&self, dir: &str) -> Vec { + let dir = dir.trim_end_matches('/'); + list().into_iter().map(|(p, _)| p).filter(|p| p.rsplit_once('/').is_some_and(|(d, _)| d == dir)).collect() + } + fn is_dir(&self, _path: &str) -> bool { + false + } } /// Media import from the table (the bytes are also handed to the media pool, which decodes them). @@ -154,6 +163,13 @@ impl effectcraft_engine::Importer for WebImporter { fn register(&self, path: &str, data: &[u8]) { self.pool.add_bytes(path, get(path).unwrap_or_else(|| data.into())); } + fn add_frames(&self, paths: &[String]) { + for p in paths { + if let Some(bytes) = get(p) { + self.pool.add_bytes(p, bytes); + } + } + } } /// Render Queue output: files land in the table and are downloaded after the render diff --git a/crates/engine/src/commands/file.rs b/crates/engine/src/commands/file.rs index 3a3e03c5..6448ac05 100644 --- a/crates/engine/src/commands/file.rs +++ b/crates/engine/src/commands/file.rs @@ -5,6 +5,7 @@ use effectcraft_project::{FootageKind, ItemId, ItemKind, LayerSource, Project}; use serde_json::{Value, json}; use super::{CommandSpec, always, b_p, bad, str_p}; +use crate::sequence::{SequenceOptions, Source}; use crate::{EngineError, Result, Session, cmd}; fn has_path(s: &Session) -> std::result::Result<(), String> { @@ -111,23 +112,22 @@ fn revert(s: &mut Session, _: &Value) -> Result { fn import_cmd(s: &mut Session, p: &Value) -> Result { let target = s.active_comp_id(); 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)), + if b_p(p, "background").unwrap_or(false) && !pending.sources.is_empty() { + let sources = std::mem::take(&mut pending.sources); + let total = sources.len(); + let label = match sources.first() { + Some(one) if total == 1 => format!("Importing {}", one.name()), _ => 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)); + for (k, src) in sources.iter().enumerate() { + ctl.message(format!("{} ({} of {total})", src.name(), 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); + probed.push(pending.prober.probe(src)); } ctl.progress(total as u64, total as u64); let apply: crate::jobs::Apply = Box::new(move |s: &mut Session| { @@ -147,17 +147,12 @@ fn import_cmd(s: &mut Session, p: &Value) -> Result { Ok(apply) }); } - let probed = pending.paths.iter().map(|path| pending.prober.probe(path)).collect(); + let probed = pending.sources.iter().map(|src| pending.prober.probe(src)).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) { @@ -206,14 +201,14 @@ pub(crate) fn add_to_comp(s: &mut Session, r: &Value, target: Option, p: /// 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(); + let probed = pending.sources.iter().map(|src| pending.prober.probe(src)).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. +/// An import whose layered files are already compositions: the files and image sequences left +/// to probe as footage, how, and what the import made so far. struct PendingImport { - paths: Vec, + sources: Vec, prober: Prober, comps: Vec, items: Vec, @@ -229,7 +224,8 @@ struct Prober { /// Settings ▸ Import ▸ Interpret Unlabeled Alpha As, for stills and for movies. still_alpha: Option, movie_alpha: Option, - /// Settings ▸ Import ▸ Sequence Footage frames per second. + /// Image sequences' frames per second: the import's `frameRate`, or Settings ▸ Import ▸ + /// Sequence Footage. sequence_rate: effectcraft_time::FrameRate, /// PDF / Illustrator page (0-based). page: u32, @@ -237,9 +233,10 @@ struct Prober { psd_layer: Option, } -/// A file probed as footage. +/// A file (or image sequence) probed as footage. struct Probed { - path: String, + /// The Project panel name. + name: String, footage: effectcraft_project::Footage, /// Its alpha is unlabeled and Settings say Ask User: open Interpret Footage. ask_alpha: bool, @@ -247,22 +244,27 @@ struct Probed { } impl Prober { - /// Probe `path` as footage; the error names the file. - fn probe(&self, path: &str) -> std::result::Result { + /// Probe a file or image sequence as footage; the error names the file. + fn probe(&self, src: &Source) -> std::result::Result { + let path = src.path(); + let name = src.name(); 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); + if let Source::File(_) = src + && let Some(f) = data_footage(self.services.as_ref(), path) + { + return f.map(|footage| Probed { name, 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 f = crate::sequence::probe(importer.as_ref(), src, self.sequence_rate).map_err(fail)?; let mut warning = None; - // Settings ▸ Import ▸ Report Missing Frames. + // Settings ▸ Import ▸ Report Missing Frames (numbered sequences). if f.kind == FootageKind::Sequence + && f.missing_frames != effectcraft_project::MissingFrames::Skip && self.report_missing_frames - && let Some(gaps) = missing_frames(&f.sequence) + && let Some(gaps) = crate::sequence::missing_report(&f.sequence) { - warning = Some(format!("{path}: {gaps}")); + warning = Some(format!("{name}: {gaps} (shown as placeholders; File ▸ Interpret Footage ▸ Missing Frames)")); } // Settings ▸ Import ▸ Interpret Unlabeled Alpha As: images only (a 3D model renders its // own alpha, and audio and data have none). @@ -277,13 +279,6 @@ impl Prober { 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 @@ -308,7 +303,7 @@ impl Prober { .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 }) + Ok(Probed { name, footage: f, ask_alpha, warning }) } } @@ -379,17 +374,25 @@ fn prepare_import(s: &mut Session, p: &Value) -> Result { } paths = rest; } + // Numbered stills as image sequences (the Import dialog's " Sequence" and Force + // Alphabetical Order), at `frameRate` or Settings ▸ Import ▸ Sequence Footage. + let opts = SequenceOptions::from_params(p); + let fps = match p.get("frameRate") { + None | Some(Value::Null) => s.prefs.import.sequence_fps, + Some(v) => v.as_f64().filter(|x| *x > 0.0 && *x <= 999.0).ok_or_else(|| bad("file.import", "frameRate: frames per second, up to 999"))?, + }; + let sources = crate::sequence::group(s.services.as_ref(), &paths, opts); 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), + sequence_rate: effectcraft_time::FrameRate::from_f64(fps), page, psd_layer: p.get("layer").cloned(), }; - Ok(PendingImport { paths, prober, comps, items, errors }) + Ok(PendingImport { sources, prober, comps, items, errors }) } /// The last part of an import: add the probed files to the project (one undo step) and select @@ -413,7 +416,7 @@ fn add_footage(s: &mut Session, pending: PendingImport, probed: Vec format!("{}/{name}", l.name), @@ -457,29 +460,6 @@ fn alpha_is_labeled(path: &str, codec: &str) -> bool { matches!(ext.as_str(), "png" | "gif" | "webp" | "psd" | "psb" | "exr" | "svg" | "jpg" | "jpeg" | "ai" | "pdf" | "eps") || codec.eq_ignore_ascii_case("PSD") } -/// Gaps in an image sequence's frame numbers ("missing frames 4–6, 9"), from the digit run at the -/// end of each file name. `None` when the numbering is continuous (or there is none). -pub(crate) fn missing_frames(files: &[String]) -> Option { - let num = |f: &String| -> Option { - let stem = std::path::Path::new(f).file_stem()?.to_string_lossy().to_string(); - let digits: String = stem.chars().rev().take_while(char::is_ascii_digit).collect::>().into_iter().rev().collect(); - digits.parse().ok() - }; - let mut ns: Vec = files.iter().filter_map(num).collect(); - ns.sort_unstable(); - ns.dedup(); - let mut gaps = vec![]; - let mut total = 0; - for w in ns.windows(2) { - if w[1] - w[0] > 1 { - let (a, b) = (w[0] + 1, w[1] - 1); - total += b - a + 1; - gaps.push(if a == b { a.to_string() } else { format!("{a}–{b}") }); - } - } - (!gaps.is_empty()).then(|| format!("{total} missing frame{} ({})", if total == 1 { "" } else { "s" }, gaps.join(", "))) -} - /// A Photoshop layer by index or name (pixel layers only). fn find_psd_layer(d: &effectcraft_psd::Psd, sel: &Value) -> Option<(usize, String)> { let l = match sel { @@ -700,7 +680,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), background?: bool (probe the files in a background job, jobs.list / jobs.wait; returns {job})}", + "{paths: [string] (files or folders), sequence?: bool (default true: numbered stills of one run import as one image sequence; one picked file brings its whole run, several picked files that range), alphabetical?: bool (Force Alphabetical Order: every image of that type in the folder, by name), frameRate?: fps (image sequences; default Settings ▸ Import ▸ Sequence Footage), importAs?: footage|composition|compositionLayerSizes (Photoshop, PDF, Illustrator and EPS files), layer?: name|index (footage of one Photoshop layer), page?: number from 1 (PDF / Illustrator page), drag?: bool (dropped files: Settings ▸ Import ▸ Default Drag Import As), 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/commands/file_more.rs b/crates/engine/src/commands/file_more.rs index 5b051265..acf93677 100644 --- a/crates/engine/src/commands/file_more.rs +++ b/crates/engine/src/commands/file_more.rs @@ -10,6 +10,7 @@ use effectcraft_time::{FrameRate, Tick}; use serde_json::{Value, json}; use super::{CommandSpec, always, b_p, bad, f_p, has_comp, has_project_selection, str_p}; +use crate::sequence::SequenceOptions; use crate::{EngineError, Result, Session, cmd}; fn has_footage_selection(s: &Session) -> std::result::Result<(), String> { @@ -552,6 +553,10 @@ pub(crate) struct Interpretation { fields: Option, profile: Option>, linear: Option, + /// Image sequences: Missing Frames. + missing: Option, + /// Image sequences: Start Frame (`Some(None)` = the first file's number). + start: Option>, } impl Interpretation { @@ -598,12 +603,32 @@ impl Interpretation { None => None, }; it.linear = super::b_p(p, "linearLight"); + it.missing = match str_p(p, "missingFrames") { + Some(m) => Some(effectcraft_project::MissingFrames::parse(m).ok_or_else(|| bad(cmd, format!("missingFrames: placeholder|hold|skip, not `{m}`")))?), + None => None, + }; + // Start Frame: a frame number, or "file" for the first file's. + it.start = match p.get("startFrame") { + None | Some(Value::Null) => None, + Some(Value::String(f)) if f == "file" => Some(None), + Some(v) => Some(Some( + v.as_f64() + .filter(|x| x.is_finite() && x.abs() <= 1.0e9) + .map(|x| x.round() as i64) + .ok_or_else(|| bad(cmd, "startFrame: a frame number (up to 1e9), or \"file\""))?, + )), + }; Ok(it) } pub(crate) fn apply(&self, f: &mut Footage, guessed: Option<(AlphaMode, [f32; 3])>) { let before = f.frame_rate; self.apply_settings(f, guessed); + // A sequence's length is its frames (Missing Frames and Start Frame decide how many). + if f.kind == FootageKind::Sequence && !f.sequence.is_empty() { + f.sync_sequence_duration(); + return; + } // Conforming keeps the frames and changes how long they last (12 frames at 30 fps are // 0.4 s; conformed to 12 fps, 1 s). Stills have no frame rate of their own. let (old, new) = (before.as_f64(), f.frame_rate.as_f64()); @@ -652,6 +677,16 @@ impl Interpretation { if let Some(v) = self.linear { f.linear_light = v; } + if f.kind == FootageKind::Sequence { + if let Some(m) = self.missing { + f.missing_frames = m; + } + if let Some(start) = self.start { + // The first file's own number needn't be stored (it follows the files). + let first = f.sequence.first().and_then(|p| effectcraft_project::sequence::frame_number(p)); + f.start_frame = start.filter(|n| Some(*n) != first); + } + } } } @@ -728,7 +763,8 @@ fn interpret(s: &mut Session, p: &Value) -> Result { Some(ItemKind::Footage(f)) => Some(json!({ "item": i.0, "alpha": format!("{:?}", f.alpha), "matteColor": f.premul_color, "invertAlpha": f.invert_alpha, "fields": f.fields.label(), "pixelAspect": f.pixel_aspect, "loop": f.loop_count, "frameRate": f.frame_rate.as_f64(), - "linearLight": f.linear_light, + "linearLight": f.linear_light, "missingFrames": f.missing_frames.id(), "startFrame": f.first_frame_number(), + "frames": f.sequence_frames(), })), _ => None, }) @@ -775,8 +811,11 @@ fn replace_footage(s: &mut Session, p: &Value) -> Result { let path = str_p(p, "path").ok_or_else(|| bad("file.replaceFootage", "missing `path`"))?.to_string(); let item = *items_p(s, p).first().ok_or_else(|| bad("file.replaceFootage", "select footage"))?; let importer = s.importer.clone().ok_or_else(|| EngineError::Other("media import is not available in this build".into()))?; - let f = importer.probe(&path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; - let name = std::path::Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or(path.clone()); + // A numbered still brings its image sequence, as on import (`sequence: false` for one frame). + let opts = SequenceOptions::from_params(p); + let src = crate::sequence::source_of(s.services.as_ref(), &path, opts); + let f = crate::sequence::probe(importer.as_ref(), &src, s.sequence_rate()).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + let name = src.name(); s.edit("Replace Footage", None, |proj, _| { let it = proj.item_mut(item).ok_or_else(|| bad("file.replaceFootage", "no such item"))?; it.name = name; @@ -843,11 +882,14 @@ fn replace_with_solid(s: &mut Session, p: &Value) -> Result { fn reload(s: &mut Session, p: &Value) -> Result { let items = items_p(s, p); - let Some(importer) = s.importer.clone() else { return Err(EngineError::Other("media import is not available in this build".into())) }; + if s.importer.is_none() { + return Err(EngineError::Other("media import is not available in this build".into())); + } let mut updates = vec![]; for i in &items { if let Some(ItemKind::Footage(f)) = s.project.item(*i).map(|x| &x.kind) { - let mut nf = match importer.probe(&f.path) { + // An image sequence picks up frames added to (or removed from) its run. + let mut nf = match s.probe_footage(&f.path) { Ok(n) => n, Err(_) => Footage { missing: true, ..f.clone() }, }; @@ -855,6 +897,13 @@ fn reload(s: &mut Session, p: &Value) -> Result { nf.alpha = f.alpha; nf.loop_count = f.loop_count; nf.pixel_aspect = f.pixel_aspect; + if nf.kind == FootageKind::Sequence && f.kind == FootageKind::Sequence { + nf.frame_rate = f.frame_rate; + nf.native_rate = f.native_rate; + nf.missing_frames = f.missing_frames; + nf.start_frame = f.start_frame; + nf.sync_sequence_duration(); + } updates.push((*i, nf)); } } @@ -954,7 +1003,7 @@ pub fn specs() -> Vec { "Main...", ["File", "Interpret Footage"], Some("Cmd+Alt+G"), - "{items?, frameRate?: fps|\"file\", alpha?: straight|premultiplied|ignore|guess, guessAlpha?, matteColor?, invertAlpha?, loop?, pixelAspect?, fields?: off|upper|lower, colorProfile?: srgb|rec709|rec2020|p3|auto, linearLight?}", + "{items?, frameRate?: fps|\"file\", alpha?: straight|premultiplied|ignore|guess, guessAlpha?, matteColor?, invertAlpha?, loop?, pixelAspect?, fields?: off|upper|lower, colorProfile?: srgb|rec709|rec2020|p3|auto, linearLight?, missingFrames?: placeholder|hold|skip (image sequences: what a gap in the numbering shows), startFrame?: number|\"file\" (image sequences: the frame number at the footage's first frame)}", has_footage_selection, interpret ), @@ -976,7 +1025,15 @@ pub fn specs() -> Vec { has_interpretation, apply_interpretation ), - cmd!("file.replaceFootage", "File...", ["File", "Replace Footage"], Some("Cmd+H"), "{path, item?}", has_footage_selection, replace_footage), + cmd!( + "file.replaceFootage", + "File...", + ["File", "Replace Footage"], + Some("Cmd+H"), + "{path, item?, sequence?: bool (default true: a numbered still brings its image sequence), alphabetical?: bool}", + has_footage_selection, + replace_footage + ), cmd!( "file.replaceWithPlaceholder", "Placeholder...", diff --git a/crates/engine/src/commands/mod.rs b/crates/engine/src/commands/mod.rs index bf18063d..b8d626ba 100644 --- a/crates/engine/src/commands/mod.rs +++ b/crates/engine/src/commands/mod.rs @@ -74,8 +74,6 @@ pub(crate) mod watch_folder; pub(crate) use app_more::report_text as report_text_for_tests; pub(crate) use app_more::view_command; #[cfg(test)] -pub(crate) use file::missing_frames as missing_frames_for_tests; -#[cfg(test)] pub(crate) use keys_more::amplitudes as amplitudes_for_tests; #[cfg(test)] pub(crate) use mask::split_segment as split_segment_for_tests; diff --git a/crates/engine/src/commands/panels_cmds.rs b/crates/engine/src/commands/panels_cmds.rs index 9133a362..bcdb7a73 100644 --- a/crates/engine/src/commands/panels_cmds.rs +++ b/crates/engine/src/commands/panels_cmds.rs @@ -120,9 +120,8 @@ fn file_info(s: &mut Session, p: &Value) -> Result { let path = str_p(p, "path").ok_or_else(|| bad("mediaBrowser.fileInfo", "missing `path`"))?; let (created, modified, size) = mb::file_dates(path); let mut v = json!({"path": path, "kind": mb::kind_of(path), "size": size, "created": created.map(mb::iso_date), "modified": modified.map(mb::iso_date)}); - if let Some(imp) = s.importer.clone() - && matches!(mb::kind_of(path), Some("video" | "image" | "audio" | "model")) - && let Ok(f) = imp.probe(path) + if matches!(mb::kind_of(path), Some("video" | "image" | "audio" | "model")) + && let Ok(f) = s.probe_footage(path) { v["footage"] = mb::footage_metadata(&f); } diff --git a/crates/engine/src/commands/proxy.rs b/crates/engine/src/commands/proxy.rs index 3e1126a9..f05e9765 100644 --- a/crates/engine/src/commands/proxy.rs +++ b/crates/engine/src/commands/proxy.rs @@ -40,8 +40,11 @@ fn has_proxy(s: &Session) -> std::result::Result<(), String> { /// Probe a proxy file through the importer. fn probe(s: &Session, path: &str, cmd: &str) -> Result { - let importer = s.importer.clone().ok_or_else(|| EngineError::Other("media import is not available in this build".into()))?; - let f = importer.probe(path).map_err(|e| bad(cmd, format!("{path}: {e}")))?; + if s.importer.is_none() { + return Err(EngineError::Other("media import is not available in this build".into())); + } + // The first frame of a proxy sequence (Create Proxy ▸ Movie renders one) brings the rest. + let f = s.probe_footage(path).map_err(|e| bad(cmd, format!("{path}: {e}")))?; if !f.has_video { return Err(bad(cmd, format!("{path} has no video to use as a proxy"))); } diff --git a/crates/engine/src/commands/timeline.rs b/crates/engine/src/commands/timeline.rs index 3614ac37..633e73c5 100644 --- a/crates/engine/src/commands/timeline.rs +++ b/crates/engine/src/commands/timeline.rs @@ -40,11 +40,12 @@ fn import_timeline(s: &mut Session, p: &Value) -> Result { }; let importer = s.importer.clone(); let services = s.services.clone(); + let rate = s.sequence_rate(); let mut probe = move |m: &str| -> Option { if !services.exists(m) { return None; } - importer.as_ref()?.probe(m).ok() + crate::sequence::probe_path(importer.as_deref()?, services.as_ref(), m, rate).ok() }; let res = s.edit("Import Timeline", None, |proj, st| { let r = effectcraft_interchange::import(proj, &bytes, format, &opts, &mut probe).map_err(|e| EngineError::Other(e.to_string()))?; diff --git a/crates/engine/src/footage_check.rs b/crates/engine/src/footage_check.rs index 7bad53d0..c3765f1e 100644 --- a/crates/engine/src/footage_check.rs +++ b/crates/engine/src/footage_check.rs @@ -51,6 +51,7 @@ pub fn start(s: &mut Session, items: Option>, wait: bool) -> crate:: } let services = s.services.clone(); let importer = s.importer.clone(); + let rate = s.sequence_rate(); let n = jobs.len(); s.spawn_task("footageCheck", format!("Checking footage ({n} items)"), wait, move |ctl| { let mut found: Vec<(ItemId, Found)> = Vec::with_capacity(jobs.len()); @@ -69,7 +70,23 @@ pub fn start(s: &mut Session, items: Option>, wait: bool) -> crate:: }; // Saved without metadata (a hand-written or converted project): probe it now. let needs_probe = present && f.width == 0 && f.height == 0 && f.kind != FootageKind::Audio; - match importer.as_ref().filter(|_| needs_probe).map(|imp| imp.probe(&f.path)) { + let probed = importer.as_ref().filter(|_| needs_probe).map(|imp| match f.kind { + // A sequence keeps its files; its first one gives the metadata. + FootageKind::Sequence if !f.sequence.is_empty() => imp.probe(&f.path).map(|one| { + let mut nf = Footage { + width: one.width, + height: one.height, + pixel_aspect: one.pixel_aspect, + has_video: one.has_video, + codec: one.codec, + ..f.clone() + }; + nf.sync_sequence_duration(); + nf + }), + _ => crate::sequence::probe_path(imp.as_ref(), services.as_ref(), &f.path, rate), + }); + match probed { Some(Ok(mut nf)) => { nf.alpha = f.alpha; nf.loop_count = f.loop_count; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index f9c7bcea..5391fab3 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -37,6 +37,7 @@ pub mod remote; pub mod render_queue; pub mod roto; pub mod scriptui; +pub mod sequence; mod session_settings; pub mod shortcuts; pub mod storage; @@ -110,6 +111,19 @@ pub trait Services: Send + Sync { } self.write_file(path, data) } + /// The files in folder `dir` (full paths; no folders, no hidden files), for image-sequence + /// detection on import. Empty when it can't be read. + fn list_dir(&self, dir: &str) -> Vec { + let Ok(rd) = std::fs::read_dir(if dir.is_empty() { "." } else { dir }) else { return vec![] }; + rd.filter_map(|e| e.ok()) + .filter(|e| e.file_type().is_ok_and(|t| t.is_file()) && !e.file_name().to_string_lossy().starts_with('.')) + .map(|e| e.path().to_string_lossy().to_string()) + .collect() + } + /// Whether `path` is a folder (dropped or picked folders import their files). + fn is_dir(&self, path: &str) -> bool { + std::path::Path::new(path).is_dir() + } } /// Native filesystem. @@ -125,11 +139,15 @@ impl Services for FsServices { /// Imports media files into the project (implemented by the media layer). pub trait Importer: Send + Sync { - /// Probe a file and return footage metadata. + /// Probe one file and return footage metadata. Image sequences are the engine's + /// ([`sequence`]): it groups numbered stills and probes their first file here. fn probe(&self, path: &str) -> std::result::Result; /// A footage file's bytes are now at `path` (extracted from a template): hosts whose media /// layer reads from memory (the browser) register them; file-based ones need nothing. fn register(&self, _path: &str, _data: &[u8]) {} + /// An image sequence's other frames (its first was probed) are footage now: hosts whose + /// media layer reads from memory (the browser) register them; file-based ones need nothing. + fn add_frames(&self, _paths: &[String]) {} } /// A keyframe reference: layer, property uid, key time (layer time). @@ -1013,6 +1031,8 @@ mod tests_proxy; #[cfg(test)] mod tests_rig3d; #[cfg(test)] +mod tests_sequence; +#[cfg(test)] mod tests_settings; #[cfg(test)] mod tests_stubs; diff --git a/crates/engine/src/render_queue.rs b/crates/engine/src/render_queue.rs index e1efe9c4..6245edda 100644 --- a/crates/engine/src/render_queue.rs +++ b/crates/engine/src/render_queue.rs @@ -608,8 +608,8 @@ impl Session { if item.post_render.is_none() { return Ok(None); } - let importer = self.importer.clone().ok_or("media import is not available in this build")?; - let footage = importer.probe(path)?; + // A rendered image sequence's first file brings the sequence. + let footage = self.probe_footage(path)?; if item.post_render == PostRenderAction::SetProxy { crate::commands::proxy_set(self, item.comp, footage).map_err(|e| e.to_string())?; self.toast(format!("Proxy set from {path}")); diff --git a/crates/engine/src/sequence.rs b/crates/engine/src/sequence.rs new file mode 100644 index 00000000..14a149f8 --- /dev/null +++ b/crates/engine/src/sequence.rs @@ -0,0 +1,310 @@ +//! Image sequences on import (File ▸ Import ▸ File…, dropped files and folders, Replace +//! Footage, Reload, proxies): numbered stills of one run (`shot_0001.png`, `shot_0002.png`, …) +//! become one footage item, as with After Effects' "PNG Sequence" import option. +//! +//! - One picked numbered file brings its whole run from its folder ([`crate::Services::list_dir`]). +//! - Several picked files of one run import as one sequence of just those files (a range). +//! - A picked or dropped folder imports its files; each run in it becomes a sequence. +//! - Force Alphabetical Order: every image of the same type in the folder, in name order, +//! whatever the numbering (gaps don't count). +//! +//! The browser has no folders: its file table lists the files picked or dropped so far, so +//! picking every frame of a sequence imports it there too. + +use effectcraft_project::sequence::{frame_number, missing_frame_ranges, sequence_name, split_frame_number}; +use effectcraft_project::{Footage, FootageKind, MissingFrames}; +use effectcraft_time::FrameRate; + +use crate::{Importer, Services}; + +/// How an import treats numbered stills. +#[derive(Clone, Copy, Debug)] +pub struct SequenceOptions { + /// The " Sequence" checkbox: numbered stills of one run import as a sequence. + pub sequence: bool, + /// Force Alphabetical Order. + pub alphabetical: bool, +} + +impl Default for SequenceOptions { + fn default() -> Self { + SequenceOptions { sequence: true, alphabetical: false } + } +} + +impl SequenceOptions { + /// From command parameters `sequence?` (default on) and `alphabetical?`. + pub fn from_params(p: &serde_json::Value) -> SequenceOptions { + let flag = |k: &str| p.get(k).and_then(serde_json::Value::as_bool); + SequenceOptions { sequence: flag("sequence").unwrap_or(true), alphabetical: flag("alphabetical").unwrap_or(false) } + } +} + +/// What one import unit makes: a file's footage, or an image sequence of files (in order). +#[derive(Clone, Debug, PartialEq)] +pub enum Source { + File(String), + Sequence { files: Vec, alphabetical: bool }, +} + +impl Source { + /// The path naming it (a sequence's first file). + pub fn path(&self) -> &str { + match self { + Source::File(p) => p, + Source::Sequence { files, .. } => files.first().map_or("", String::as_str), + } + } + + /// The Project panel name: the file name, or a sequence's `shot_[0001-0250].png`. + pub fn name(&self) -> String { + match self { + Source::Sequence { files, alphabetical: false } => sequence_name(files).unwrap_or_else(|| file_name(self.path())), + _ => file_name(self.path()), + } + } +} + +/// The file name of a path (either separator). +pub fn file_name(path: &str) -> String { + path.rsplit(['/', '\\']).find(|n| !n.is_empty()).unwrap_or(path).to_string() +} + +/// The folder of a path (`""` for a bare name). +fn folder(path: &str) -> &str { + path.rfind(['/', '\\']).map_or("", |i| path.get(..i.max(1)).unwrap_or("")) +} + +fn extension(path: &str) -> String { + let name = file_name(path); + name.rsplit_once('.').map(|(_, e)| e.to_ascii_lowercase()).unwrap_or_default() +} + +/// Stills that can form a sequence (not layered or vector documents, which import as comps). +fn sequenceable(path: &str) -> bool { + crate::media_browser::kind_of(path) == Some("image") && !matches!(extension(path).as_str(), "svg" | "psd" | "psb") +} + +/// The run a still belongs to: (folder, name prefix, extension); alphabetical runs ignore the +/// name. `None` for files that can't be part of one. +fn run_key(path: &str, alphabetical: bool) -> Option<(String, String, String)> { + if !sequenceable(path) { + return None; + } + let ext = extension(path); + if alphabetical { + return Some((folder(path).to_string(), String::new(), ext)); + } + let name = file_name(path); + let stem = name.get(..name.len().saturating_sub(ext.len() + usize::from(!ext.is_empty()))).unwrap_or(""); + let (prefix, _) = split_frame_number(stem)?; + Some((folder(path).to_string(), prefix.to_string(), ext)) +} + +/// Put a run's files in order: by frame number, or by name (Force Alphabetical Order). +fn sort_run(files: &mut Vec, alphabetical: bool) { + if alphabetical { + files.sort_by_key(|p| file_name(p).to_lowercase()); + } else { + files.sort_by_key(|p| (frame_number(p), p.clone())); + } + files.dedup(); +} + +/// The files of the run `path` belongs to, from its folder. +fn run_files(services: &dyn Services, path: &str, alphabetical: bool) -> Vec { + let Some(key) = run_key(path, alphabetical) else { return vec![] }; + let mut files: Vec = services.list_dir(&key.0).into_iter().filter(|p| run_key(p, alphabetical).as_ref() == Some(&key)).collect(); + sort_run(&mut files, alphabetical); + files +} + +/// What importing `paths` makes, in the order picked: folders become their importable files, +/// and (with the Sequence option) numbered stills of one run one [`Source::Sequence`]. +pub fn group(services: &dyn Services, paths: &[String], opts: SequenceOptions) -> Vec { + let mut files: Vec = vec![]; + for p in paths { + if services.is_dir(p) { + let mut inside: Vec = + services.list_dir(p).into_iter().filter(|f| matches!(crate::media_browser::kind_of(f), Some(k) if k != "project")).collect(); + inside.sort_by_key(|f| file_name(f).to_lowercase()); + files.extend(inside); + } else { + files.push(p.clone()); + } + } + if !opts.sequence { + return files.into_iter().map(Source::File).collect(); + } + // Runs in the order their first file was picked. + let mut runs: Vec<((String, String, String), Vec)> = vec![]; + let mut out: Vec> = vec![]; + let mut slot: Vec = vec![]; + for f in files { + match run_key(&f, opts.alphabetical) { + Some(key) => match runs.iter_mut().find(|(k, _)| *k == key) { + Some((_, run)) => run.push(f), + None => { + runs.push((key, vec![f])); + slot.push(out.len()); + out.push(None); + } + }, + None => out.push(Some(Source::File(f))), + } + } + for ((_, mut picked), at) in runs.into_iter().zip(slot) { + sort_run(&mut picked, opts.alphabetical); + // One file: its whole run from the folder. + if let [one] = picked.as_slice() { + let all = run_files(services, one, opts.alphabetical); + if all.len() >= 2 { + picked = all; + } + } + let src = match picked.len() { + 0 => None, + 1 => picked.pop().map(Source::File), + _ => Some(Source::Sequence { files: picked, alphabetical: opts.alphabetical }), + }; + if let Some(o) = out.get_mut(at) { + *o = src; + } + } + out.into_iter().flatten().collect() +} + +/// The image sequences importing `paths` would make (the Import dialog asks about them). +pub fn sequences(services: &dyn Services, paths: &[String], alphabetical: bool) -> Vec> { + group(services, paths, SequenceOptions { sequence: true, alphabetical }) + .into_iter() + .filter_map(|s| match s { + Source::Sequence { files, .. } => Some(files), + Source::File(_) => None, + }) + .collect() +} + +/// Probe what a [`Source`] makes. A sequence is its first file's footage with every file, at +/// `rate`, showing gaps in its numbering as placeholders (or closing them up when +/// alphabetical). +pub fn probe(importer: &dyn Importer, src: &Source, rate: FrameRate) -> Result { + let Source::Sequence { files, alphabetical } = src else { return importer.probe(src.path()) }; + let mut f = importer.probe(src.path())?; + if f.kind != FootageKind::Still { + return Ok(f); + } + f.kind = FootageKind::Sequence; + f.sequence = files.clone(); + f.missing_frames = if *alphabetical { MissingFrames::Skip } else { MissingFrames::Placeholder }; + f.start_frame = None; + f.frame_rate = rate; + f.native_rate = None; + f.sync_sequence_duration(); + importer.add_frames(files.get(1..).unwrap_or_default()); + Ok(f) +} + +/// Probe one path the way it imports by default: a numbered still with numbered siblings +/// brings its run as a sequence (Replace Footage, Reload, proxies, render outputs). +pub fn probe_path(importer: &dyn Importer, services: &dyn Services, path: &str, rate: FrameRate) -> Result { + probe(importer, &source_of(services, path, SequenceOptions::default()), rate) +} + +/// What importing the one file `path` makes. +pub fn source_of(services: &dyn Services, path: &str, opts: SequenceOptions) -> Source { + group(services, &[path.to_string()], opts).into_iter().next().unwrap_or_else(|| Source::File(path.to_string())) +} + +impl crate::Session { + /// Image sequences' frame rate on import: Settings ▸ Import ▸ Sequence Footage. + pub fn sequence_rate(&self) -> FrameRate { + FrameRate::from_f64(self.prefs.import.sequence_fps) + } + + /// Probe `path` as footage the way it imports by default ([`probe_path`]). + pub(crate) fn probe_footage(&self, path: &str) -> Result { + let importer = self.importer.as_ref().ok_or("media import is not available in this build")?; + probe_path(importer.as_ref(), self.services.as_ref(), path, self.sequence_rate()) + } +} + +/// Settings ▸ Import ▸ Report Missing Frames: "3 missing frames (3–4, 6)". `None` when the +/// numbering is continuous. +pub fn missing_report(files: &[String]) -> Option { + let gaps = missing_frame_ranges(files); + let total: i64 = gaps.iter().map(|(a, b)| b.saturating_sub(*a).saturating_add(1)).fold(0, i64::saturating_add); + let list: Vec = gaps.iter().map(|(a, b)| if a == b { a.to_string() } else { format!("{a}–{b}") }).collect(); + (!gaps.is_empty()).then(|| format!("{total} missing frame{} ({})", if total == 1 { "" } else { "s" }, list.join(", "))) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A file table: listed folders and their files. + struct Table(Vec<&'static str>); + impl Services for Table { + fn read_file(&self, path: &str) -> std::io::Result> { + Err(std::io::Error::other(path.to_string())) + } + fn write_file(&self, _: &str, _: &[u8]) -> std::io::Result<()> { + Ok(()) + } + fn list_dir(&self, dir: &str) -> Vec { + self.0.iter().filter(|p| folder(p) == dir).map(|p| p.to_string()).collect() + } + fn is_dir(&self, path: &str) -> bool { + self.0.iter().any(|p| folder(p) == path) + } + } + + fn strs(v: &[&str]) -> Vec { + v.iter().map(|s| s.to_string()).collect() + } + + #[test] + fn groups_runs_ranges_folders_and_alphabetical() { + let t = Table(vec!["/s/a_0002.png", "/s/a_0001.png", "/s/a_0010.png", "/s/b_1.png", "/s/notes.txt", "/s/c.png", "/s/a_0003.jpg", "/x/one_1.png"]); + let on = SequenceOptions::default(); + // One picked file: its run from the folder, in frame order. + assert_eq!( + group(&t, &strs(&["/s/a_0010.png"]), on), + [Source::Sequence { files: strs(&["/s/a_0001.png", "/s/a_0002.png", "/s/a_0010.png"]), alphabetical: false }] + ); + // A picked range stays that range; other files import as they are. + assert_eq!( + group(&t, &strs(&["/s/c.png", "/s/a_0010.png", "/s/a_0002.png", "/s/b_1.png", "/x/one_1.png"]), on), + [ + Source::File("/s/c.png".into()), + Source::Sequence { files: strs(&["/s/a_0002.png", "/s/a_0010.png"]), alphabetical: false }, + Source::File("/s/b_1.png".into()), + Source::File("/x/one_1.png".into()), + ] + ); + // Sequence off: every file on its own. + assert_eq!(group(&t, &strs(&["/s/a_0001.png"]), SequenceOptions { sequence: false, alphabetical: false }), [Source::File("/s/a_0001.png".into())]); + // A folder: its media files, runs as sequences. + let g = group(&t, &strs(&["/s"]), on); + assert_eq!(g.len(), 4, "{g:?}"); + assert_eq!(g[0], Source::Sequence { files: strs(&["/s/a_0001.png", "/s/a_0002.png", "/s/a_0010.png"]), alphabetical: false }); + assert_eq!(g[0].name(), "a_[0001-0010].png"); + assert!(!g.iter().any(|s| s.path().ends_with(".txt"))); + // Force Alphabetical Order: every PNG in the folder, by name. + let a = group(&t, &strs(&["/s/c.png"]), SequenceOptions { sequence: true, alphabetical: true }); + assert_eq!(a, [Source::Sequence { files: strs(&["/s/a_0001.png", "/s/a_0002.png", "/s/a_0010.png", "/s/b_1.png", "/s/c.png"]), alphabetical: true }]); + assert_eq!(a[0].name(), "a_0001.png"); + assert_eq!(sequences(&t, &strs(&["/s/a_0001.png", "/s/c.png"]), false).len(), 1); + } + + #[test] + fn windows_paths_and_reports() { + let t = Table(vec!["C:\\r\\f_01.exr", "C:\\r\\f_02.exr", "C:\\r\\f_05.exr"]); + let g = group(&t, &strs(&["C:\\r\\f_02.exr"]), SequenceOptions::default()); + assert_eq!(g, [Source::Sequence { files: strs(&["C:\\r\\f_01.exr", "C:\\r\\f_02.exr", "C:\\r\\f_05.exr"]), alphabetical: false }]); + assert_eq!(missing_report(&strs(&["a_001.png", "a_002.png", "a_005.png", "a_007.png"])).as_deref(), Some("3 missing frames (3–4, 6)")); + assert_eq!(missing_report(&strs(&["a1.png", "a2.png"])), None); + assert_eq!(folder("/x.png"), "/"); + assert_eq!(file_name("C:\\r\\f_01.exr"), "f_01.exr"); + } +} diff --git a/crates/engine/src/tests_m145.rs b/crates/engine/src/tests_m145.rs index c168448e..a73527bb 100644 --- a/crates/engine/src/tests_m145.rs +++ b/crates/engine/src/tests_m145.rs @@ -237,10 +237,10 @@ fn import_export_helpers() { p.import.drag_import_as = "compLayerSizes".into(); assert_eq!(p.drag_import_as(), "compositionLayerSizes"); assert_eq!( - super::commands::missing_frames_for_tests(&["a_001.png".into(), "a_002.png".into(), "a_005.png".into(), "a_007.png".into()]).unwrap(), + crate::sequence::missing_report(&["a_001.png".into(), "a_002.png".into(), "a_005.png".into(), "a_007.png".into()]).unwrap(), "3 missing frames (3–4, 6)" ); - assert!(super::commands::missing_frames_for_tests(&["a1.png".into(), "a2.png".into()]).is_none()); + assert!(crate::sequence::missing_report(&["a1.png".into(), "a2.png".into()]).is_none()); // Append Bit Depth to File Name. assert_eq!(p.output_name("/out/Comp 1.png", 16), "/out/Comp 1.png"); p.export.append_bits_to_name = true; diff --git a/crates/engine/src/tests_sequence.rs b/crates/engine/src/tests_sequence.rs new file mode 100644 index 00000000..c837fda2 --- /dev/null +++ b/crates/engine/src/tests_sequence.rs @@ -0,0 +1,179 @@ +//! Image sequences (#297): File ▸ Import with the " Sequence" option, picked ranges, +//! folders, Force Alphabetical Order, the import frame rate, Replace Footage, and Interpret +//! Footage ▸ Missing Frames / Start Frame. + +use effectcraft_project::{Footage, FootageKind, ItemId, ItemKind, MissingFrames}; +use effectcraft_time::{FrameRate, Tick}; +use serde_json::{Value, json}; + +use crate::Session; + +/// Probes every file as a 64×32 still (the media layer's single-file probe). +struct Stills; +impl crate::Importer for Stills { + fn probe(&self, path: &str) -> Result { + if !std::path::Path::new(path).is_file() { + return Err(format!("{path}: no such file")); + } + Ok(Footage { path: path.into(), kind: FootageKind::Still, width: 64, height: 32, has_video: true, codec: "PNG".into(), ..Default::default() }) + } +} + +/// A fresh folder with empty files of these names; returns its path. +fn folder(names: &[&str]) -> String { + use std::sync::atomic::{AtomicU32, Ordering}; + static N: AtomicU32 = AtomicU32::new(0); + let d = std::env::temp_dir().join(format!("ec-seq-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed))); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + for n in names { + std::fs::write(d.join(n), b"").unwrap(); + } + d.to_string_lossy().to_string() +} + +fn at(dir: &str, name: &str) -> String { + std::path::Path::new(dir).join(name).to_string_lossy().to_string() +} + +fn session() -> Session { + let mut s = Session { importer: Some(std::sync::Arc::new(Stills)), ..Default::default() }; + s.prefs.import.sequence_fps = 30.0; + s +} + +fn footage(s: &Session, item: &Value) -> (String, Footage) { + let it = s.project.item(ItemId(item.as_u64().unwrap())).unwrap(); + let ItemKind::Footage(f) = &it.kind else { panic!("footage") }; + (it.name.clone(), f.clone()) +} + +const RUN: [&str; 5] = ["shot_0001.png", "shot_0002.png", "shot_0004.png", "shot_0005.png", "notes.txt"]; + +#[test] +fn one_picked_numbered_file_imports_its_run_as_one_sequence() { + let dir = folder(&RUN); + let mut s = session(); + let r = s.execute("file.import", json!({"paths": [at(&dir, "shot_0002.png")]})).unwrap(); + assert_eq!(r["items"].as_array().unwrap().len(), 1, "{r}"); + let (name, f) = footage(&s, &r["items"][0]); + assert_eq!(name, "shot_[0001-0005].png"); + assert_eq!((f.kind, f.sequence.len(), f.missing_frames), (FootageKind::Sequence, 4, MissingFrames::Placeholder)); + assert_eq!(f.path, at(&dir, "shot_0001.png")); + // Frames 1–5 at Settings ▸ Import ▸ Sequence Footage (30 fps); frame 3 is a placeholder. + assert_eq!(f.duration, FrameRate::FPS_30.tick_of(5)); + assert_eq!(f.sequence_file(2), None); + // Settings ▸ Import ▸ Report Missing Frames. + let w = r["warnings"][0].as_str().unwrap(); + assert!(w.contains("1 missing frame (3)"), "{w}"); +} + +/// Picking every frame of a sequence used to make one item per frame (each a whole sequence on +/// the desktop, a still in the browser). +#[test] +fn picking_every_frame_imports_one_sequence() { + let dir = folder(&RUN); + let mut s = session(); + let all: Vec = RUN[..4].iter().map(|n| at(&dir, n)).collect(); + let r = s.execute("file.import", json!({"paths": all, "frameRate": 24})).unwrap(); + assert_eq!(r["items"].as_array().unwrap().len(), 1, "{r}"); + let (_, f) = footage(&s, &r["items"][0]); + assert_eq!(f.sequence.len(), 4); + // `frameRate` sets the sequence's rate on import. + assert_eq!((f.frame_rate, f.duration), (FrameRate::FPS_24, FrameRate::FPS_24.tick_of(5))); + // A picked range is just that range. + let r = s.execute("file.import", json!({"paths": [at(&dir, "shot_0004.png"), at(&dir, "shot_0005.png")]})).unwrap(); + let (name, f) = footage(&s, &r["items"][0]); + assert_eq!((name.as_str(), f.sequence.len()), ("shot_[0004-0005].png", 2)); + assert!(s.execute("file.import", json!({"paths": all, "frameRate": 0})).is_err()); +} + +#[test] +fn the_sequence_option_off_imports_stills() { + let dir = folder(&RUN); + let mut s = session(); + let r = s.execute("file.import", json!({"paths": [at(&dir, "shot_0002.png")], "sequence": false})).unwrap(); + let (name, f) = footage(&s, &r["items"][0]); + assert_eq!((name.as_str(), f.kind), ("shot_0002.png", FootageKind::Still)); + let r = s.execute("file.import", json!({"paths": [at(&dir, "shot_0001.png"), at(&dir, "shot_0002.png")], "sequence": false})).unwrap(); + assert_eq!(r["items"].as_array().unwrap().len(), 2); +} + +#[test] +fn a_folder_imports_its_files_with_runs_as_sequences() { + let dir = folder(&["a_01.png", "a_02.png", "b.png", "readme.txt"]); + let mut s = session(); + let r = s.execute("file.import", json!({"paths": [dir], "background": true})).unwrap(); + s.execute("jobs.wait", json!({})).unwrap(); + assert!(r["job"].is_u64() || r["job"].is_string(), "{r}"); + let names: Vec = s.project.items.values().map(|i| i.name.clone()).collect(); + assert_eq!(names, ["a_[01-02].png", "b.png"], "the text file is skipped"); +} + +#[test] +fn force_alphabetical_order_takes_every_image_of_the_type() { + let dir = folder(&["take_b.png", "take_a.png", "other_7.png", "c.jpg"]); + let mut s = session(); + let r = s.execute("file.import", json!({"paths": [at(&dir, "take_a.png")], "alphabetical": true})).unwrap(); + let (name, f) = footage(&s, &r["items"][0]); + let files: Vec = f.sequence.iter().map(|p| crate::sequence::file_name(p)).collect(); + assert_eq!(files, ["other_7.png", "take_a.png", "take_b.png"]); + assert_eq!((name.as_str(), f.missing_frames), ("other_7.png", MissingFrames::Skip)); +} + +#[test] +fn interpret_footage_sets_missing_frames_and_start_frame() { + let dir = folder(&RUN); + let mut s = session(); + let item = s.execute("file.import", json!({"paths": [at(&dir, "shot_0001.png")]})).unwrap()["items"][0].clone(); + s.execute("comp.new", json!({"name": "Main", "width": 64, "height": 32, "frameRate": 30, "duration": 10})).unwrap(); + let layer = s.execute("layer.addItem", json!({"item": item})).unwrap()["layer"].as_u64().unwrap(); + let out = |s: &Session| s.active_comp().unwrap().layer(effectcraft_project::LayerId(layer)).unwrap().out_point; + assert_eq!(out(&s), FrameRate::FPS_30.tick_of(5)); + // Hold: the gap shows frame 2; the length stays 5 frames. + let r = s.execute("file.interpretFootage", json!({"items": [item], "missingFrames": "hold"})).unwrap(); + assert_eq!((r["items"][0]["missingFrames"].as_str(), r["items"][0]["frames"].as_i64()), (Some("hold"), Some(5))); + assert_eq!(footage(&s, &item).1.sequence_file(2).map(crate::sequence::file_name).as_deref(), Some("shot_0002.png")); + // Skip: 4 frames, and the layer that ran to the end still does. + s.execute("file.interpretFootage", json!({"items": [item], "missingFrames": "skip"})).unwrap(); + assert_eq!((footage(&s, &item).1.duration, out(&s)), (FrameRate::FPS_30.tick_of(4), FrameRate::FPS_30.tick_of(4))); + // Start Frame 0 with placeholders: frame 0 is missing, 6 frames in all. + let r = s.execute("file.interpretFootage", json!({"items": [item], "missingFrames": "placeholder", "startFrame": 0})).unwrap(); + assert_eq!((r["items"][0]["startFrame"].as_i64(), r["items"][0]["frames"].as_i64()), (Some(0), Some(6))); + assert_eq!(footage(&s, &item).1.sequence_file(0), None); + // The first file's own number isn't stored; "file" goes back to it. + s.execute("file.interpretFootage", json!({"items": [item], "startFrame": 1})).unwrap(); + assert_eq!(footage(&s, &item).1.start_frame, None); + s.execute("file.interpretFootage", json!({"items": [item], "startFrame": 3})).unwrap(); + s.execute("file.interpretFootage", json!({"items": [item], "startFrame": "file"})).unwrap(); + assert_eq!(footage(&s, &item).1.start_frame, None); + // The frame rate re-times the frames. + s.execute("file.interpretFootage", json!({"items": [item], "frameRate": 10})).unwrap(); + assert_eq!(footage(&s, &item).1.duration, Tick::from_seconds_f64(0.5)); + for bad in [json!({"missingFrames": "blank"}), json!({"startFrame": "x"}), json!({"startFrame": 1e300})] { + let mut p = bad.clone(); + p["items"] = json!([item]); + assert!(s.execute("file.interpretFootage", p).is_err(), "{bad}"); + } + // Undo puts the interpretation back. + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(footage(&s, &item).1.frame_rate, FrameRate::FPS_30); +} + +#[test] +fn replace_footage_and_reload_keep_sequences() { + let dir = folder(&RUN); + let mut s = session(); + let item = s.execute("file.import", json!({"paths": [at(&dir, "shot_0001.png")], "sequence": false})).unwrap()["items"][0].clone(); + s.state.project_selection = vec![ItemId(item.as_u64().unwrap())]; + s.execute("file.replaceFootage", json!({"path": at(&dir, "shot_0004.png")})).unwrap(); + let (name, f) = footage(&s, &item); + assert_eq!((name.as_str(), f.sequence.len()), ("shot_[0001-0005].png", 4)); + // Reload finds a new frame and keeps the interpretation. + s.execute("file.interpretFootage", json!({"items": [item], "frameRate": 12, "missingFrames": "hold"})).unwrap(); + std::fs::write(at(&dir, "shot_0003.png"), b"").unwrap(); + s.execute("file.reloadFootage", json!({"items": [item]})).unwrap(); + let (_, f) = footage(&s, &item); + assert_eq!((f.sequence.len(), f.frame_rate, f.missing_frames), (5, FrameRate::from_f64(12.0), MissingFrames::Hold)); + assert_eq!(f.duration, FrameRate::from_f64(12.0).tick_of(5)); +} diff --git a/crates/media/src/lib.rs b/crates/media/src/lib.rs index a1c8e056..64c137c9 100644 --- a/crates/media/src/lib.rs +++ b/crates/media/src/lib.rs @@ -23,7 +23,7 @@ mod pool; mod probe; pub use pool::{DEFAULT_BUDGET, MediaPool, PoolStats}; -pub use probe::{DEFAULT_SEQUENCE_RATE, probe, probe_bytes, probe_model, probe_single, sequence_files}; +pub use probe::{DEFAULT_SEQUENCE_RATE, probe, probe_bytes, probe_model}; /// Errors from probing or decoding footage. #[derive(Debug, thiserror::Error)] diff --git a/crates/media/src/pool.rs b/crates/media/src/pool.rs index 1ff740b2..57019085 100644 --- a/crates/media/src/pool.rs +++ b/crates/media/src/pool.rs @@ -16,6 +16,23 @@ use crate::{MediaError, Result}; /// Default frame-cache budget: 1 GiB of decoded frames. pub const DEFAULT_BUDGET: usize = 1 << 30; +/// The cache path of a missing image-sequence frame (no file can have this name). +const PLACEHOLDER: &str = "\0missing frame"; + +/// What a missing image-sequence frame shows: 75 % colour bars (white, yellow, cyan, green, +/// magenta, red, blue), the usual "no picture here" frame. +fn colour_bars(w: u32, h: u32) -> Image { + const BARS: [[f32; 3]; 7] = [[1.0, 1.0, 1.0], [1.0, 1.0, 0.0], [0.0, 1.0, 1.0], [0.0, 1.0, 0.0], [1.0, 0.0, 1.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]]; + let (w, h) = (w.clamp(1, 16_384), h.clamp(1, 16_384)); + let row: Vec = (0..w) + .map(|x| { + let c = BARS.get((x as usize * BARS.len()) / w as usize).copied().unwrap_or([0.0; 3]); + [c[0] * 0.75, c[1] * 0.75, c[2] * 0.75, 1.0] + }) + .collect(); + Image { width: w, height: h, data: row.repeat(h as usize) } +} + #[derive(Clone, PartialEq, Eq, Hash)] struct Key { path: Arc, @@ -503,8 +520,12 @@ impl Inner { let key = |path: &str, frame| Key { path: path.into(), frame, alpha, matte }; match footage.kind { FootageKind::Sequence if !footage.sequence.is_empty() => { - let i = Self::frame_index(footage.frame_rate, t, footage.sequence.len() as i64, footage.loop_count); - Loc { key: key(&footage.sequence[i as usize], 0), media_t: None } + let i = Self::frame_index(footage.frame_rate, t, footage.sequence_frames(), footage.loop_count); + match footage.sequence_file(i) { + Some(path) => Loc { key: key(path, 0), media_t: None }, + // A gap in the numbering (Missing Frames ▸ Show Placeholder). + None => Loc { key: key(PLACEHOLDER, ((footage.width as i64) << 32) | footage.height as i64), media_t: None }, + } } // A layer of a layered still is keyed by its layer index and size mode (smart objects // by their embedded file; PDF pages by their number). @@ -589,6 +610,7 @@ impl Inner { let op = AlphaOp::new(footage.alpha, footage.premul_color); let path = &loc.key.path; match loc.media_t { + None if &**path == PLACEHOLDER => Ok(colour_bars(footage.width, footage.height)), None => { let bytes = self.read(path)?; if let Some(img) = crate::layered::decode(path, &bytes, footage, op)? { diff --git a/crates/media/src/probe.rs b/crates/media/src/probe.rs index 8ff0c05c..72e25699 100644 --- a/crates/media/src/probe.rs +++ b/crates/media/src/probe.rs @@ -1,31 +1,19 @@ //! Probing: describe a file as an [`effectcraft_project::Footage`]. -use std::path::{Path, PathBuf}; +use std::path::Path; use std::sync::Arc; use effectcraft_project::{AlphaMode, Footage, FootageKind}; use effectcraft_time::{FrameRate, Tick}; -use crate::{MediaError, Result, STILL_EXTENSIONS, ext_of, rate_from_fc}; +use crate::{MediaError, Result, ext_of, rate_from_fc}; /// Frame rate given to image sequences on import (After Effects' default preference). pub const DEFAULT_SEQUENCE_RATE: FrameRate = FrameRate::FPS_30; -/// Probe a file. Stills whose name ends in a frame number (`shot_0001.png`) with at least one -/// numbered sibling are imported as an image sequence; use [`probe_single`] to import one frame. +/// Probe one file. Image sequences are made by the engine, which groups numbered stills +/// (`effectcraft_engine::sequence`) and probes their first frame here. pub fn probe(path: impl AsRef) -> Result { - let path = path.as_ref(); - if STILL_EXTENSIONS.contains(&ext_of(path).as_str()) { - let files = sequence_files(path); - if files.len() >= 2 { - return probe_sequence(&files); - } - } - probe_single(path) -} - -/// Probe a file without image-sequence detection. -pub fn probe_single(path: impl AsRef) -> Result { let path = path.as_ref(); let bytes = std::fs::read(path).map_err(|e| MediaError::Io(format!("{}: {e}", path.display())))?; if crate::MODEL_EXTENSIONS.contains(&ext_of(path).as_str()) { @@ -176,59 +164,3 @@ fn probe_still_bytes(path: &str, bytes: &[u8], fmt: image::ImageFormat) -> Resul }; Ok(still_footage(path, w, h, fmt, has_alpha)) } - -fn probe_sequence(files: &[PathBuf]) -> Result { - let first = &files[0]; - let mut f = probe_single(first)?; - f.kind = FootageKind::Sequence; - f.frame_rate = DEFAULT_SEQUENCE_RATE; - f.duration = DEFAULT_SEQUENCE_RATE.tick_of(files.len() as i64); - f.sequence = files.iter().map(|p| p.to_string_lossy().to_string()).collect(); - Ok(f) -} - -/// Split a file stem into (prefix, frame number digits). `None` when it does not end in digits. -fn split_number(stem: &str) -> Option<(&str, &str)> { - let digits = stem.bytes().rev().take_while(u8::is_ascii_digit).count(); - (digits > 0).then(|| stem.split_at(stem.len() - digits)) -} - -/// The numbered files of the image sequence `path` belongs to (sorted by frame number; empty when -/// the name has no frame number). Siblings must share the prefix and extension. -pub fn sequence_files(path: impl AsRef) -> Vec { - let path = path.as_ref(); - let ext = ext_of(path); - let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else { return Vec::new() }; - let Some((prefix, _)) = split_number(stem) else { return Vec::new() }; - let dir = path.parent().filter(|d| !d.as_os_str().is_empty()).unwrap_or(Path::new(".")); - let Ok(rd) = std::fs::read_dir(dir) else { return Vec::new() }; - let mut out: Vec<(u64, PathBuf)> = rd - .filter_map(|e| e.ok()) - .filter_map(|e| { - let p = e.path(); - if ext_of(&p) != ext || !p.is_file() { - return None; - } - let s = p.file_stem()?.to_str()?; - let (pre, num) = split_number(s)?; - if pre != prefix { - return None; - } - Some((num.parse().ok()?, p)) - }) - .collect(); - out.sort(); - out.into_iter().map(|(_, p)| p).collect() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn split_numbers() { - assert_eq!(split_number("shot_0012"), Some(("shot_", "0012"))); - assert_eq!(split_number("shot"), None); - assert_eq!(split_number("v2_10"), Some(("v2_", "10"))); - } -} diff --git a/crates/media/tests/footage.rs b/crates/media/tests/footage.rs index e7819610..a106041c 100644 --- a/crates/media/tests/footage.rs +++ b/crates/media/tests/footage.rs @@ -9,8 +9,8 @@ use std::process::Command; use std::sync::Arc; use std::time::Instant; -use effectcraft_media::{MediaPool, probe, probe_single}; -use effectcraft_project::{AlphaMode, FootageKind, ItemId}; +use effectcraft_media::{MediaPool, probe}; +use effectcraft_project::{AlphaMode, Footage, FootageKind, ItemId, MissingFrames}; use effectcraft_raster::Image; use effectcraft_render::FootageSource; use effectcraft_time::{FrameRate, TICKS_PER_SECOND, Tick}; @@ -359,9 +359,12 @@ fn vp9_webm_frames() { #[test] fn png_sequence_import() { let Some(first) = png_sequence() else { return }; - let f = probe(&first).expect("probe"); - assert_eq!(f.kind, FootageKind::Sequence); - assert_eq!(f.sequence.len(), 10); + // One file probes as a still; the engine groups numbered files into the sequence. + let mut f = probe(&first).expect("probe"); + assert_eq!(f.kind, FootageKind::Still); + f.kind = FootageKind::Sequence; + f.sequence = (1..=10).map(|n| first.with_file_name(format!("seq_{n:04}.png")).to_string_lossy().to_string()).collect(); + f.sync_sequence_duration(); assert_eq!((f.width, f.height), (320, 240)); assert_eq!(f.duration, FrameRate::FPS_30.tick_of(10)); let pool = MediaPool::new(); @@ -374,9 +377,43 @@ fn png_sequence_import() { looped.loop_count = 2; let img = pool.frame_at(&looped, t_of(f.frame_rate, 13)).expect("frame"); assert_eq!(*img, load_png(&first.with_file_name("seq_0004.png"))); - // a single frame of it - let one = probe_single(&first).expect("probe"); - assert_eq!(one.kind, FootageKind::Still); +} + +/// A gap in a sequence's numbering shows colour bars (Missing Frames ▸ Show Placeholder), the +/// frame before it (Hold) or nothing at all (Skip: the files play back to back) (#297). +#[test] +fn sequence_gaps_show_placeholders_hold_or_close_up() { + let dir = fixtures().join(format!("gap-seq-{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("mkdir"); + // Frames 1, 2 and 4: frame 3 is missing. Each frame is a flat grey of its number. + let grey = |n: u8| image::RgbaImage::from_pixel(8, 4, image::Rgba([n * 50, n * 50, n * 50, 255])); + let mut files = vec![]; + for n in [1u8, 2, 4] { + let p = dir.join(format!("gap_{n:03}.png")); + grey(n).save(&p).expect("write png"); + files.push(p.to_string_lossy().to_string()); + } + let mut f = + Footage { kind: FootageKind::Sequence, sequence: files, missing_frames: MissingFrames::Placeholder, ..probe(dir.join("gap_001.png")).expect("probe") }; + f.sync_sequence_duration(); + assert_eq!(f.duration, f.frame_rate.tick_of(4), "frames 1-4"); + let pool = MediaPool::new(); + let at = |f: &Footage, n: i64| pool.frame_at(f, t_of(f.frame_rate, n)).expect("frame"); + let level = |img: &Image| (img.get(0, 0)[0] * 255.0).round() as u32; + assert_eq!(level(&at(&f, 1)), 100); + // The placeholder: colour bars across the frame, starting with 75 % white. + let bars = at(&f, 2); + assert_eq!((bars.width, bars.height), (8, 4)); + assert_eq!(bars.get(0, 0), [0.75, 0.75, 0.75, 1.0]); + assert_eq!(bars.get(7, 3), [0.0, 0.0, 0.75, 1.0], "blue on the right"); + assert_eq!(level(&at(&f, 3)), 200); + f.missing_frames = MissingFrames::Hold; + assert_eq!(level(&at(&f, 2)), 100, "holds frame 2"); + f.missing_frames = MissingFrames::Skip; + f.sync_sequence_duration(); + assert_eq!(f.duration, f.frame_rate.tick_of(3)); + assert_eq!(level(&at(&f, 2)), 200, "frame 4 follows frame 2"); + let _ = std::fs::remove_dir_all(&dir); } #[test] diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index ebd1bdef..21382572 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -11,6 +11,7 @@ pub mod essential; pub mod props; pub mod render_queue; pub mod render_templates; +pub mod sequence; pub mod styles; pub mod tracking; @@ -21,6 +22,7 @@ pub use effectcraft_color::ColorSpace; use effectcraft_color::{BlendMode, Label}; use effectcraft_time::{FrameRate, Tick}; pub use props::{Expression, FeatherFalloff, GroupKind, MaskMode, MaskMotionBlur, Node, ParamUi, PropGroup, Property, Uid, parse_path}; +pub use sequence::MissingFrames; use serde::{Deserialize, Serialize}; pub use effectcraft_keyframe as keyframe; @@ -963,6 +965,13 @@ pub struct Footage { /// PDF / Illustrator footage: the page shown (0-based; File ▸ Import ▸ Page). #[serde(default, skip_serializing_if = "is_default")] pub page: u32, + /// Image sequences: Interpret Footage ▸ Missing Frames ([`sequence`]). + #[serde(default, skip_serializing_if = "is_default")] + pub missing_frames: MissingFrames, + /// Image sequences: Interpret Footage ▸ Start Frame, the frame number at the footage's first + /// frame (`None` = the first file's). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub start_frame: Option, } fn is_default(v: &T) -> bool { @@ -995,6 +1004,8 @@ impl Default for Footage { data: None, layer: None, page: 0, + missing_frames: MissingFrames::Skip, + start_frame: None, } } } diff --git a/crates/project/src/sequence.rs b/crates/project/src/sequence.rs new file mode 100644 index 00000000..cad2b337 --- /dev/null +++ b/crates/project/src/sequence.rs @@ -0,0 +1,253 @@ +//! Image sequences: numbered stills (`shot_0001.png`, `shot_0002.png`, …) played as one footage +//! item ([`FootageKind::Sequence`]). +//! +//! A sequence's files are kept in frame-number order ([`Footage::sequence`]). How its frames map +//! to the files follows Interpret Footage: **Start Frame** ([`Footage::start_frame`]) is the +//! frame number shown at the footage's first frame, and **Missing Frames** +//! ([`Footage::missing_frames`]) says what a gap in the numbering shows. + +use serde::{Deserialize, Serialize}; + +use crate::{Footage, FootageKind}; + +/// The most frames an image sequence's numbering can span (a gap of millions of frames is a +/// naming accident, such as dates in file names, not footage). +pub const MAX_SEQUENCE_FRAMES: i64 = 10_000_000; + +/// Interpret Footage ▸ Missing Frames: what an image sequence shows where its numbering skips a +/// number. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum MissingFrames { + /// The files play one after another and the gaps close up (and a sequence imported with + /// Force Alphabetical Order, whose numbering doesn't count). + #[default] + Skip, + /// A missing frame shows colour bars, as in After Effects. + Placeholder, + /// A missing frame shows the frame before it. + Hold, +} + +impl MissingFrames { + pub const ALL: [MissingFrames; 3] = [MissingFrames::Placeholder, MissingFrames::Hold, MissingFrames::Skip]; + + /// The command parameter value. + pub fn id(self) -> &'static str { + match self { + MissingFrames::Skip => "skip", + MissingFrames::Placeholder => "placeholder", + MissingFrames::Hold => "hold", + } + } + + pub fn label(self) -> &'static str { + match self { + MissingFrames::Skip => "Skip (close the gaps)", + MissingFrames::Placeholder => "Show Placeholder (color bars)", + MissingFrames::Hold => "Hold Previous Frame", + } + } + + pub fn parse(s: &str) -> Option { + MissingFrames::ALL.into_iter().find(|m| m.id().eq_ignore_ascii_case(s.trim())) + } +} + +/// Split a file stem into (prefix, frame number digits): `shot_0012` → (`shot_`, `0012`). +/// `None` when it doesn't end in digits. +pub fn split_frame_number(stem: &str) -> Option<(&str, &str)> { + let digits = stem.bytes().rev().take_while(u8::is_ascii_digit).count(); + // ASCII digits are one byte each, so the split is on a char boundary. + (digits > 0).then(|| stem.split_at(stem.len() - digits)) +} + +/// The file stem of a path (either separator, so Windows paths split on any host). +fn stem_of(path: &str) -> &str { + let name = path.rsplit(['/', '\\']).next().unwrap_or(path); + match name.rsplit_once('.') { + Some((stem, _)) if !stem.is_empty() => stem, + _ => name, + } +} + +/// The frame number at the end of a file name (`shot_0012.png` → 12); `None` without one. +pub fn frame_number(path: &str) -> Option { + let (_, digits) = split_frame_number(stem_of(path))?; + // Keep the last 15 digits: longer runs would overflow and aren't frame numbers anyway. + digits.get(digits.len().saturating_sub(15)..)?.parse().ok() +} + +/// The After Effects name of a numbered sequence: `shot_[0001-0250].png`. `None` when the first +/// file isn't numbered. +pub fn sequence_name(files: &[String]) -> Option { + let (first, last) = (files.first()?, files.last()?); + let name = |p: &str| p.rsplit(['/', '\\']).next().unwrap_or(p).to_string(); + let (prefix, a) = split_frame_number(stem_of(first))?; + let (_, b) = split_frame_number(stem_of(last))?; + let first_name = name(first); + let ext = first_name.rsplit_once('.').map(|(_, e)| format!(".{e}")).unwrap_or_default(); + Some(format!("{prefix}[{a}-{b}]{ext}")) +} + +/// Frame numbers missing from a run of numbered files, as `(from, to)` ranges. +pub fn missing_frame_ranges(files: &[String]) -> Vec<(i64, i64)> { + let mut ns: Vec = files.iter().filter_map(|p| frame_number(p)).collect(); + ns.sort_unstable(); + ns.dedup(); + ns.windows(2).filter(|w| w[1].saturating_sub(w[0]) > 1).map(|w| (w[0].saturating_add(1), w[1].saturating_sub(1))).collect() +} + +impl Footage { + /// Whether this is an image sequence with files. + fn is_file_sequence(&self) -> bool { + self.kind == FootageKind::Sequence && !self.sequence.is_empty() + } + + /// Frame number of the sequence's `i`th file. + fn file_number(&self, i: usize) -> Option { + self.sequence.get(i).and_then(|p| frame_number(p)) + } + + /// The numbering is used: Placeholder / Hold and a numbered first file. + fn numbered(&self) -> Option { + (self.missing_frames != MissingFrames::Skip).then(|| self.file_number(0)).flatten() + } + + /// Index of the first file numbered `n` or later. + fn first_file_from(&self, n: i64) -> usize { + self.sequence.partition_point(|p| frame_number(p).is_some_and(|x| x < n)) + } + + /// The frame number at the footage's first frame: Start Frame, or the first file's number + /// (0 when unnumbered). + pub fn first_frame_number(&self) -> i64 { + self.start_frame.or_else(|| self.file_number(0)).unwrap_or(0) + } + + /// How many frames an image sequence has: the span of its numbering from Start Frame (with + /// Placeholder / Hold), or its files from Start Frame on (Skip). At least 1; 0 for other + /// footage. + pub fn sequence_frames(&self) -> i64 { + if !self.is_file_sequence() { + return 0; + } + let n = match self.numbered() { + Some(first) => { + let last = self.sequence.len().checked_sub(1).and_then(|i| self.file_number(i)).unwrap_or(first); + last.saturating_sub(self.start_frame.unwrap_or(first)).saturating_add(1) + } + None => { + let skip = self.start_frame.map_or(0, |s| self.first_file_from(s)); + self.sequence.len().saturating_sub(skip) as i64 + } + }; + n.clamp(1, MAX_SEQUENCE_FRAMES) + } + + /// The file an image sequence shows at its frame `i` (0-based, after looping). `None` for a + /// missing frame shown as a placeholder (or when there are no files). + pub fn sequence_file(&self, i: i64) -> Option<&str> { + if !self.is_file_sequence() { + return None; + } + let Some(first) = self.numbered() else { + let skip = self.start_frame.map_or(0, |s| self.first_file_from(s)); + let k = usize::try_from(i).ok()?.saturating_add(skip); + // Past the last file (Start Frame after it): the last file. + return self.sequence.get(k).or(self.sequence.last()).map(String::as_str); + }; + let n = self.start_frame.unwrap_or(first).saturating_add(i); + let k = self.first_file_from(n); + match self.sequence.get(k) { + Some(p) if frame_number(p) == Some(n) => Some(p), + _ if self.missing_frames == MissingFrames::Hold => self.sequence.get(k.saturating_sub(1)).map(String::as_str), + _ => None, + } + } + + /// Set an image sequence's duration to its frames at its frame rate (after its files, + /// frame rate, Missing Frames or Start Frame changed). + pub fn sync_sequence_duration(&mut self) { + if self.is_file_sequence() { + self.duration = self.frame_rate.tick_of(self.sequence_frames()); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn seq(numbers: &[i64], missing: MissingFrames) -> Footage { + Footage { + kind: FootageKind::Sequence, + sequence: numbers.iter().map(|n| format!("C:\\shots\\a_{n:04}.png")).collect(), + missing_frames: missing, + ..Default::default() + } + } + + #[test] + fn numbers_and_names() { + assert_eq!(split_frame_number("shot_0012"), Some(("shot_", "0012"))); + assert_eq!(split_frame_number("shot"), None); + assert_eq!(split_frame_number("v2_10"), Some(("v2_", "10"))); + assert_eq!(frame_number("/a/v2/shot_0012.exr"), Some(12)); + assert_eq!(frame_number("C:\\a.b\\shot.png"), None); + assert_eq!(frame_number("/a/99999999999999999999999.png"), Some(999_999_999_999_999)); + let files: Vec = ["/s/fx_0998.png", "/s/fx_1002.png"].map(String::from).to_vec(); + assert_eq!(sequence_name(&files).as_deref(), Some("fx_[0998-1002].png")); + assert_eq!(sequence_name(&["/s/a.png".to_string()]), None); + assert_eq!(MissingFrames::parse("Hold"), Some(MissingFrames::Hold)); + } + + #[test] + fn missing_frames_map_frames_to_files() { + // Files 1, 2, 5: frames 3 and 4 are missing. + let f = seq(&[1, 2, 5], MissingFrames::Placeholder); + assert_eq!(f.sequence_frames(), 5); + let at = |f: &Footage, i| f.sequence_file(i).map(|p| frame_number(p).unwrap_or(-1)); + assert_eq!((0..5).map(|i| at(&f, i)).collect::>(), [Some(1), Some(2), None, None, Some(5)]); + let h = seq(&[1, 2, 5], MissingFrames::Hold); + assert_eq!((0..5).map(|i| at(&h, i)).collect::>(), [Some(1), Some(2), Some(2), Some(2), Some(5)]); + let s = seq(&[1, 2, 5], MissingFrames::Skip); + assert_eq!(s.sequence_frames(), 3); + assert_eq!((0..3).map(|i| at(&s, i)).collect::>(), [Some(1), Some(2), Some(5)]); + assert_eq!(missing_frame_ranges(&f.sequence), [(3, 4)]); + } + + #[test] + fn start_frame_trims_or_pads_the_head() { + let at = |f: &Footage, i| f.sequence_file(i).map(|p| frame_number(p).unwrap_or(-1)); + let mut f = seq(&[1001, 1002, 1003], MissingFrames::Placeholder); + assert_eq!(f.first_frame_number(), 1001); + f.start_frame = Some(1002); + assert_eq!((f.sequence_frames(), at(&f, 0), at(&f, 1)), (2, Some(1002), Some(1003))); + f.start_frame = Some(999); + assert_eq!((f.sequence_frames(), at(&f, 0), at(&f, 2)), (5, None, Some(1001))); + f.missing_frames = MissingFrames::Hold; + assert_eq!(at(&f, 0), Some(1001), "nothing before the first file: hold it"); + f.missing_frames = MissingFrames::Skip; + f.start_frame = Some(1003); + assert_eq!((f.sequence_frames(), at(&f, 0)), (1, Some(1003))); + // Hostile values never panic and stay bounded. + f.missing_frames = MissingFrames::Placeholder; + f.start_frame = Some(i64::MIN); + assert_eq!(f.sequence_frames(), MAX_SEQUENCE_FRAMES); + assert_eq!(f.sequence_file(i64::MAX), None); + f.start_frame = Some(i64::MAX); + assert_eq!(f.sequence_frames(), 1); + f.missing_frames = MissingFrames::Skip; + assert_eq!(f.sequence_file(-1), None); + assert!(f.sequence_file(5).is_some()); + } + + #[test] + fn duration_follows_frames_and_rate() { + let mut f = seq(&[1, 2, 5], MissingFrames::Placeholder); + f.frame_rate = effectcraft_time::FrameRate::from_f64(10.0); + f.sync_sequence_duration(); + assert!((f.duration.seconds() - 0.5).abs() < 1e-9); + assert_eq!(Footage::default().sequence_frames(), 0); + } +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 1128d6c0..a31f4b39 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1200,8 +1200,12 @@ fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option bool { keys.iter().any(|k| p.get(*k).is_some()) } -/// If `id` is a dialog command invoked without its parameters, open its form and return true. +/// The files of an import's parameters (`paths` or `path`). +fn import_paths(p: &Value) -> 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()], + _ => vec![], + } +} + +/// The import includes a file with one of these (lowercase) extensions. +fn imports_ext(p: &Value, exts: &[&str]) -> bool { + import_paths(p).iter().any(|s| s.rsplit('.').next().is_some_and(|e| exts.contains(&e.to_ascii_lowercase().as_str()))) +} + /// The import includes a Photoshop document. fn is_layered(p: &Value) -> bool { - let psd = |v: &Value| v.as_str().is_some_and(|s| matches!(s.rsplit('.').next().map(str::to_ascii_lowercase).as_deref(), Some("psd" | "psb"))); - match p.get("paths").or(p.get("path")) { - Some(Value::Array(a)) => a.iter().any(psd), - Some(v) => psd(v), - None => false, - } + imports_ext(p, &["psd", "psb"]) } /// The import includes a PDF or Illustrator file (which may have several pages). fn is_pdf(p: &Value) -> bool { - let pdf = |v: &Value| v.as_str().is_some_and(|s| matches!(s.rsplit('.').next().map(str::to_ascii_lowercase).as_deref(), Some("pdf" | "ai"))); - match p.get("paths").or(p.get("path")) { - Some(Value::Array(a)) => a.iter().any(pdf), - Some(v) => pdf(v), - None => false, - } + imports_ext(p, &["pdf", "ai"]) } +/// File ▸ Import ▸ File… picked numbered stills that import as image sequences: ask, as After +/// Effects' " Sequence" checkbox in its Import dialog does (rfd's native dialog can't +/// carry a checkbox), with Force Alphabetical Order and the frame rate. `p`: the import's +/// parameters. Returns whether the dialog opened. Dropped files and agents' `file.import` +/// don't ask (`sequence` defaults to on). +pub fn open_import_sequence(app: &mut EffectcraftApp, p: &Value) -> bool { + if has(p, &["sequence", "alphabetical"]) { + return false; + } + let runs = effectcraft_engine::sequence::sequences(app.session.services.as_ref(), &import_paths(p), false); + if runs.is_empty() { + return false; + } + let fields = sequence_fields(app, &runs); + form(app, "Import Image Sequence", "file.import", p.clone(), fields); + true +} + +/// The Import Image Sequence dialog's fields for `runs` (each a sequence's files). +fn sequence_fields(app: &EffectcraftApp, runs: &[Vec]) -> Vec { + use effectcraft_engine::project::sequence::sequence_name; + let first = runs.first().map(Vec::as_slice).unwrap_or_default(); + let ext = first.first().and_then(|f| f.rsplit_once('.')).map(|(_, e)| e.to_ascii_uppercase()).unwrap_or_default(); + let what = |files: &[String]| { + let name = sequence_name(files).unwrap_or_default(); + match effectcraft_engine::sequence::missing_report(files) { + Some(gaps) => format!("{name}: {} files, {gaps}", files.len()), + None => format!("{name}: {} files", files.len()), + } + }; + let note = match runs { + [one] => format!("{} – numbered stills of one image sequence.", what(one)), + _ => format!("{} image sequences: {}.", runs.len(), runs.iter().map(|r| what(r)).collect::>().join("; ")), + }; + vec![ + Field::note("note", ¬e), + Field::bool("sequence", &format!("{ext} Sequence"), true), + Field::bool("alphabetical", "Force alphabetical order", false), + Field::num("frameRate", "Frame rate (fps)", app.session.prefs.import.sequence_fps), + Field::note("after", "Change the frame rate, alpha, missing frames and start frame later with File ▸ Interpret Footage."), + ] +} + +/// If `id` is a dialog command invoked without its parameters, open its form and return true. pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool { let s = &app.session; let comp = s.active_comp(); @@ -346,7 +393,22 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool { ], ), "file.interpretFootage" | "file.interpretProxy" - if !has(p, &["frameRate", "alpha", "loop", "pixelAspect", "colorProfile", "fields", "invertAlpha", "matteColor", "linearLight"]) => + if !has( + p, + &[ + "frameRate", + "alpha", + "loop", + "pixelAspect", + "colorProfile", + "fields", + "invertAlpha", + "matteColor", + "linearLight", + "missingFrames", + "startFrame", + ], + ) => { let proxy = id == "file.interpretProxy"; let f = s.state.project_selection.first().and_then(|i| s.project.item(*i)).and_then(|it| match (&it.kind, &it.proxy) { @@ -356,53 +418,67 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool { }); let Some(f) = f else { return false }; let hex = |c: [f32; 3]| format!("#{:02x}{:02x}{:02x}", (c[0] * 255.0).round() as u8, (c[1] * 255.0).round() as u8, (c[2] * 255.0).round() as u8); - ( - if proxy { "Interpret Footage: Proxy".into() } else { "Interpret Footage".into() }, - vec![ - // Main Options ▸ Alpha. - Field::choice( - "alpha", - "Alpha", - &[ - ("Interpret Straight - Unmatted", json!("straight")), - ("Interpret Premultiplied - Matted With Color", json!("premultiplied")), - ("Ignore", json!("ignore")), - ("Guess", json!("guess")), - ], - f.alpha as usize, - ), - Field::text("matteColor", "Matte color (premultiplied)", &hex(f.premul_color)), - Field::bool("invertAlpha", "Invert Alpha", f.invert_alpha), - // Main Options ▸ Frame Rate, Fields and Pulldown, Other Options. - Field::num("frameRate", "Assume this frame rate", f.frame_rate.as_f64()), - Field::choice( - "fields", - "Separate Fields", - &[("Off", json!("off")), ("Upper Field First", json!("upper")), ("Lower Field First", json!("lower"))], - f.fields as usize, - ), - Field::num("pixelAspect", "Pixel Aspect Ratio", f.pixel_aspect), - Field::num("loop", "Loop (times)", f.loop_count as f64), - // Color. - Field::choice( - "colorProfile", - "Assign Profile", - &[ - ("Embedded / sRGB", json!("auto")), - ("sRGB IEC61966-2.1", json!("srgb")), - ("HDTV (Rec. 709)", json!("rec709")), - ("Rec. 2020", json!("rec2020")), - ("Display P3", json!("p3")), - ("ACEScg", json!("acescg")), - ("ACES2065-1", json!("aces2065")), - ("Rec. 2100 PQ", json!("rec2100pq")), - ("Rec. 2100 HLG", json!("rec2100hlg")), - ], - f.color_profile.map_or(0, |c| 1 + effectcraft_engine::project::ColorSpace::ALL.iter().position(|x| *x == c).unwrap_or(0)), - ), - Field::bool("linearLight", "Interpret As Linear Light", f.linear_light), - ], - ) + let mut fields = vec![ + // Main Options ▸ Alpha. + Field::choice( + "alpha", + "Alpha", + &[ + ("Interpret Straight - Unmatted", json!("straight")), + ("Interpret Premultiplied - Matted With Color", json!("premultiplied")), + ("Ignore", json!("ignore")), + ("Guess", json!("guess")), + ], + f.alpha as usize, + ), + Field::text("matteColor", "Matte color (premultiplied)", &hex(f.premul_color)), + Field::bool("invertAlpha", "Invert Alpha", f.invert_alpha), + // Main Options ▸ Frame Rate, Fields and Pulldown, Other Options. + Field::num("frameRate", "Assume this frame rate", f.frame_rate.as_f64()), + Field::choice( + "fields", + "Separate Fields", + &[("Off", json!("off")), ("Upper Field First", json!("upper")), ("Lower Field First", json!("lower"))], + f.fields as usize, + ), + Field::num("pixelAspect", "Pixel Aspect Ratio", f.pixel_aspect), + Field::num("loop", "Loop (times)", f.loop_count as f64), + // Color. + Field::choice( + "colorProfile", + "Assign Profile", + &[ + ("Embedded / sRGB", json!("auto")), + ("sRGB IEC61966-2.1", json!("srgb")), + ("HDTV (Rec. 709)", json!("rec709")), + ("Rec. 2020", json!("rec2020")), + ("Display P3", json!("p3")), + ("ACEScg", json!("acescg")), + ("ACES2065-1", json!("aces2065")), + ("Rec. 2100 PQ", json!("rec2100pq")), + ("Rec. 2100 HLG", json!("rec2100hlg")), + ], + f.color_profile.map_or(0, |c| 1 + effectcraft_engine::project::ColorSpace::ALL.iter().position(|x| *x == c).unwrap_or(0)), + ), + Field::bool("linearLight", "Interpret As Linear Light", f.linear_light), + ]; + // Image sequences: what a gap in the numbering shows, and the first frame's number. + if f.kind == effectcraft_engine::project::FootageKind::Sequence { + use effectcraft_engine::project::MissingFrames; + let at = fields.iter().position(|x| x.key == "frameRate").map_or(fields.len(), |i| i + 1); + let gaps = effectcraft_engine::sequence::missing_report(&f.sequence).unwrap_or_else(|| "no missing frames".into()); + let options: Vec<(&str, Value)> = MissingFrames::ALL.iter().map(|m| (m.label(), json!(m.id()))).collect(); + let sel = MissingFrames::ALL.iter().position(|m| *m == f.missing_frames).unwrap_or(0); + fields.splice( + at..at, + [ + Field::note("sequenceInfo", &format!("Image sequence: {} files, {} frames; {gaps}.", f.sequence.len(), f.sequence_frames())), + Field::choice("missingFrames", "Missing Frames", &options, sel), + Field::num("startFrame", "Start Frame", f.first_frame_number() as f64), + ], + ); + } + (if proxy { "Interpret Footage: Proxy".into() } else { "Interpret Footage".into() }, fields) } "file.projectSettings" if p.as_object().is_none_or(|m| m.is_empty()) => { let st = &s.project.settings; @@ -668,10 +744,20 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool { pub fn show_form(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { let mut f = app.dialog_state.form.clone(); let (mut ok, mut close) = (false, false); - let h = 120.0 + f.fields.len() as f32 * 30.0; + // Rows are 30 px; a note wraps at about 40 characters a line, so OK and Cancel stay in view. + let rows: f32 = f + .fields + .iter() + .map(|fl| match &fl.kind { + FieldKind::Note(text) => 10.0 + 15.0 * (text.chars().count() as f32 / 40.0).ceil().max(1.0), + _ => 30.0, + }) + .sum(); + let h = 120.0 + rows; let mut regs: Vec<(String, egui::Rect, String)> = vec![]; let mut browse: Option = None; - super::dialogs::modal(ctx, &f.title.clone(), vec2(440.0, h), t, |ui| { + // Wide enough for a label column and a 240 px popup without a horizontal scroll bar. + super::dialogs::modal(ctx, &f.title.clone(), vec2(480.0, h), t, |ui| { egui::Grid::new("form-grid").num_columns(2).spacing([14.0, 10.0]).show(ui, |ui| { for (i, fl) in f.fields.iter_mut().enumerate() { ui.label(&fl.label); @@ -705,7 +791,16 @@ pub fn show_form(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { }) .inner } - FieldKind::Note(text) => ui.add(egui::Label::new(egui::RichText::new(text.as_str()).small()).wrap()).rect, + // A fixed width: in the grid's value column a wrapping label would otherwise + // shrink to a word per line. + FieldKind::Note(text) => { + ui.scope(|ui| { + ui.set_width(220.0); + ui.add(egui::Label::new(egui::RichText::new(text.as_str()).small()).wrap()) + }) + .inner + .rect + } FieldKind::Bool(b) => ui.checkbox(b, "").rect, FieldKind::Choice { options, sel } => { let cur = options.get(*sel).map(|o| o.0.clone()).unwrap_or_default(); diff --git a/crates/ui-egui/tests/ui_drag_drop.rs b/crates/ui-egui/tests/ui_drag_drop.rs index 7c35f6ec..dc09c4ec 100644 --- a/crates/ui-egui/tests/ui_drag_drop.rs +++ b/crates/ui-egui/tests/ui_drag_drop.rs @@ -257,3 +257,109 @@ fn dropped_files_show_import_progress_then_the_items_they_made() { fn card_id(h: &Harness<'_, EffectcraftApp>) -> String { h.state().auto.query("import.job.").first().map(|e| e.id.clone()).unwrap_or_default() } + +/// Probes any existing file as a 64×32 PNG still. +struct Stills; + +impl effectcraft_engine::Importer for Stills { + fn probe(&self, path: &str) -> Result { + if !std::path::Path::new(path).is_file() { + return Err(format!("{path}: no such file")); + } + Ok(effectcraft_engine::project::Footage { path: path.into(), width: 64, height: 32, has_video: true, codec: "PNG".into(), ..Default::default() }) + } +} + +/// A fresh folder holding empty `frame_0001.png` … `frame_000.png`. +fn frames(tag: &str, n: u32) -> std::path::PathBuf { + let d = std::env::temp_dir().join(format!("ec-ui-seq-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + for k in 1..=n { + std::fs::write(d.join(format!("frame_{k:04}.png")), b"").unwrap(); + } + d +} + +fn click_id(h: &mut Harness<'_, EffectcraftApp>, id: &str) { + // A dialog that just opened settles its size and place in a frame or two. + h.run_steps(3); + let p = rect(h, id).center(); + h.event(Event::PointerMoved(p)); + h.step(); + h.event(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.step(); + h.event(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); +} + +/// (name, sequence files) of every footage item. +fn footage_items(h: &Harness<'_, EffectcraftApp>) -> Vec<(String, usize)> { + h.state() + .session + .project + .items + .values() + .filter_map(|i| match &i.kind { + effectcraft_engine::project::ItemKind::Footage(f) => Some((i.name.clone(), f.sequence.len())), + _ => None, + }) + .collect() +} + +/// #297: File ▸ Import ▸ File… on one numbered still asks, as After Effects' "PNG Sequence" +/// checkbox does, whether to import its whole run as one image sequence (and at what frame +/// rate); unticked, the file imports as a still. +#[test] +fn import_dialog_offers_the_image_sequence_option() { + let (mut h, _) = harness(); + let dir = frames("dialog", 3); + let pick = dir.join("frame_0002.png").to_string_lossy().to_string(); + h.state_mut().session.importer = Some(Arc::new(Stills)); + h.state_mut().hooks.pick_files = Some(Box::new(move |_: &[&str]| vec![pick.clone()])); + let ctx = h.ctx.clone(); + effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "file.import", json!({})).unwrap(); + h.run_steps(2); + assert_eq!(h.state().dialog, Some(effectcraft_ui_egui::Dialog::Form)); + let label = |h: &Harness<'_, EffectcraftApp>, id: &str| h.state().auto.find(id).map(|e| e.label.clone()); + assert_eq!(label(&h, "form.field.sequence").as_deref(), Some("PNG Sequence")); + assert!(label(&h, "form.field.alphabetical").is_some() && label(&h, "form.field.frameRate").is_some()); + assert!(footage_items(&h).is_empty(), "nothing imported before OK"); + click_id(&mut h, "form.ok"); + step_until(&mut h, |h| !footage_items(h).is_empty()); + assert_eq!(footage_items(&h), [("frame_[0001-0003].png".to_string(), 3)]); + + // Unticked: the picked file alone, as a still. + effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "file.import", json!({})).unwrap(); + h.run_steps(2); + click_id(&mut h, "form.field.sequence"); + click_id(&mut h, "form.ok"); + step_until(&mut h, |h| footage_items(h).len() == 2); + assert_eq!(footage_items(&h)[1], ("frame_0002.png".to_string(), 0)); + let _ = std::fs::remove_dir_all(&dir); +} + +/// #297: every frame of a sequence dropped on the window arrives as one image sequence (it used +/// to make one item per frame), and Interpret Footage shows its Missing Frames and Start Frame. +#[test] +fn dropped_frames_import_as_one_sequence_with_sequence_interpretation() { + let (mut h, _) = harness(); + let dir = frames("drop", 4); + h.state_mut().session.importer = Some(Arc::new(Stills)); + for k in 1..=4 { + h.input_mut().dropped_files.push(Arc::new(Dropped(dir.join(format!("frame_{k:04}.png"))))); + } + h.run_steps(2); + step_until(&mut h, |h| !footage_items(h).is_empty()); + h.run_steps(2); + assert_eq!(footage_items(&h), [("frame_[0001-0004].png".to_string(), 4)]); + assert!(h.state().dialog.is_none(), "drops don't ask"); + // Interpret Footage on it (the import selected it). + let ctx = h.ctx.clone(); + effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "file.interpretFootage", json!({})).unwrap(); + h.run_steps(2); + for id in ["form.field.missingFrames", "form.field.startFrame", "form.field.frameRate", "form.field.alpha"] { + assert!(h.state().auto.find(id).is_some(), "{id}"); + } + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/docs/agents.md b/docs/agents.md index 90878e10..95b7dd19 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -205,8 +205,12 @@ build real projects through these interfaces; they are worked examples of everyt then `keys.velocity {"outSpeed":0,"outInfluence":33.33}`. * **Gradients**: `set_property {"path":"contents/gfill/colors","value":["#0080ff","#ffff00"]}` or `{"colors":[[0,"#0080ff"],[1,[1,1,0]]],"opacities":[[0,1],[1,0.5]]}`. -* **Image sequences** import at 30 fps (Settings ▸ Import); `file.interpretFootage {"items":[id], - "frameRate":12}` conforms them (the item and layers that ran to its end get the new length). +* **Image sequences**: `file.import {"paths":["/r/shot_0001.png"]}` imports the file's whole + numbered run as one item (several picked frames: that range; a folder: each run in it; + `"sequence": false`: one still; `"alphabetical": true`: Force Alphabetical Order) at + `frameRate` or 30 fps (Settings ▸ Import). `file.interpretFootage {"items":[id], + "frameRate":12, "startFrame":1001}` conforms them (the + item and layers that ran to its end get the new length). See [footage.md](footage.md). * **Multi-layer OpenEXR**: `layer.channels {layer}` lists a footage layer's EXR layers and channels (`{"layers":[{"name":"diffuse","rgba":["diffuse.R","diffuse.G","diffuse.B",""]}]}`); EXtractoR shows them when its `red` / `green` / `blue` / `alpha` are set to those names diff --git a/docs/footage.md b/docs/footage.md new file mode 100644 index 00000000..b27d7dab --- /dev/null +++ b/docs/footage.md @@ -0,0 +1,79 @@ +# Footage: importing files and image sequences + +How to bring footage into a project and tell EffectCraft how to read it. Everything here works +the way it does in After Effects, and every step is a command an agent can run too (shown in +`code`). + +## Importing files + +- **File ▸ Import ▸ File…** (Ctrl+I / Cmd+I) picks one or more files. Photoshop and PDF / + Illustrator files first ask whether to import them as footage or as a composition. +- **Drop files or folders** on the window. Dropped on the Composition viewer, + they also become layers there. +- **The Media Browser** (Window ▸ Media Browser) browses folders and imports what you pick. + +Agents: `file.import {"paths": ["/shots/plate.mov", "/shots/logo.psd"]}`. + +## Image sequences + +VFX plates, 3D renders and hand-drawn animation usually come as numbered stills: +`shot_0001.png`, `shot_0002.png`, … EffectCraft imports such a run as **one footage item** that +plays like a movie. Its name shows the range, for example `shot_[0001-0250].png`, and its type in +the Project panel is *Image Sequence*. + +**File ▸ Import ▸ File…:** pick any one numbered file of the run. The *Import Image Sequence* +dialog shows the run it found (and any missing frames) with these options: + +| Option | What it does | +|---|---| +| **PNG Sequence** (EXR, TIFF, JPG… after the file type) | On: the whole run imports as one sequence. Off: just the picked file imports, as a still. | +| **Force alphabetical order** | Imports every image of that type in the folder in name order, whatever the numbering. Use it for files that aren't numbered consistently. | +| **Frame rate (fps)** | The sequence's frame rate. The default comes from Settings ▸ Import ▸ Sequence Footage (30 fps). | + +Other ways in: + +- **Pick part of a run** (Shift-click the first and the last frame in the file dialog) to import + just that range as one sequence. +- **Pick or drop every frame** of the run: it arrives as one sequence, not one item per frame. +- **Drop a folder**: its media files import, and each numbered run in it becomes a sequence. +- **In the browser** (the web app) there are no folders to look in, so pick or drop all the + frames of the sequence at once. + +Stills of any format EffectCraft reads can form a sequence: PNG, JPEG, TIFF, OpenEXR, BMP and +WebP. Layered and vector documents (PSD, SVG, PDF) import one by one. + +Agents: `file.import {"paths": ["/shots/shot_0001.png"], "sequence": true, "frameRate": 24}`. +`sequence` is on by default; `"sequence": false` imports the file as a still, and +`"alphabetical": true` is Force Alphabetical Order. + +## Interpret Footage + +Select a footage item in the Project panel and choose **File ▸ Interpret Footage ▸ Main…** +(Ctrl+Alt+G / Cmd+Opt+G) to change how it is read. A change applies everywhere the footage is +used, and layers that ran to the end of the footage keep doing so. + +| Setting | What it does | +|---|---| +| **Alpha** | *Straight - Unmatted*, *Premultiplied - Matted With Color* (with the matte colour), *Ignore* (opaque), or *Guess* (EffectCraft looks at the first frame). *Invert Alpha* flips it. | +| **Assume this frame rate** | The rate the frames play at. A 250-frame sequence at 25 fps lasts 10 seconds; at 24 fps, 10.4 seconds. | +| **Missing Frames** (sequences) | What a gap in the numbering shows: *Show Placeholder* (color bars, as After Effects shows), *Hold Previous Frame*, or *Skip* (the files play back to back and the gap closes up). | +| **Start Frame** (sequences) | The frame number at the footage's first frame. It defaults to the first file's number. A higher number trims the head (start a 1001–1250 render at 1010); a lower one adds missing frames before the first file. | +| Fields, pixel aspect, loop, colour profile, linear light | As in After Effects. | + +Settings ▸ Import ▸ **Report Missing Frames** (on by default) lists the missing frame numbers +when a sequence imports. + +**File ▸ Interpret Footage ▸ Remember Interpretation / Apply Interpretation** copy the alpha, +fields, pixel aspect, loop and colour settings to other footage. + +Agents: `file.interpretFootage {"items": [id], "frameRate": 24, "alpha": "premultiplied", +"missingFrames": "hold", "startFrame": 1001}` (`"startFrame": "file"` goes back to the first +file's number). + +## Replacing and reloading + +- **File ▸ Replace Footage ▸ File…** (Ctrl+H) points the item at another file. Pick a numbered + file to replace it with that file's whole sequence (`file.replaceFootage {"path": …, + "sequence": false}` for one frame). +- **File ▸ Reload Footage** (Ctrl+Alt+L) reads the files again. A sequence picks up frames + added to its folder (a render still in progress) and keeps its interpretation. diff --git a/docs/parity.md b/docs/parity.md index 53be4d85..05d88bbd 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -172,7 +172,7 @@ the disk cache in the formats wave; the 3D Camera Tracker in M12.6.) | 3D | 88% | 5.5 | reflections of other layers (Advanced 3D surfaces reflect only the Environment light; Material Options ▸ Appears in Reflections is stored but has no visible effect yet, #261), multi-view layouts, the Extended Viewer for Advanced 3D comps (Classic 3D Extended Viewer landed in M13.5 UI completion); collapsed precomps of another size seen through the parent's camera render (fixed in M13.2); stereo rigs, orbit nulls, lights controlled by the camera, cameras/lights from glTF models, environment backgrounds, Advanced 3D motion blur, blend modes and track mattes landed in M7.7; Classic 3D iris-shaped bokeh with highlights, progressive depth of field on tilted layers and the focus-link commands landed in M13.5; Advanced 3D depth of field with the iris and highlight options, collapsed precomps as real Advanced 3D geometry and extruded text/shape strokes landed in M13.8; Advanced 3D (glTF/OBJ models, primitives, extruded text and shapes, PBR, image-based light, shadow maps, GPU rasteriser) in M7.4–M7.6; Advanced 3D end to end on the GPU (motion blur, iris depth of field, compositing) in M13.11 | | Effects | ≈ 85% | 3.0 | GPU versions of the remaining effects (280 run on the GPU since M13.29, EFF-5: M13.28 added the remaining pixel effects and the geometry and audio generators (Lightning, Advanced Lightning, Beam, Lens Flare, Radio Waves, Vegas, Stroke, Scribble, Write-on, Paint Bucket, Eyedropper Fill, CC Glue Gun, CC Threads, Audio Spectrum / Waveform, Basic / Path Text); M13.29 added the particle effects (CC Particle World / Systems II, Particle Playground, CC Ball Action, CC Pixel Polly, CC Scatterize, Curl Noise) and removed the fallbacks of Shatter's wireframe views, Foam's extras, Timewarp's Matte Layer, strong Warp Fisheye / Twist bends and custom `.ocio` configs; M13.23 added the 3D Channel and Immersive Video families, Apply Color LUT, the OCIO effects, Color Profile Converter and the simulations' render passes (Card Wipe included); M13.22 added the CC light family, the CC transitions, Block Dissolve, Radial Shadow, CC Bender / Blobbylize / Cylinder / Sphere / Spotlight / Environment, 3D Glasses, Numbers, Timecode and the time effects joined the 166 of M13.13; Fractal and the analysis / tool effects still render on the CPU) and the missing controls listed as partial in [effects.md](effects.md) (every After Effects effect exists since M9.11, M12.5 and M12.6; parameter names, order, twirl-downs, popups, units and defaults were aligned in M9.12) | | Interface | 75% | 5.0 | more Learn tutorials and pixel-level fidelity of dialogs (G2 on 9 October: the in-window menu bar works from the keyboard (Alt, arrows, Enter, Escape) and the app's shortcuts wait while a menu is open, #279; the Timeline's composition marker bin and numpad * add comp markers, #275; Settings ▸ Appearance ▸ UI Scale (75–200 %, beyond After Effects, which follows the system's scaling), #284; G2 on 7 October: Project items, files and effects dropped on the Composition viewer, Project items dropped in the Timeline between layers and at a time, #85, #88, #89; popup menus moved to fit the window take clicks, so Render Queue templates apply, #117; View ▸ New Viewer landed in M13.32; an automation id on every control of Composition and Solid Settings, Group / Ungroup Shapes in the Layer menu, Window ▸ Learn, the flowchart shortcuts and Reveal in Explorer landed in M3.10 and M3.13; the Home ▸ Templates gallery (eight original built-in templates, user templates from File ▸ Save as Template…) and View ▸ Simulate Output ▸ My Custom RGB… landed in M13.25; Timeline layer reordering by drag, a non-snapping viewer pan, a working rename field and twirl arrows, and the full set of property reveal shortcuts — double presses, Alt+Shift keyframes, Ctrl+` — landed in M13.17–M13.20; the Home ▸ Learn tab with interactive tutorials and a UI fidelity pass landed in M13.5 UI completion; visual editors for Lumetri RGB / hue-saturation curves, Colorama's output cycle wheel, Glow's colour map and Reshape's correspondence points (viewer handles), a scrolling Preview panel and AE-style Composition / Timeline tabs (close, label swatch, viewer lock) landed in M13.10; Timeline outline and Project panel columns scroll horizontally, the Layer Style dialog, ROI resize handles, Pan Behind snapping and 3D Reference Axes landed in M13.5; native macOS menu bar, Timeline columns/search/reveal-add, Home screen with recent projects and all AE workspaces landed; viewer rulers/snapping/channels/snapshots landed in M0.13) | -| Project | ≈ 68% | 5.5 | OCIO displays beyond the built-in tone map; relative footage paths and relinking when a project moves (unsaved-changes prompts, a modified mark that follows undo and project files that name their version and always reopen landed in M3.9 and M3.14; auto-save and crash recovery in M3.7; Color Engine with OCIO/ACES working spaces, HDR compand/tone mapping, Rec. 2100 PQ/HLG output, Feet + Frames, display colour management, Simulate Output and the locked viewer landed in M7.7; proxies and Interpret Footage fields / pixel aspect / alpha guess landed: PRJ-8, PRJ-3) | +| Project | ≈ 68% | 5.5 | OCIO displays beyond the built-in tone map; relative footage paths and relinking when a project moves (unsaved-changes prompts, a modified mark that follows undo and project files that name their version and always reopen landed in M3.9 and M3.14; auto-save and crash recovery in M3.7; Color Engine with OCIO/ACES working spaces, HDR compand/tone mapping, Rec. 2100 PQ/HLG output, Feet + Frames, display colour management, Simulate Output and the locked viewer landed in M7.7; proxies and Interpret Footage fields / pixel aspect / alpha guess landed: PRJ-8, PRJ-3; image sequence import options (Sequence, Force Alphabetical Order, frame rate, picked ranges, folders, the browser) and Interpret Footage Missing Frames / Start Frame landed: #297) | | Masks & roto | 74% | 5.0 | (audit estimates; Roto Brush 2.0 / 3.0's trained model, MobileSAM in the swappable `segment` module, has since landed in M13.35; variable-width mask feather points with the Mask Feather tool landed in M13.5; mask tracking and Mask Interpolation landed in M6.6; Roto Brush & Refine Edge with graph-cut segmentation, flow propagation, edge matting, decontamination and Freeze in M6.7) | | Preview | 77% | 2.8 | GPU effects for the audio-driven effects and the stepped particle systems; content-keyed RAM preview frames (an edit drops every comp's), audio scrubbing, more snapshot slots (G2 on 7 October: Audio, Lock and Shy switches keep the cached frames and playback with audio waits for frames instead of skipping them, #103; running out of video memory renders the frame on the CPU and keeps fewer GPU frames instead of crashing, #106; RAM preview keys with the render options, least-recently-shown eviction, Auto resolution while playing, Cache Before Playback that fits, an achieved-fps readout, Cache Frames When Idle and purges that clear what they name landed in M4.5–M4.8; 3D channel, VR, OCIO, Card Wipe and the simulations' render passes run on the GPU since M13.23; Advanced 3D layers with blend modes / track mattes / Preserve Transparency and environment backgrounds composite on the GPU since M13.22; pooled GPU textures and fused quantisation since M13.13: Lower Third GPU warm 21 → 4 ms/frame, and Auto now picks the CPU or the GPU per comp from measured frame times; Advanced 3D runs — raster, motion blur, iris depth of field, compositing — and wireframes run on the GPU since M13.11: CPU warm 3394 → GPU warm 290 ms/frame at 1920×1080 (11.7×; Half 968 → 85 ms), GPU ≠ CPU on 0.005 % of pixels, on an M4 Pro under load; Classic 3D bokeh depth of field — iris shapes, highlights, fringe, progressive blur on tilted planes — runs in WGSL since M13.6: 3D Showcase GPU warm 205 → 54 ms/frame at full size on an M4 Pro; the Preview panel's five shortcuts with their own Include / Loop / Cache Before Playback / Range / Play From / Frame Rate / Skip / Resolution / Full Screen / stop options and `playback.settings.get/set` landed in M13.8; Classic 3D runs and adjustment layers composite on the GPU since M12.7; persistent disk cache with the blue cache bar landed; region of interest, snapshots, exposure and Fast Previews landed in M0.13) | | Tracking | ≈ 96% | 0.3 | face tracking's trained model (MediaPipe Face Landmarker, M13.36) is an optional download; without it the classical (skin model + feature components + shape model) fitter is weak on profile views and occluded faces; Rolling Shutter Ripple is approximated by Subspace Warp's mesh density (face tracking (Outline Only / Detailed Features with Face Track Points and Extract & Copy Face Measurements), Subspace Warp's content-preserving mesh warp on subspace-smoothed trajectories, and radial lens distortion (k1, k2) in the camera bundle adjustment with Undistort Footage landed in M13.3; Rolling Shutter Repair in M9.11; point tracker, mask tracking, Warp Stabilizer and the 3D Camera Tracker in M6.x / M12.5 / M12.6) | diff --git a/docs/preferences.md b/docs/preferences.md index df948f0d..9e42d702 100644 --- a/docs/preferences.md +++ b/docs/preferences.md @@ -108,6 +108,7 @@ Commands (CLI, MCP, control channel): - `type.recentFonts`: how many recently used fonts head the font menu - `type.fontNamesInEnglish`: off, fonts with a native-language family name show it - `import.reportMissingFrames`: gaps in an image sequence's numbering are reported on import + (they show as placeholders; Interpret Footage ▸ Missing Frames, see [footage.md](footage.md)) - `import.unlabeledAlpha`: alpha of TGA / TIFF / movie footage (Ask opens Interpret Footage; Guess takes premultiplied for movies, straight for stills) - `import.dragImportAs`: layered files dropped on the window import as footage or a comp