Files
effectcraft/crates/engine/src/sequence.rs
T
Claude 40e87709d5 Import: numbered stills import as stills unless the Sequence option is on; dropped folders make sequences (#431)
Cause: SequenceOptions::from_params defaulted `sequence` to true for
file.import, so any numbered still (IMG_0001.jpg) brought its whole numbered
run from its folder, and several picked or dropped stills with a common
prefix became one sequence. Dropped files never asked, and the Import
dialog's "<format> Sequence" checkbox started ticked.

Fix, matching After Effects: file.import takes picked and dropped files as
stills unless `sequence: true` (the Import dialog's Sequence checkbox, which
now starts unticked; Force Alphabetical Order implies it), while the numbered
stills inside a picked or dropped folder still import as sequences
(SequenceOptions::folders), and `sequence: false` (Alt-drag of a folder)
imports a folder's files one by one. Replace Footage, reload, proxies and
render outputs keep detecting their sequence (SequenceOptions::default and
from_params are unchanged), and mediaBrowser.import keeps bringing a numbered
still's run unless `sequence: false`. docs/footage.md and docs/agents.md
describe the new defaults.

Tests: sequence import_takes_stills_unless_asked_for_a_sequence and engine
numbered_stills_import_as_stills_by_default (one dropped still and two picked
IMG_ stills stay stills, a dropped folder makes one sequence, the checkbox
brings the run); the existing sequence tests pass `sequence: true`; ui
import_dialog_offers_the_image_sequence_option (unticked by default: a still;
ticked: the run) and a_dropped_folder_imports_as_one_sequence_with_sequence_interpretation
(two dropped frames: two stills; the folder: one sequence).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
(cherry picked from commit aa3c31db9ab3a774ee2f8ba867e1bafc1fce7aba)
2026-10-10 19:48:25 +00:00

345 lines
16 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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, as in After Effects: picked or dropped files import as stills
/// unless `sequence: true` (the Import dialog's "<format> Sequence" checkbox, which is off
/// unless ticked), while a folder's numbered stills import as sequences unless
/// `sequence: false` (Alt-drag of a folder).
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));
// Force Alphabetical Order is part of the Sequence option.
SequenceOptions { sequence: sequence.unwrap_or(alphabetical), 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");
}
}