effectcraft/crates/project/src/sequence.rs

193 lines
8.7 KiB
Rust

//! 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`]). Its frames run from
//! the first file's number to the last's; a gap in the numbering shows a placeholder (colour
//! bars), as in After Effects, except in a sequence imported with Force Alphabetical Order
//! ([`Footage::alphabetical`]), whose files play one after another.
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;
/// 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<i64> {
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<String> {
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<i64> = 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<i64> {
self.sequence.get(i).and_then(|p| frame_number(p))
}
/// The numbering is used: a numbered first file, not in Force Alphabetical Order.
fn numbered(&self) -> Option<i64> {
(!self.alphabetical).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))
}
/// How many frames an image sequence has: the span of its numbering, or its files (Force
/// Alphabetical Order, or unnumbered files). 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(first).saturating_add(1)
}
None => self.sequence.len() 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 {
return self.sequence.get(usize::try_from(i).ok()?).map(String::as_str);
};
let n = first.saturating_add(i);
let k = self.first_file_from(n);
self.sequence.get(k).filter(|p| frame_number(p) == Some(n)).map(String::as_str)
}
/// Set an image sequence's duration to its frames at its frame rate (after its files or
/// frame rate 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]) -> Footage {
Footage { kind: FootageKind::Sequence, sequence: numbers.iter().map(|n| format!("C:\\shots\\a_{n:04}.png")).collect(), ..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<String> = ["/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);
}
#[test]
fn missing_frames_map_frames_to_files() {
// Files 1, 2, 5: frames 3 and 4 are missing and show placeholders.
let f = seq(&[1, 2, 5]);
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::<Vec<_>>(), [Some(1), Some(2), None, None, Some(5)]);
// Force Alphabetical Order: the files play one after another.
let s = Footage { alphabetical: true, ..seq(&[1, 2, 5]) };
assert_eq!(s.sequence_frames(), 3);
assert_eq!((0..3).map(|i| at(&s, i)).collect::<Vec<_>>(), [Some(1), Some(2), Some(5)]);
assert_eq!(missing_frame_ranges(&f.sequence), [(3, 4)]);
}
#[test]
fn projects_saved_without_the_flag_show_placeholders() {
// Projects saved before #297 have no `alphabetical`: their gaps show placeholders, as in
// After Effects; only Force Alphabetical Order is saved.
let f = seq(&[1, 2, 5]);
let json = serde_json::to_value(&f).unwrap();
assert!(json.get("alphabetical").is_none(), "{json}");
let back: Footage = serde_json::from_value(json).unwrap();
assert_eq!((back.alphabetical, back.sequence_frames()), (false, 5));
let alpha = serde_json::to_value(Footage { alphabetical: true, ..seq(&[1, 2, 5]) }).unwrap();
assert_eq!(serde_json::from_value::<Footage>(alpha).unwrap().sequence_frames(), 3);
}
#[test]
fn start_timecode_only_relabels_source_time() {
// Override Start changes the timecode shown, not the frames (After Effects).
let at = |f: &Footage, i| f.sequence_file(i).map(|p| frame_number(p).unwrap_or(-1));
let mut f = Footage { frame_rate: effectcraft_time::FrameRate::from_f64(24.0), ..seq(&[1001, 1002, 1003]) };
assert_eq!(f.timecode(0), "0:00:00:00");
f.start_timecode = Some(1001);
assert_eq!((f.sequence_frames(), at(&f, 0), at(&f, 2)), (3, Some(1001), Some(1003)));
assert_eq!((f.timecode(0).as_str(), f.timecode(2).as_str()), ("0:00:41:17", "0:00:41:19"));
// Hostile values never panic.
f.start_timecode = Some(i64::MAX);
let _ = f.timecode(i64::MAX);
assert_eq!((f.sequence_file(i64::MAX), f.sequence_file(-1)), (None, None));
f.alphabetical = true;
assert_eq!((f.sequence_frames(), f.sequence_file(-1), f.sequence_file(3)), (3, None, None));
}
#[test]
fn duration_follows_frames_and_rate() {
let mut f = seq(&[1, 2, 5]);
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);
}
}