347 lines
16 KiB
Rust
347 lines
16 KiB
Rust
//! 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 "<format> 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 "<format> 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<String>, 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<String>, 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<String> {
|
||
let Some(key) = run_key(path, alphabetical) else { return vec![] };
|
||
let mut files: Vec<String> = 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<Source> {
|
||
// 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<String> =
|
||
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<String>)> = vec![];
|
||
let mut out: Vec<Option<Source>> = vec![];
|
||
let mut slot: Vec<usize> = 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<Vec<String>> {
|
||
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<Footage, String> {
|
||
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<Footage, String> {
|
||
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<Footage, String> {
|
||
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<String> {
|
||
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<String> = 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<Vec<u8>> {
|
||
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<String> {
|
||
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<String> {
|
||
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");
|
||
}
|
||
}
|