Merge main into enhance/avi-import

This commit is contained in:
tylerjenningsw
2026-10-25 14:26:28 -04:00
427 changed files with 24699 additions and 4513 deletions
+107 -30
View File
@@ -45,6 +45,9 @@ struct Key {
matte: [u32; 3],
/// A movie frame made at this size for a reduced-resolution render ((0, 0): as decoded).
size: (u32, u32),
/// A still at a reduced `size`: the footage's nominal size, which decides whether it is
/// resampled (see `Inner::still_at_size`); (0, 0) otherwise.
nominal: (u32, u32),
}
struct Entry {
@@ -559,7 +562,7 @@ impl Inner {
let loaded = self.read(path).map_err(|e| e.to_string()).and_then(|bytes| {
// Files the model names are read only when local or on the model's own network share.
let resolve = |uri: &str| -> Option<Vec<u8>> {
let p = craft_docpath::resolve(uri, Some(std::path::Path::new(path))).ok().filter(|p| p.is_absolute())?;
let p = craft_docpath::resolve_absolute(uri, Some(std::path::Path::new(path))).ok()?;
self.read(&p.to_string_lossy()).ok().map(|b| b.to_vec())
};
effectcraft_model::load(path, &bytes, &resolve).map_err(|e| e.to_string())
@@ -678,7 +681,7 @@ impl Inner {
FloatCurve::Gamma24 => 2,
} << 2);
let matte = if footage.alpha == AlphaMode::Premultiplied { footage.premul_color.map(f32::to_bits) } else { [0; 3] };
let key = |path: &str, frame| Key { path: path.into(), frame, interp, matte, size: (0, 0) };
let key = |path: &str, frame| Key { path: path.into(), frame, interp, matte, size: (0, 0), nominal: (0, 0) };
match footage.kind {
FootageKind::Sequence if !footage.sequence.is_empty() => {
let i = Self::frame_index(footage.frame_rate, t, footage.sequence_frames(), footage.loop_count);
@@ -711,16 +714,20 @@ impl Inner {
}
}
/// The frame of `footage` at `t`; a movie's resampled to `size` when it can be made at that
/// size directly (see [`FootageSource::frame_at_size`]).
/// The frame of `footage` at `t`, resampled to `size` (see [`FootageSource::frame_at_size`]):
/// a movie's made at that size directly, a still's resampled once and kept at that size.
fn frame_at(self: &Arc<Self>, footage: &Footage, t: Tick, playback: bool, size: Option<(u32, u32)>, float: FloatCurve) -> Result<Arc<Image>> {
if !footage.has_video {
return Err(MediaError::Unsupported(format!("{}: no video", footage.path)));
}
let mut loc = Self::locate(footage, t, float);
let movie = loc.media_t.is_some();
if movie && let Some(size) = size {
// A still asked for at its own size is the unsized frame (one decode, one entry).
if let Some(size) = size.filter(|s| movie || *s != (footage.width, footage.height)) {
loc.key.size = size;
if !movie {
loc.key.nominal = (footage.width, footage.height);
}
}
let mut c = lock(&self.cache);
// sequential playback of a movie: read the next frame ahead
@@ -776,34 +783,62 @@ impl Inner {
#[cfg(target_arch = "wasm32")]
fn prefetch(self: &Arc<Self>, _footage: &Footage, _t: Tick, _size: Option<(u32, u32)>, _float: FloatCurve) {}
/// A still (image file or sequence frame) at `loc.key.size` when one is set: resampled as the
/// renderer would ([`FootageSource::frame_at_size`]), so the pool keeps the reduced frame and a
/// reduced-resolution render neither decodes nor resamples a large still again for every frame
/// (#1812: 21120-px plates at Quarter). Like the renderer's `footage_buf`, an image decoded
/// larger than the footage's nominal size (a smart object's pixels) is resampled too, and one
/// decoded smaller is not (the renderer scales the layer instead).
fn still_at_size(img: Image, loc: &Loc, footage: &Footage) -> Image {
let (w, h) = loc.key.size;
let smaller = img.width == 0 || footage.width as f64 / img.width as f64 > 1.0 + 1e-9;
if w == 0 || h == 0 || smaller || (w, h) == (img.width, img.height) {
return img;
}
effectcraft_raster::resample(&img, w, h)
}
fn decode(&self, loc: &Loc, footage: &Footage) -> Result<Image> {
match loc.media_t {
None => self.decode_still(loc, footage).map(|img| Self::still_at_size(img, loc, footage)),
Some(_) => self.decode_media(loc, footage),
}
}
fn decode_still(&self, loc: &Loc, footage: &Footage) -> Result<Image> {
let op = AlphaOp::of(footage, loc.float);
let path = &loc.key.path;
if &**path == PLACEHOLDER {
return Ok(colour_bars(footage.width, footage.height));
}
let bytes = self.read(path)?;
if let Some(img) = crate::dpx::decode(path, &bytes)? {
return Ok(dynamic_to_image(&img, op));
}
if let Some(img) = crate::layered::decode(path, &bytes, footage, op)? {
return Ok(img);
}
// (an OpenEXR compression the decoder lacks came out transparent or as a missing
// channel error)
if path.to_ascii_lowercase().ends_with(".exr")
&& let Some(why) = crate::exr_channels::unsupported_compression(&bytes)
{
return Err(MediaError::Decode(format!("{path}: {why}")));
}
let loaded = crate::probe::load_oriented(&bytes, crate::still_format(path, &bytes));
let img = match loaded {
Ok(img) => img,
// A multi-layer OpenEXR file without an unnamed RGB layer: its colour layer.
Err(e) => crate::exr_channels::cached_layered_image(path, bytes).ok_or_else(|| MediaError::Decode(format!("{path}: {e}")))?,
};
Ok(dynamic_to_image(&img, op))
}
fn decode_media(&self, loc: &Loc, footage: &Footage) -> Result<Image> {
let op = AlphaOp::of(footage, loc.float);
let path = &loc.key.path;
match loc.media_t {
None if &**path == PLACEHOLDER => Ok(colour_bars(footage.width, footage.height)),
None => {
let bytes = self.read(path)?;
if let Some(img) = crate::dpx::decode(path, &bytes)? {
return Ok(dynamic_to_image(&img, op));
}
if let Some(img) = crate::layered::decode(path, &bytes, footage, op)? {
return Ok(img);
}
// (an OpenEXR compression the decoder lacks came out transparent or as a missing
// channel error)
if path.to_ascii_lowercase().ends_with(".exr")
&& let Some(why) = crate::exr_channels::unsupported_compression(&bytes)
{
return Err(MediaError::Decode(format!("{path}: {why}")));
}
let loaded = crate::probe::load_oriented(&bytes, crate::still_format(path, &bytes));
let img = match loaded {
Ok(img) => img,
// A multi-layer OpenEXR file without an unnamed RGB layer: its colour layer.
Err(e) => crate::exr_channels::cached_layered_image(path, bytes).ok_or_else(|| MediaError::Decode(format!("{path}: {e}")))?,
};
Ok(dynamic_to_image(&img, op))
}
None => self.decode_still(loc, footage),
Some(mt) if gif_anim::is_animated(footage) => {
let anim = self.gif(path)?;
let frame = anim.frame_at(Tick(mt.0)).ok_or_else(|| MediaError::Decode(format!("{path}: no frames")))?;
@@ -1033,6 +1068,48 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
// Regression test for #382 (#274): resampling footage audio divided by the requested mix
// rate, so a rate of 0 panicked (division by zero); the read must come back instead.
#[test]
fn decoding_audio_at_a_zero_mix_rate_does_not_divide_by_zero() {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/test-zero-rate");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
// One second of a 440 Hz tone, 44.1 kHz mono 16-bit PCM: not the 48 kHz mix rate.
let native = 44_100u32;
let pcm: Vec<u8> =
(0..native).flat_map(|n| (((n as f32 * 440.0 * std::f32::consts::TAU / native as f32).sin() * 8000.0) as i16).to_le_bytes()).collect();
let mut wav = b"RIFF".to_vec();
wav.extend((36 + pcm.len() as u32).to_le_bytes());
wav.extend(b"WAVEfmt ");
wav.extend(16u32.to_le_bytes());
wav.extend([1u16, 1].iter().flat_map(|v| v.to_le_bytes()));
wav.extend(native.to_le_bytes());
wav.extend((native * 2).to_le_bytes());
wav.extend([2u16, 16].iter().flat_map(|v| v.to_le_bytes()));
wav.extend(b"data");
wav.extend((pcm.len() as u32).to_le_bytes());
wav.extend(&pcm);
let path = dir.join("tone.wav");
std::fs::write(&path, &wav).unwrap();
let footage = Footage {
path: path.to_string_lossy().into(),
kind: FootageKind::Audio,
duration: Tick::from_seconds_f64(1.0),
has_audio: true,
..Default::default()
};
let pool = MediaPool::new();
// The file really is read, and resampled, at a normal rate...
let tone = pool.decode_audio(&footage, Tick::from_seconds_f64(0.1), 1024, 48_000);
assert!(tone.iter().any(|v| v.abs() > 0.05), "the tone is audible at 48 kHz");
// ...and a mix rate of 0 (which `audio_samples` itself turns away) returns a read of the
// requested length instead of dividing by it.
let degenerate = pool.try_decode_audio(&footage, Tick::from_seconds_f64(0.1), 64, 0).unwrap();
assert_eq!(degenerate.len(), 128);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn frame_index_loops_and_clamps() {
let r = FrameRate::FPS_30;
@@ -1048,7 +1125,7 @@ mod tests {
fn lru_respects_budget_and_recycles() {
let mut c = Lru::default();
let img = || Arc::new(Image::new(16, 16)); // 4 KiB + overhead
let k = |i| Key { path: "a".into(), frame: i, interp: 0, matte: [0; 3], size: (0, 0) };
let k = |i| Key { path: "a".into(), frame: i, interp: 0, matte: [0; 3], size: (0, 0), nominal: (0, 0) };
let budget = 3 * (4096 + 64);
for i in 0..10 {
c.insert(k(i), img(), budget);
+25 -2
View File
@@ -27,12 +27,21 @@ pub fn probe(path: impl AsRef<Path>) -> Result<Footage> {
}
let bytes = std::fs::read(path).map_err(|e| MediaError::Io(format!("{}: {e}", path.display())))?;
if crate::MODEL_EXTENSIONS.contains(&ext_of(path).as_str()) {
let dir = path.parent().map(Path::to_path_buf).unwrap_or_default();
return probe_model(&path.to_string_lossy(), &bytes, &|uri| std::fs::read(dir.join(uri)).ok());
return probe_model(&path.to_string_lossy(), &bytes, &side_file_reader(path));
}
probe_bytes(&path.to_string_lossy(), bytes.into())
}
/// Reads the files a model file names (buffers, textures, MTL files). Like the pool, it follows only
/// local files and files on the model's own network share: a URI is a path stored in the model.
fn side_file_reader(model: &Path) -> impl Fn(&str) -> Option<Vec<u8>> + use<> {
let model = std::path::absolute(model).unwrap_or_else(|_| model.to_path_buf());
move |uri| {
let p = craft_docpath::resolve_absolute(uri, Some(&model)).ok()?;
std::fs::read(p).ok()
}
}
/// Probe a 3D model (glTF 2.0 / OBJ / STL / FBX): a [`FootageKind::Model`] item whose duration is its
/// longest animation. `resolve` reads sibling resources.
pub fn probe_model(path: &str, bytes: &[u8], resolve: &dyn Fn(&str) -> Option<Vec<u8>>) -> Result<Footage> {
@@ -323,6 +332,20 @@ mod tests {
(0..n).map(|k| (at(k + 1) - at(k)) as u32).collect()
}
/// A model's side files are read from beside it; a URI to another computer is not followed.
#[test]
fn model_side_files_are_not_read_from_other_computers() {
let dir = std::env::temp_dir().join(format!("ec-probe-side-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("buf.bin"), b"abc").unwrap();
let read = side_file_reader(&dir.join("model.gltf"));
assert_eq!(read("buf.bin"), Some(b"abc".to_vec()));
assert_eq!(read(r"\\192.0.2.1\share\buf.bin"), None);
assert_eq!(read("file://192.0.2.1/share/buf.bin"), None);
assert_eq!(read(r"C:\buf.bin"), None);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn millisecond_rounded_frame_times_average_to_their_rate() {
assert_eq!(rounded(FrameRate::FPS_30, 1000, 6), [33, 34, 33, 33, 34, 33]);
@@ -40,7 +40,11 @@ fn ffmpeg_fixture(name: &str, args: &[&str]) -> Option<PathBuf> {
let tmp = out.with_file_name(format!("tmp-{}-{:?}.{ext}", std::process::id(), std::thread::current().id()).replace(['(', ')'], ""));
let st = Command::new("ffmpeg").args(["-hide_banner", "-loglevel", "error", "-y"]).args(args).arg(&tmp).status().expect("run ffmpeg");
assert!(st.success(), "ffmpeg failed for {name}");
std::fs::rename(&tmp, &out).expect("rename");
if std::fs::rename(&tmp, &out).is_err() {
// Another test made it first; on Windows the rename fails while that test has it open.
let _ = std::fs::remove_file(&tmp);
assert!(out.exists(), "rename {} to {name} failed", tmp.display());
}
Some(out)
}
@@ -703,6 +707,46 @@ fn sequence_gaps_show_placeholders() {
let _ = std::fs::remove_dir_all(&dir);
}
/// #1812: a still asked for at a reduced size comes back as the renderer would resample it, and
/// is kept at that size: later frames at that size neither decode nor resample it again (a
/// parallax project's 21120-px plates were decoded and resampled for every frame at Quarter).
#[test]
fn stills_at_reduced_sizes_are_resampled_once() {
let dir = std::env::temp_dir().join(format!("ec-still-size-{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("temp dir");
let p = dir.join("plate.png");
let px = |x: u32, y: u32| {
image::Rgba([(x * 7 % 256) as u8, (y * 13 % 256) as u8, ((x ^ y) % 256) as u8, if (x + y).is_multiple_of(5) { 0 } else { 255 - (x % 128) as u8 }])
};
image::RgbaImage::from_fn(613, 97, px).save(&p).expect("png");
let f = probe(&p).expect("probe");
let pool = MediaPool::new();
let full = pool.frame(ItemId(1), &f, Tick::ZERO).expect("frame");
// At its own size it is the unsized frame: no second decode, no second full-size copy.
let misses = pool.stats().misses;
let native = pool.frame_at_size(ItemId(1), &f, Tick::ZERO, f.width, f.height).expect("frame");
assert!(Arc::ptr_eq(&full, &native), "the native size reuses the unsized frame");
assert_eq!(pool.stats().misses, misses);
for (w, h) in [(306, 48), (153, 24), (77, 12)] {
let sized = pool.frame_at_size(ItemId(1), &f, Tick::ZERO, w, h).expect("frame");
assert_eq!(*sized, effectcraft_raster::resample(&full, w, h), "{w}×{h}: the renderer's resample");
let misses = pool.stats().misses;
let again = pool.frame_at_size(ItemId(1), &f, Tick(3 * TICKS_PER_SECOND), w, h).expect("frame");
assert!(Arc::ptr_eq(&sized, &again), "{w}×{h}: kept at that size");
assert_eq!(pool.stats().misses, misses, "{w}×{h}: not decoded again");
}
// Pixels larger than the nominal size (a smart object's) are resampled as the renderer does;
// smaller ones are left as they are (the renderer scales the layer up instead).
// One pool, one file, one requested size: the two decisions don't share a cache entry.
let larger = Footage { width: 306, height: 48, ..f.clone() };
let smaller = Footage { width: 1226, height: 194, ..f.clone() };
let sized = pool.frame_at_size(ItemId(2), &larger, Tick::ZERO, 153, 24).expect("frame");
assert_eq!(*sized, effectcraft_raster::resample(&full, 153, 24));
let kept = pool.frame_at_size(ItemId(3), &smaller, Tick::ZERO, 153, 24).expect("frame");
assert_eq!(*kept, *full);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn png_with_alpha() {
let Some(p) = alpha_png() else { return };
@@ -956,7 +1000,7 @@ fn flac_audio_of_any_shape_reads_in_device_blocks() {
}
// ffmpeg's sine source has an amplitude of 1/8 (mono is duplicated). The fixture carries
// channels 0 and 1 at full level and the others at half, so a multichannel file sounds as
// its stereo downmix (tests/downmix.rs checks that matrix channel by channel).
// its stereo downmix (tests/it/downmix.rs checks that matrix channel by channel).
let gains = effectcraft_media::downmix::stereo_gains(channels as usize, false);
let side = |s: usize| gains.iter().enumerate().map(|(c, g)| f64::from(g[s]) * if c < 2 { 1.0 } else { 0.5 }).sum::<f64>();
let want = 0.125 * ((side(0).powi(2) + side(1).powi(2)) / 2.0).sqrt() / 2f64.sqrt();
+7
View File
@@ -0,0 +1,7 @@
//! The crate's integration tests, one binary (each module is a former `tests/<name>.rs`).
mod downmix;
mod exif_orientation;
mod footage;
mod open_in_place;
mod tga;