//! 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}; use effectcraft_time::FrameRate; use crate::{Importer, Services}; /// How an import treats numbered stills. #[derive(Clone, Copy, Debug)] pub struct SequenceOptions { /// The " Sequence" checkbox: picked numbered stills of one run import as a sequence. pub sequence: bool, /// The numbered stills inside a picked or dropped folder import as sequences. pub folders: bool, /// Force Alphabetical Order. pub alphabetical: bool, } impl Default for SequenceOptions { fn default() -> Self { SequenceOptions { sequence: true, folders: 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); let sequence = flag("sequence").unwrap_or(true); SequenceOptions { sequence, folders: sequence, alphabetical: flag("alphabetical").unwrap_or(false) } } /// `file.import`'s options. Dropped files (`drag: true`) import as stills, as in After /// Effects, while a folder's numbered stills import as sequences unless `sequence: false` /// (Alt-drag of a folder). The Import dialog passes its " Sequence" checkbox (off /// unless ticked) as `sequence`. Without `sequence` or `drag` (scripts, agents) a numbered /// still brings its run, as it always has. pub fn for_import(p: &serde_json::Value) -> SequenceOptions { let flag = |k: &str| p.get(k).and_then(serde_json::Value::as_bool); let (sequence, alphabetical) = (flag("sequence"), flag("alphabetical").unwrap_or(false)); let dropped = flag("drag").unwrap_or(false); // Force Alphabetical Order is part of the Sequence option. SequenceOptions { sequence: sequence.unwrap_or(alphabetical || !dropped), folders: sequence.unwrap_or(true), alphabetical } } } /// 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, or [`SequenceOptions::folders`] for a folder's files) numbered /// stills of one run one [`Source::Sequence`]. pub fn group(services: &dyn Services, paths: &[String], opts: SequenceOptions) -> Vec { // Each file, and whether it may join a sequence. let mut files: Vec<(String, bool)> = 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.into_iter().map(|f| (f, opts.folders))); } else { files.push((p.clone(), opts.sequence)); } } // 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, joins) in files { match run_key(&f, opts.alphabetical).filter(|_| joins) { 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 picked file: its whole run from the folder. if let [one] = picked.as_slice() && opts.sequence { 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, folders: 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, as in After Effects (in Force /// Alphabetical Order the files play one after another). 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.alphabetical = *alphabetical; 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, folders: 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, folders: 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); } /// #431: `file.import` brings picked or dropped numbered stills in as stills; only a folder or /// an explicit `sequence: true` makes a sequence, as After Effects' Sequence checkbox does. #[test] fn import_takes_stills_unless_asked_for_a_sequence() { let t = Table(vec!["/s/a_0001.png", "/s/a_0002.png", "/s/a_0003.png", "/s/IMG_0001.jpg", "/s/IMG_0042.jpg"]); let opts = |p: serde_json::Value| SequenceOptions::for_import(&p); let none = opts(serde_json::json!({"drag": true})); assert_eq!(group(&t, &strs(&["/s/a_0002.png"]), none), [Source::File("/s/a_0002.png".into())]); assert_eq!(group(&t, &strs(&["/s/IMG_0001.jpg", "/s/IMG_0042.jpg"]), none).len(), 2); // A dropped folder: its runs as sequences (Alt-drag: `sequence: false`, files on their own). assert_eq!(group(&t, &strs(&["/s"]), none).len(), 2); assert_eq!(group(&t, &strs(&["/s"]), opts(serde_json::json!({"sequence": false}))).len(), 5); // The Sequence checkbox: one picked file brings its whole run. let on = group(&t, &strs(&["/s/a_0002.png"]), opts(serde_json::json!({"sequence": true}))); assert!(matches!(on.as_slice(), [Source::Sequence { files, .. }] if files.len() == 3), "{on:?}"); } #[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"); } }