344 lines
15 KiB
Rust
344 lines
15 KiB
Rust
//! Image sequences: numbered stills (`shot_0001.png`, `shot_0002.png`, …) as one movie clip.
|
|
//!
|
|
//! - [`Numbered`] splits a file name around its last run of digits; [`sequence_frames`] lists the
|
|
//! frames from the chosen file's number up to the highest number present (gaps are `None`).
|
|
//! - [`ImageSequenceSource`] decodes frames on demand through a [`FrameLoader`] (the host's file
|
|
//! access) with the still decoders (PNG, JPEG, TIFF, BMP, WebP, GIF, Targa, DPX; OpenEXR and DNG
|
|
//! camera raw as linear float frames), keeps recently used frames in a byte-budgeted
|
|
//! [`FrameCache`], and shows the nearest earlier frame for a missing or undecodable one (the
|
|
//! nearest later frame before the first).
|
|
//! - The frame rate is the caller's (Settings ▸ Media ▸ Indeterminate Media Timebase), except for
|
|
//! CinemaDNG: a frame rate and start time code in the first frame's tags are the sequence's.
|
|
|
|
use std::sync::Arc;
|
|
|
|
use filmcraft_frame::{AudioBuffer, VideoFrame};
|
|
use filmcraft_time::FrameRate;
|
|
|
|
use crate::{FrameCache, FrameRequest, MediaError, MediaInfo, MediaKind, MediaSource, Result, VideoStreamInfo};
|
|
|
|
/// Reads a file's bytes (the host's file access).
|
|
pub type FrameLoader = Arc<dyn Fn(&str) -> std::io::Result<Vec<u8>> + Send + Sync>;
|
|
|
|
/// A file name with a frame number: `<dir><prefix><digits><suffix>`.
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub struct Numbered {
|
|
/// Directory part including the trailing separator ("" when none).
|
|
pub dir: String,
|
|
pub prefix: String,
|
|
pub number: u64,
|
|
/// Width of the digit run as written (zero padding).
|
|
pub digits: usize,
|
|
/// Everything after the digits, including the extension.
|
|
pub suffix: String,
|
|
}
|
|
|
|
impl Numbered {
|
|
/// Split `path` at the last run of digits in its file stem. `None` when the stem has no digits
|
|
/// or the file is not a still image.
|
|
pub fn parse(path: &str) -> Option<Numbered> {
|
|
let cut = path.rfind(['/', '\\']).map_or(0, |i| i + 1);
|
|
let (dir, name) = path.split_at(cut);
|
|
let ext_at = name.rfind('.').filter(|&i| i > 0)?;
|
|
let ext = name[ext_at + 1..].to_ascii_lowercase();
|
|
if !crate::STILL_EXTENSIONS.contains(&ext.as_str()) {
|
|
return None;
|
|
}
|
|
let stem = &name[..ext_at];
|
|
let end = stem.rfind(|c: char| c.is_ascii_digit())? + 1;
|
|
let start = stem[..end].rfind(|c: char| !c.is_ascii_digit()).map_or(0, |i| i + 1);
|
|
let digits = &stem[start..end];
|
|
if digits.len() > 18 {
|
|
return None;
|
|
}
|
|
Some(Numbered {
|
|
dir: dir.to_string(),
|
|
prefix: stem[..start].to_string(),
|
|
number: digits.parse().ok()?,
|
|
digits: digits.len(),
|
|
suffix: name[end..].to_string(),
|
|
})
|
|
}
|
|
|
|
/// Whether the numbers are zero-padded to a fixed width.
|
|
pub fn padded(&self) -> bool {
|
|
self.digits > 1 && self.number.to_string().len() < self.digits
|
|
}
|
|
|
|
/// The file name (no directory) of frame number `n`.
|
|
pub fn file_name(&self, n: u64) -> String {
|
|
format!("{}{:0w$}{}", self.prefix, n, self.suffix, w = self.digits)
|
|
}
|
|
|
|
/// The path of frame number `n`.
|
|
pub fn path(&self, n: u64) -> String {
|
|
format!("{}{}", self.dir, self.file_name(n))
|
|
}
|
|
|
|
/// The frame number of a sibling file name of this sequence (same prefix, suffix and padding).
|
|
pub fn number_of(&self, file_name: &str) -> Option<u64> {
|
|
let rest = file_name.strip_prefix(self.prefix.as_str())?;
|
|
let mid = {
|
|
let n = rest.as_bytes();
|
|
let s = self.suffix.as_bytes();
|
|
if n.len() < s.len() || !n[n.len() - s.len()..].eq_ignore_ascii_case(s) {
|
|
return None;
|
|
}
|
|
&rest[..n.len() - s.len()]
|
|
};
|
|
if mid.is_empty() || !mid.bytes().all(|b| b.is_ascii_digit()) || mid.len() > 18 {
|
|
return None;
|
|
}
|
|
// every number has the first one's width, or is wider without leading zeros (overflowing
|
|
// the padding, or an unpadded sequence counting past 9, 99…)
|
|
if mid.len() != self.digits && (mid.len() < self.digits || mid.starts_with('0')) {
|
|
return None;
|
|
}
|
|
mid.parse().ok()
|
|
}
|
|
}
|
|
|
|
/// The frames of the sequence `first` belongs to, from `first.number` to the highest number among
|
|
/// `siblings` (file names in its directory): each frame's path, or `None` for a missing number.
|
|
pub fn sequence_frames(first: &Numbered, siblings: &[String]) -> Vec<Option<String>> {
|
|
let mut nums: Vec<(u64, &String)> = siblings.iter().filter_map(|n| first.number_of(n).map(|k| (k, n))).filter(|(k, _)| *k >= first.number).collect();
|
|
nums.sort();
|
|
nums.dedup_by_key(|x| x.0);
|
|
let last = nums.last().map_or(first.number, |x| x.0);
|
|
// a sequence longer than this is a numbering accident (e.g. a date in the name)
|
|
let span = (last - first.number).min(1_000_000) as usize + 1;
|
|
let mut frames = vec![None; span];
|
|
for (k, name) in nums {
|
|
let i = (k - first.number) as usize;
|
|
if i < span {
|
|
frames[i] = Some(format!("{}{}", first.dir, name));
|
|
}
|
|
}
|
|
if frames[0].is_none() {
|
|
frames[0] = Some(first.path(first.number));
|
|
}
|
|
frames
|
|
}
|
|
|
|
/// Numbered stills played as a movie.
|
|
pub struct ImageSequenceSource {
|
|
info: MediaInfo,
|
|
frames: Vec<Option<String>>,
|
|
loader: FrameLoader,
|
|
cache: FrameCache<usize>,
|
|
}
|
|
|
|
impl ImageSequenceSource {
|
|
/// `frames`: path per frame (`None`: missing). The first present frame is decoded now (size,
|
|
/// alpha); the rest on demand.
|
|
pub fn new(name: &str, frames: Vec<Option<String>>, rate: FrameRate, loader: FrameLoader) -> Result<Self> {
|
|
let first = frames.iter().flatten().next().ok_or_else(|| MediaError::Offline(format!("{name}: no frames")))?.clone();
|
|
let bytes = loader(&first).map_err(|e| {
|
|
if e.kind() == std::io::ErrorKind::NotFound { MediaError::Offline(format!("{first}: {e}")) } else { MediaError::Io(format!("{first}: {e}")) }
|
|
})?;
|
|
let (frame, has_alpha, pixel_format, timing) = decode_frame(&first, &bytes)?;
|
|
let (w, h) = (frame.width, frame.height);
|
|
let (rate, start_timecode) = match timing {
|
|
Some(info) => crate::still::dng::sequence_timing(&info, rate),
|
|
None => (rate, None),
|
|
};
|
|
let ext = first.rsplit('.').next().unwrap_or("image").to_ascii_uppercase();
|
|
let n = frames.len() as i64;
|
|
let info = MediaInfo {
|
|
name: name.to_string(),
|
|
kind: MediaKind::ImageSequence,
|
|
duration: rate.tick_of(n),
|
|
video: Some(VideoStreamInfo {
|
|
width: w,
|
|
height: h,
|
|
frame_rate: rate,
|
|
par: (1, 1),
|
|
codec: format!("{ext} sequence"),
|
|
pixel_format,
|
|
color: frame.color,
|
|
has_alpha,
|
|
bitrate: None,
|
|
hdr: None,
|
|
}),
|
|
audio_streams: Vec::new(),
|
|
container: "Image Sequence".into(),
|
|
start_timecode,
|
|
file_size: None,
|
|
};
|
|
let cache = FrameCache::new(256 << 20);
|
|
cache.insert(frames.iter().position(Option::is_some).unwrap_or(0), Arc::new(frame));
|
|
Ok(Self { info, frames, loader, cache })
|
|
}
|
|
|
|
pub fn frame_count(&self) -> usize {
|
|
self.frames.len()
|
|
}
|
|
|
|
/// Indices of missing frames.
|
|
pub fn missing_frames(&self) -> Vec<usize> {
|
|
self.frames.iter().enumerate().filter(|(_, f)| f.is_none()).map(|(i, _)| i).collect()
|
|
}
|
|
|
|
/// Path of frame `i` (`None` when missing).
|
|
pub fn frame_path(&self, i: usize) -> Option<&str> {
|
|
self.frames.get(i).and_then(|f| f.as_deref())
|
|
}
|
|
|
|
fn decode(&self, i: usize) -> Result<Arc<VideoFrame>> {
|
|
if let Some(f) = self.cache.get(&i) {
|
|
return Ok(f);
|
|
}
|
|
let path = self.frames.get(i).and_then(|f| f.as_deref()).ok_or(MediaError::NoStream("video"))?;
|
|
let bytes = (self.loader)(path).map_err(|e| MediaError::Io(format!("{path}: {e}")))?;
|
|
let f = Arc::new(decode_frame(path, &bytes)?.0);
|
|
self.cache.insert(i, f.clone());
|
|
Ok(f)
|
|
}
|
|
}
|
|
|
|
impl MediaSource for ImageSequenceSource {
|
|
fn info(&self) -> &MediaInfo {
|
|
&self.info
|
|
}
|
|
|
|
fn video_frame(&self, req: FrameRequest) -> Result<Arc<VideoFrame>> {
|
|
let n = self.frames.len();
|
|
let want = self.info.frame_rate().frame_at(req.time.max(filmcraft_time::Tick::ZERO)).clamp(0, n as i64 - 1) as usize;
|
|
// the wanted frame, else the nearest earlier present frame, else the nearest later one
|
|
let candidates = (0..=want).rev().chain(want + 1..n);
|
|
let mut last_err = None;
|
|
for i in candidates.filter(|&i| self.frames[i].is_some()).take(8) {
|
|
match self.decode(i) {
|
|
Ok(f) => return Ok(f),
|
|
Err(e) => last_err = Some(e),
|
|
}
|
|
}
|
|
Err(last_err.unwrap_or(MediaError::NoStream("video")))
|
|
}
|
|
|
|
fn audio(&self, _start: i64, _frames: usize, _sample_rate: u32) -> Result<AudioBuffer> {
|
|
Err(MediaError::NoStream("audio"))
|
|
}
|
|
}
|
|
|
|
/// Decode one frame file: OpenEXR, Radiance HDR and DNG camera raw as linear floats (DNG with its metadata, for
|
|
/// the sequence's timing), every other format through the still decoders as RGBA8. Returns the
|
|
/// frame, whether it has alpha, and its pixel format for the media info.
|
|
fn decode_frame(name: &str, bytes: &[u8]) -> Result<(VideoFrame, bool, String, Option<filmcraft_dng::Info>)> {
|
|
if crate::still::exr::sniff(bytes) {
|
|
let d = crate::still::exr::decode(name, bytes)?;
|
|
return Ok((d.frame, d.has_alpha, d.pixel_format, None));
|
|
}
|
|
if crate::still::hdr::sniff(bytes) {
|
|
let frame = crate::still::hdr::decode(name, bytes)?;
|
|
return Ok((frame, false, "RGB 32-bit float (RGBE)".to_string(), None));
|
|
}
|
|
if crate::still::dng::sniff(bytes) {
|
|
let d = crate::still::dng::decode(name, bytes)?;
|
|
return Ok((d.frame, false, d.pixel_format, Some(d.info)));
|
|
}
|
|
let img = crate::still::decode_raster(name, bytes)?;
|
|
let pixel_format = if img.has_alpha { "RGBA 8-bit" } else { "RGB 8-bit" }.to_string();
|
|
Ok((VideoFrame::rgba8(img.width, img.height, img.rgba), img.has_alpha, pixel_format, None))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::collections::HashMap;
|
|
use std::sync::Mutex;
|
|
|
|
#[test]
|
|
fn numbered_names() {
|
|
let n = Numbered::parse("/shots/A001_C002_0099.png").unwrap();
|
|
assert_eq!((n.dir.as_str(), n.prefix.as_str(), n.number, n.digits, n.suffix.as_str()), ("/shots/", "A001_C002_", 99, 4, ".png"));
|
|
assert!(n.padded());
|
|
assert_eq!(n.path(100), "/shots/A001_C002_0100.png");
|
|
assert_eq!(n.number_of("A001_C002_0100.png"), Some(100));
|
|
assert_eq!(n.number_of("A001_C002_100.png"), None, "padding differs");
|
|
assert_eq!(n.number_of("A001_C002_12345.png"), Some(12345), "overflowing the padding");
|
|
assert_eq!(n.number_of("A001_C002_0100.jpg"), None);
|
|
assert_eq!(n.number_of("B001_C002_0100.png"), None);
|
|
let u = Numbered::parse("frame7.TIF").unwrap();
|
|
assert_eq!((u.prefix.as_str(), u.number, u.suffix.as_str()), ("frame", 7, ".TIF"));
|
|
assert!(!u.padded());
|
|
assert_eq!(u.file_name(10), "frame10.TIF");
|
|
assert_eq!(u.number_of("frame10.TIF"), Some(10));
|
|
assert_eq!(u.number_of("frame10.tif"), Some(10), "Linux stills often mix .TIF and .tif");
|
|
assert_eq!(Numbered::parse("clip.mov"), None, "not a still");
|
|
assert_eq!(Numbered::parse("nodigits.png"), None);
|
|
let w = Numbered::parse("C:\\renders\\out.0010.exr.png").unwrap();
|
|
assert_eq!((w.dir.as_str(), w.prefix.as_str(), w.number), ("C:\\renders\\", "out.", 10));
|
|
}
|
|
|
|
#[test]
|
|
fn frames_with_gaps() {
|
|
let n = Numbered::parse("/d/s_003.png").unwrap();
|
|
let names: Vec<String> = ["s_001.png", "s_003.png", "s_004.png", "s_007.png", "other.png", "s_005.jpg"].iter().map(|s| s.to_string()).collect();
|
|
let f = sequence_frames(&n, &names);
|
|
assert_eq!(f.len(), 5);
|
|
assert_eq!(f[0].as_deref(), Some("/d/s_003.png"));
|
|
assert_eq!(f[1].as_deref(), Some("/d/s_004.png"));
|
|
assert_eq!((f[2].as_deref(), f[3].as_deref()), (None, None));
|
|
assert_eq!(f[4].as_deref(), Some("/d/s_007.png"));
|
|
}
|
|
|
|
fn png(w: u32, h: u32, v: u8) -> Vec<u8> {
|
|
let img = image::RgbaImage::from_pixel(w, h, image::Rgba([v, 255 - v, 7, 255]));
|
|
let mut out = std::io::Cursor::new(Vec::new());
|
|
img.write_to(&mut out, image::ImageFormat::Png).unwrap();
|
|
out.into_inner()
|
|
}
|
|
|
|
#[test]
|
|
fn webp_frames_lossless() {
|
|
let enc = |v: u8| {
|
|
let img = image::RgbaImage::from_fn(6, 4, |x, y| image::Rgba([v, (x * 40) as u8, (y * 60) as u8, 255]));
|
|
let mut out = Vec::new();
|
|
image::codecs::webp::WebPEncoder::new_lossless(&mut out).encode(img.as_raw(), 6, 4, image::ExtendedColorType::Rgba8).unwrap();
|
|
(img.into_raw(), out)
|
|
};
|
|
let (a, wa) = enc(1);
|
|
let (b, wb) = enc(2);
|
|
let files: HashMap<String, Vec<u8>> = [("w1.webp".to_string(), wa), ("w2.webp".to_string(), wb)].into_iter().collect();
|
|
let loader: FrameLoader = Arc::new(move |p: &str| files.get(p).cloned().ok_or_else(|| std::io::Error::other("missing")));
|
|
let s = ImageSequenceSource::new("w1.webp", vec![Some("w1.webp".into()), Some("w2.webp".into())], FrameRate::FPS_24, loader).unwrap();
|
|
for (i, want) in [a, b].iter().enumerate() {
|
|
match &s.video_frame(FrameRequest::full(FrameRate::FPS_24.tick_of(i as i64))).unwrap().data {
|
|
filmcraft_frame::PixelData::Rgba8(p) => assert_eq!(&p[..], &want[..]),
|
|
_ => panic!(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn plays_frames_holds_missing_and_caches() {
|
|
let files: HashMap<String, Vec<u8>> = [(0, 10u8), (1, 20), (3, 40)].iter().map(|(i, v)| (format!("f{i}.png"), png(8, 4, *v))).collect();
|
|
let reads = Arc::new(Mutex::new(0usize));
|
|
let r = reads.clone();
|
|
let loader: FrameLoader = Arc::new(move |p: &str| {
|
|
*r.lock().unwrap() += 1;
|
|
files.get(p).cloned().ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, p.to_string()))
|
|
});
|
|
let frames = vec![Some("f0.png".to_string()), Some("f1.png".into()), None, Some("f3.png".into())];
|
|
let s = ImageSequenceSource::new("f0.png", frames, FrameRate::FPS_25, loader).unwrap();
|
|
assert_eq!(s.info().kind, MediaKind::ImageSequence);
|
|
assert_eq!(s.info().duration, FrameRate::FPS_25.tick_of(4));
|
|
assert_eq!(s.missing_frames(), vec![2]);
|
|
let px = |i: i64| match &s.video_frame(FrameRequest::full(FrameRate::FPS_25.tick_of(i))).unwrap().data {
|
|
filmcraft_frame::PixelData::Rgba8(p) => p[0],
|
|
_ => panic!(),
|
|
};
|
|
assert_eq!(px(0), 10);
|
|
assert_eq!(px(1), 20);
|
|
assert_eq!(px(2), 20, "missing frame holds the previous one");
|
|
assert_eq!(px(3), 40);
|
|
assert_eq!(px(99), 40, "past the end: the last frame");
|
|
let n = *reads.lock().unwrap();
|
|
for i in 0..4 {
|
|
px(i);
|
|
}
|
|
assert_eq!(*reads.lock().unwrap(), n, "frames come from the cache");
|
|
}
|
|
}
|