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
+236
View File
@@ -0,0 +1,236 @@
//! Multichannel audio downmix (#903) against ffmpeg as an external oracle. Fixtures: ffmpeg with
//! one sine per channel, written to `target/fixtures/media`; tests skip when ffmpeg is missing.
use std::f64::consts::PI;
use std::path::{Path, PathBuf};
use std::process::Command;
use effectcraft_media::{MediaPool, probe};
use effectcraft_time::Tick;
const RATE: u32 = 48_000;
fn fixtures() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/fixtures/media")
}
/// A fixture with `layout`'s channels `names` (join order) each carrying its own sine, encoded
/// with `codec_args`. `None` when ffmpeg is missing or cannot make it.
fn fixture(name: &str, layout: &str, names: &[&str], freqs: &[u32], codec_args: &[&str]) -> Option<PathBuf> {
if !Command::new("ffmpeg").arg("-version").output().is_ok_and(|o| o.status.success()) {
eprintln!("ffmpeg not found: skipping");
return None;
}
let out = fixtures().join(name);
if out.exists() {
return Some(out);
}
std::fs::create_dir_all(fixtures()).ok()?;
let ext = out.extension()?.to_string_lossy().to_string();
let tmp = out.with_file_name(format!("tmp-{}-{name}", std::process::id())).with_extension(ext);
let mut cmd = Command::new("ffmpeg");
cmd.args(["-hide_banner", "-loglevel", "error", "-y"]);
let mut map = String::new();
for (i, (n, f)) in names.iter().zip(freqs).enumerate() {
cmd.args(["-f", "lavfi", "-i", &format!("sine=frequency={f}:sample_rate={RATE}:duration=2")]);
map.push_str(&format!("[{i}]"));
let _ = n;
}
let post = if name.contains("loud") { ",volume=8:precision=float,alimiter=limit=1:level=disabled" } else { "" };
let joins: Vec<String> = names.iter().enumerate().map(|(i, n)| format!("{i}.0-{n}")).collect();
cmd.args(["-filter_complex", &format!("{map}join=inputs={}:channel_layout={layout}:map={}{}[a]", names.len(), joins.join("|"), post), "-map", "[a]"]);
cmd.args(codec_args).arg(&tmp);
let ok = cmd.status().ok()?.success();
if !ok {
eprintln!("ffmpeg could not make {name}");
return None;
}
std::fs::rename(&tmp, &out).ok()?;
Some(out)
}
/// Amplitude (peak) of the sine at `freq` in one channel of interleaved stereo.
fn amp(s: &[f32], ch: usize, freq: u32) -> f64 {
let (mut re, mut im, mut n) = (0.0, 0.0, 0usize);
for (i, f) in s.as_chunks::<2>().0.iter().enumerate() {
let w = 2.0 * PI * f64::from(freq) * i as f64 / f64::from(RATE);
re += f64::from(f[ch]) * w.cos();
im += f64::from(f[ch]) * w.sin();
n += 1;
}
2.0 * (re * re + im * im).sqrt() / n.max(1) as f64
}
const K: f64 = std::f64::consts::FRAC_1_SQRT_2;
struct Case {
file: &'static str,
layout: &'static str,
names: &'static [&'static str],
codec: &'static [&'static str],
/// Expected `[left, right]` gain of each channel (the same order as `names`).
gains: Vec<[f64; 2]>,
/// Allowed deviation of a channel's amplitude (lossy codecs).
tol: f64,
}
const FREQS: [u32; 8] = [300, 500, 700, 900, 1100, 1300, 1500, 1700];
fn five_one() -> (&'static [&'static str], Vec<[f64; 2]>) {
let k = 1.0 / (1.0 + 2.0 * K);
(&["FL", "FR", "FC", "LFE", "BL", "BR"], vec![[k, 0.0], [0.0, k], [k * K, k * K], [0.0, 0.0], [k * K, 0.0], [0.0, k * K]])
}
fn seven_one() -> (&'static [&'static str], Vec<[f64; 2]>) {
let k = 1.0 / (1.0 + 3.0 * K);
(
&["FL", "FR", "FC", "LFE", "BL", "BR", "SL", "SR"],
vec![[k, 0.0], [0.0, k], [k * K, k * K], [0.0, 0.0], [k * K, 0.0], [0.0, k * K], [k * K, 0.0], [0.0, k * K]],
)
}
fn cases() -> Vec<Case> {
let (n51, g51) = five_one();
let (n71, g71) = seven_one();
let k5 = 1.0 / (1.0 + 2.0 * K);
let kq = 1.0 / (1.0 + K);
let k61 = 1.0 / (1.5 + 2.0 * K);
vec![
Case { file: "dm51.m4a", layout: "5.1", names: n51, codec: &["-c:a", "aac", "-b:a", "384k"], gains: g51.clone(), tol: 0.15 },
Case {
file: "dm61.m4a",
layout: "6.1",
names: &["FL", "FR", "FC", "LFE", "BC", "SL", "SR"],
codec: &["-c:a", "aac", "-b:a", "448k"],
gains: vec![[k61, 0.0], [0.0, k61], [k61 * K, k61 * K], [0.0, 0.0], [k61 / 2.0, k61 / 2.0], [k61 * K, 0.0], [0.0, k61 * K]],
tol: 0.15,
},
Case { file: "dm71.m4a", layout: "7.1", names: n71, codec: &["-c:a", "aac", "-b:a", "512k"], gains: g71.clone(), tol: 0.15 },
Case { file: "dm51.ac3.mp4", layout: "5.1", names: n51, codec: &["-c:a", "ac3", "-b:a", "640k"], gains: g51.clone(), tol: 0.15 },
Case { file: "dm51.wav", layout: "5.1", names: n51, codec: &["-c:a", "pcm_s16le"], gains: g51.clone(), tol: 0.02 },
Case { file: "dm51.flac", layout: "5.1", names: n51, codec: &["-c:a", "flac"], gains: g51, tol: 0.02 },
Case { file: "dm71.wav", layout: "7.1", names: n71, codec: &["-c:a", "pcm_s16le"], gains: g71.clone(), tol: 0.02 },
Case { file: "dm71.flac", layout: "7.1", names: n71, codec: &["-c:a", "flac"], gains: g71, tol: 0.02 },
Case {
file: "dm50.wav",
layout: "5.0",
names: &["FL", "FR", "FC", "BL", "BR"],
codec: &["-c:a", "pcm_s16le"],
gains: vec![[k5, 0.0], [0.0, k5], [k5 * K, k5 * K], [k5 * K, 0.0], [0.0, k5 * K]],
tol: 0.02,
},
Case {
file: "dmquad.wav",
layout: "quad",
names: &["FL", "FR", "BL", "BR"],
codec: &["-c:a", "pcm_s16le"],
gains: vec![[kq, 0.0], [0.0, kq], [kq * K, 0.0], [0.0, kq * K]],
tol: 0.02,
},
]
}
/// Our stereo mix of `p` over one second, from 0.2 s in.
fn ours(p: &Path) -> Vec<f32> {
let f = probe(p).expect("probe");
MediaPool::new().audio_samples(&f, Tick::from_seconds_f64(0.2), RATE as usize, RATE)
}
/// ffmpeg's own `-ac 2` downmix of the same second, interleaved. Written as 16-bit integers:
/// swresample only normalises the matrix (to keep full scale from clipping) for integer output.
fn ffmpeg_ac2(p: &Path) -> Vec<f32> {
let out = Command::new("ffmpeg")
.args(["-hide_banner", "-loglevel", "error", "-ss", "0.2", "-t", "1", "-i"])
.arg(p)
.args(["-ac", "2", "-ar", "48000", "-f", "s16le", "-"])
.output()
.expect("run ffmpeg");
out.stdout.as_chunks::<2>().0.iter().map(|b| f32::from(i16::from_le_bytes(*b)) / 32768.0).collect()
}
fn rms(s: &[f32]) -> f64 {
(s.iter().map(|v| f64::from(*v).powi(2)).sum::<f64>() / s.len().max(1) as f64).sqrt()
}
fn peak(s: &[f32]) -> f64 {
s.iter().fold(0.0f64, |m, v| m.max(f64::from(v.abs())))
}
/// Each channel carries its own sine at amplitude 0.5·√... (ffmpeg's `sine` is −24 dBFS RMS,
/// peak ≈ 0.125). Every channel must reach each side at the BS.775 gain, and the overall level
/// must match ffmpeg's `-ac 2`.
#[test]
fn each_channel_reaches_stereo_at_its_bs775_gain() {
for c in cases() {
let freqs: Vec<u32> = FREQS.iter().copied().take(c.names.len()).collect();
let Some(p) = fixture(c.file, c.layout, c.names, &freqs, c.codec) else { continue };
let s = ours(&p);
let sine = 0.125 * 1.0; // peak of ffmpeg's sine source (0.125 = 2^-3)
for (i, (name, g)) in c.names.iter().zip(&c.gains).enumerate() {
for (side, gain) in g.iter().enumerate() {
let got = amp(&s, side, freqs[i]);
let want = sine * gain;
assert!((got - want).abs() <= sine * c.tol.max(0.02), "{}: {name} -> {} is {got:.4}, expected {want:.4}", c.file, ["L", "R"][side]);
}
}
let theirs = ffmpeg_ac2(&p);
let (a, b) = (rms(&s), rms(&theirs));
let db = 20.0 * (a / b).log10();
assert!(db.abs() < 1.0, "{}: level {db:.2} dB off ffmpeg -ac 2 (rms {a:.4} vs {b:.4})", c.file);
let pdb = 20.0 * (peak(&s) / peak(&theirs)).log10();
assert!(pdb.abs() < 1.5, "{}: peak {pdb:.2} dB off ffmpeg -ac 2", c.file);
}
}
/// Full-scale in every channel must not clip.
#[test]
fn loud_surround_does_not_clip() {
let Some(p) = fixture("dm51_loud.wav", "5.1", &["FL", "FR", "FC", "LFE", "BL", "BR"], &[300; 6], &["-c:a", "pcm_s16le"]) else { return };
let s = ours(&p);
assert!(peak(&s) <= 1.001, "peak {}", peak(&s));
assert!(peak(&s) > 0.5, "audible, peak {}", peak(&s));
}
/// Mono and stereo footage keep their level (mono on both sides).
#[test]
fn mono_and_stereo_keep_their_level() {
let Some(p) = fixture("dm10.wav", "mono", &["FC"], &[440], &["-c:a", "pcm_s16le"]) else { return };
let s = ours(&p);
assert!((amp(&s, 0, 440) - 0.125).abs() < 0.003 && (amp(&s, 1, 440) - 0.125).abs() < 0.003);
let Some(p) = fixture("dm20.wav", "stereo", &["FL", "FR"], &[440, 660], &["-c:a", "pcm_s16le"]) else { return };
let s = ours(&p);
assert!((amp(&s, 0, 440) - 0.125).abs() < 0.003 && amp(&s, 1, 440) < 0.003);
assert!((amp(&s, 1, 660) - 0.125).abs() < 0.003 && amp(&s, 0, 660) < 0.003);
}
/// Odd channel counts (3, 4, 7, 9, 12, 16) decode to finite stereo without panicking.
#[test]
fn hostile_channel_counts_do_not_panic() {
for (n, layout) in [(3, "3.0"), (4, "quad"), (7, "6.1"), (9, "9 channels"), (12, "12 channels"), (16, "16 channels")] {
let names: Vec<String> = (0..n).map(|i| format!("{i}")).collect();
// channel names for unnamed layouts are DEFAULT-ordered by position
let _ = names;
let file = format!("dmn{n}.wav");
let args = ["-f", "lavfi", "-i", &format!("sine=frequency=440:sample_rate={RATE}:duration=1")];
if !Command::new("ffmpeg").arg("-version").output().is_ok_and(|o| o.status.success()) {
return;
}
let out = fixtures().join(&file);
std::fs::create_dir_all(fixtures()).expect("mkdir");
if !out.exists() {
let tmp = fixtures().join(format!("tmp-{}-{file}", std::process::id()));
let af = format!("pan={layout}|{}", (0..n).map(|i| format!("c{i}=c0")).collect::<Vec<_>>().join("|"));
let ok =
Command::new("ffmpeg").args(["-hide_banner", "-loglevel", "error", "-y"]).args(args).args(["-af", &af, "-c:a", "pcm_s16le"]).arg(&tmp).status();
if !ok.is_ok_and(|s| s.success()) {
eprintln!("ffmpeg cannot make {n} channels: skipping");
continue;
}
std::fs::rename(&tmp, &out).expect("rename");
}
let Ok(f) = probe(&out) else { continue };
let s = MediaPool::new().audio_samples(&f, Tick::ZERO, 4800, RATE);
assert_eq!(s.len(), 9600, "{n} channels");
assert!(s.iter().all(|v| v.is_finite() && v.abs() <= 1.001), "{n} channels");
}
}
+84
View File
@@ -0,0 +1,84 @@
//! A still's Exif orientation (a camera held sideways, a mirrored scan) is applied on import: the
//! footage has the size the picture is seen at, and its frames are the picture the right way up.
use std::sync::Arc;
use effectcraft_media::{MediaPool, probe_bytes};
use effectcraft_project::FootageKind;
use effectcraft_time::Tick;
const RED: [f32; 3] = [1.0, 0.0, 0.0];
const GREEN: [f32; 3] = [0.0, 1.0, 0.0];
const BLUE: [f32; 3] = [0.0, 0.0, 1.0];
const WHITE: [f32; 3] = [1.0, 1.0, 1.0];
/// A 32×16 JPEG with red, green / blue, white quarters, tagged with Exif orientation `tag`
/// (`None`: no Exif at all).
fn quartered_jpeg(tag: Option<u8>) -> Vec<u8> {
let img = image::RgbImage::from_fn(32, 16, |x, y| {
let c = match (x < 16, y < 8) {
(true, true) => RED,
(false, true) => GREEN,
(true, false) => BLUE,
(false, false) => WHITE,
};
image::Rgb(c.map(|v| (v * 255.0) as u8))
});
let mut jpeg = Vec::new();
image::codecs::jpeg::JpegEncoder::new_with_quality(&mut jpeg, 100).encode_image(&img).expect("encode");
let Some(tag) = tag else { return jpeg };
// APP1 "Exif": a big-endian TIFF header and one IFD holding Orientation (0x0112, SHORT).
let mut app1 = vec![0xFF, 0xE1, 0x00, 0x22];
app1.extend_from_slice(b"Exif\0\0MM\0\x2A\0\0\0\x08");
app1.extend_from_slice(&[0x00, 0x01, 0x01, 0x12, 0x00, 0x03, 0x00, 0x00, 0x00, 0x01, 0x00, tag, 0x00, 0x00]);
app1.extend_from_slice(&[0x00; 4]);
// (Straight after the SOI marker.)
jpeg.splice(2..2, app1);
jpeg
}
/// The footage size and the colours of its four corners (top-left, top-right, bottom-left,
/// bottom-right), as imported.
fn imported(tag: Option<u8>) -> ((u32, u32), [[f32; 3]; 4]) {
let name = format!("oriented-{tag:?}.jpg");
let bytes: Arc<[u8]> = quartered_jpeg(tag).into();
let f = probe_bytes(&name, bytes.clone()).expect("probe");
assert_eq!(f.kind, FootageKind::Still);
let pool = MediaPool::new();
pool.add_bytes(&name, bytes);
let img = pool.frame_at(&f, Tick::ZERO).expect("frame");
assert_eq!((img.width, img.height), (f.width, f.height), "orientation {tag:?}: the frame has the probed size");
let (w, h) = (img.width as usize, img.height as usize);
let at = |x: usize, y: usize| {
let p = img.data[y * w + x];
[p[0], p[1], p[2]]
};
((f.width, f.height), [at(2, 2), at(w - 3, 2), at(2, h - 3), at(w - 3, h - 3)])
}
#[test]
fn exif_orientation_is_applied_to_stills() {
let (landscape, portrait) = ((32, 16), (16, 32));
let cases = [
(None, landscape, [RED, GREEN, BLUE, WHITE]),
(Some(1), landscape, [RED, GREEN, BLUE, WHITE]),
(Some(2), landscape, [GREEN, RED, WHITE, BLUE]),
(Some(3), landscape, [WHITE, BLUE, GREEN, RED]),
(Some(4), landscape, [BLUE, WHITE, RED, GREEN]),
(Some(5), portrait, [RED, BLUE, GREEN, WHITE]),
(Some(6), portrait, [BLUE, RED, WHITE, GREEN]),
(Some(7), portrait, [WHITE, GREEN, BLUE, RED]),
(Some(8), portrait, [GREEN, WHITE, RED, BLUE]),
// (Not an orientation: the picture as stored.)
(Some(0), landscape, [RED, GREEN, BLUE, WHITE]),
(Some(9), landscape, [RED, GREEN, BLUE, WHITE]),
];
for (tag, size, corners) in cases {
let (got_size, got) = imported(tag);
assert_eq!(got_size, size, "orientation {tag:?}: size");
for (i, (g, want)) in got.iter().zip(corners).enumerate() {
let near = g.iter().zip(want).all(|(a, b)| (a - b).abs() < 0.2);
assert!(near, "orientation {tag:?}: corner {i} is {g:?}, expected {want:?}");
}
}
}
File diff suppressed because it is too large Load Diff
+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;
+101
View File
@@ -0,0 +1,101 @@
//! Opening a long movie reads its index, not the whole file: probing it and decoding a frame and
//! some audio must not grow peak memory by anything near the file's size.
//!
//! The large movie is a small ffmpeg-made MP4 followed by a 256 MiB `free` box (a sparse file, so
//! it costs no disk writes). Reading it whole, as before, grew peak RSS by its size; read in place,
//! the box is skipped by its header. The only test in this binary, so peak RSS is its own.
//! Skips (passes with a note) without ffmpeg or off Linux (peak RSS from `/proc/self/status`).
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::Command;
use effectcraft_media::{MediaPool, probe};
use effectcraft_time::Tick;
const PADDING: u64 = 256 << 20;
fn fixtures() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/fixtures/media")
}
/// A 2 s 320×240 H.264 + AAC movie (written to a temp name and renamed, so concurrent test
/// processes never see a partial file). `None` without ffmpeg.
fn small_mp4() -> Option<PathBuf> {
let out = fixtures().join("in_place_small.mp4");
if out.exists() {
return Some(out);
}
if !Command::new("ffmpeg").arg("-version").output().is_ok_and(|o| o.status.success()) {
return None;
}
std::fs::create_dir_all(fixtures()).unwrap();
let tmp = fixtures().join(format!("in_place_tmp_{}.mp4", std::process::id()));
let st = Command::new("ffmpeg")
.args(["-hide_banner", "-loglevel", "error", "-y", "-f", "lavfi", "-i", "testsrc2=size=320x240:rate=30:duration=2"])
.args(["-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000:duration=2"])
.args(["-c:v", "libx264", "-pix_fmt", "yuv420p", "-c:a", "aac", "-shortest"])
.arg(&tmp)
.status()
.unwrap();
assert!(st.success(), "ffmpeg failed");
std::fs::rename(&tmp, &out).unwrap();
Some(out)
}
/// `/proc/self/status` field `key` in KiB.
fn status_kib(key: &str) -> Option<u64> {
let s = std::fs::read_to_string("/proc/self/status").ok()?;
let line = s.lines().find(|l| l.starts_with(key))?;
line.split_whitespace().nth(1)?.parse().ok()
}
/// Peak RSS so far in bytes, after resetting it to the current RSS where the kernel allows that.
fn reset_peak() -> Option<u64> {
let _ = std::fs::write("/proc/self/clear_refs", "5");
status_kib("VmHWM:").map(|k| k * 1024)
}
#[test]
fn a_long_movie_is_read_in_place_not_whole() {
if !cfg!(target_os = "linux") {
eprintln!("peak RSS is read from /proc: skipping");
return;
}
let Some(small) = small_mp4() else {
eprintln!("ffmpeg not found: skipping");
return;
};
// The same movie with 256 MiB of padding after its boxes.
let large = fixtures().join(format!("in_place_large_{}.mp4", std::process::id()));
std::fs::copy(&small, &large).unwrap();
let mut f = std::fs::OpenOptions::new().append(true).open(&large).unwrap();
f.write_all(&(8 + PADDING as u32).to_be_bytes()).unwrap();
f.write_all(b"free").unwrap();
f.set_len(std::fs::metadata(&small).unwrap().len() + 8 + PADDING).unwrap();
drop(f);
// Warm up on the small file (code paths, allocator, decoder tables).
let small_footage = probe(&small).unwrap();
let pool = MediaPool::new();
pool.set_read_ahead(false);
let small_frame = pool.frame_at(&small_footage, Tick::ZERO).unwrap();
let small_audio = pool.audio_samples(&small_footage, Tick::ZERO, 4800, 48_000);
let before = reset_peak().unwrap();
let footage = probe(&large).unwrap();
let frame = pool.frame_at(&footage, Tick::ZERO).unwrap();
let audio = pool.audio_samples(&footage, Tick::ZERO, 4800, 48_000);
let after = status_kib("VmHWM:").unwrap() * 1024;
drop(pool);
let _ = std::fs::remove_file(&large);
let growth = after.saturating_sub(before);
eprintln!("peak RSS growth: {} KiB", growth >> 10);
assert!(growth < 64 << 20, "probing and decoding a {} MiB movie grew peak RSS by {} MiB", PADDING >> 20, growth >> 20);
// Same movie, same results.
assert_eq!(footage, effectcraft_project::Footage { path: footage.path.clone(), ..small_footage });
assert_eq!(frame, small_frame);
assert_eq!(audio, small_audio);
assert!(audio.iter().any(|s| s.abs() > 0.1), "the tone decodes");
}
+115
View File
@@ -0,0 +1,115 @@
//! Tiny synthetic Targa files exercise import without external fixtures or codecs.
use std::path::Path;
use std::sync::Arc;
use effectcraft_media::{MediaPool, is_importable, probe_bytes};
use effectcraft_project::{AlphaMode, Footage, FootageKind};
use effectcraft_time::Tick;
/// A classic 2×2 TGA without a footer. True-colour pixels are stored BGR(A); RLE
/// packets repeat each row's colour twice. `pixels` is always in top-down RGBA order.
fn tga(pixels: [[u8; 4]; 4], alpha: bool, rle: bool, top: bool) -> Vec<u8> {
let mut bytes = vec![0; 18];
bytes[2] = if rle { 10 } else { 2 };
bytes[12] = 2;
bytes[14] = 2;
bytes[16] = if alpha { 32 } else { 24 };
bytes[17] = if top { 0x20 } else { 0 } | if alpha { 8 } else { 0 };
let rows = if top { [[0, 1], [2, 3]] } else { [[2, 3], [0, 1]] };
for row in rows {
if rle {
assert_eq!(pixels[row[0]], pixels[row[1]]);
bytes.push(0x81); // A two-pixel run.
}
for &i in row.iter().take(if rle { 1 } else { 2 }) {
let [r, g, b, a] = pixels[i];
bytes.extend_from_slice(&[b, g, r]);
if alpha {
bytes.push(a);
}
}
}
bytes
}
#[test]
fn tga_raw_and_rle_preserve_colours_orientation_and_alpha() {
for alpha in [false, true] {
for rle in [false, true] {
for top in [false, true] {
let pixels = if rle {
[[255, 0, 0, 128], [255, 0, 0, 128], [0, 0, 255, 255], [0, 0, 255, 255]]
} else {
[[255, 0, 0, 128], [0, 255, 0, 255], [0, 0, 255, 64], [255, 255, 255, 0]]
};
let path = if top { "/frame.TGA" } else { "/frame.tga" };
assert!(is_importable(Path::new(path)));
let bytes: Arc<[u8]> = tga(pixels, alpha, rle, top).into();
let footage = probe_bytes(path, bytes.clone()).unwrap();
assert_eq!(footage.kind, FootageKind::Still);
assert_eq!((footage.width, footage.height), (2, 2));
assert_eq!(footage.codec, "TGA");
assert_eq!(footage.alpha, if alpha { AlphaMode::Straight } else { AlphaMode::Ignore });
assert!(footage.has_video && !footage.has_audio);
let pool = MediaPool::new();
pool.add_bytes(path, bytes);
let image = pool.frame_at(&footage, Tick::ZERO).unwrap();
for (actual, [r, g, b, a]) in image.data.iter().zip(pixels) {
let a = if alpha { f32::from(a) / 255.0 } else { 1.0 };
let expected = [f32::from(r) / 255.0 * a, f32::from(g) / 255.0 * a, f32::from(b) / 255.0 * a, a];
assert!(actual.iter().zip(expected).all(|(a, b)| (a - b).abs() < 1e-6), "{actual:?} != {expected:?}");
}
}
}
}
}
#[test]
fn tga_sequence_decodes_each_frame_from_memory() {
let pool = MediaPool::new();
pool.set_read_ahead(false);
let paths = ["/shot_0001.tga", "/shot_0002.tga"];
let colours = [[255, 0, 0, 255], [0, 0, 255, 255]];
let mut footage = None;
for (path, colour) in paths.iter().zip(colours) {
let bytes: Arc<[u8]> = tga([colour; 4], false, true, true).into();
footage.get_or_insert_with(|| probe_bytes(path, bytes.clone()).unwrap());
pool.add_bytes(path, bytes);
}
let mut footage = Footage { kind: FootageKind::Sequence, sequence: paths.iter().map(|s| s.to_string()).collect(), ..footage.unwrap() };
footage.sync_sequence_duration();
for (n, expected) in [[1.0, 0.0, 0.0, 1.0], [0.0, 0.0, 1.0, 1.0]].into_iter().enumerate() {
let image = pool.frame_at(&footage, footage.frame_rate.tick_of(n as i64)).unwrap();
assert_eq!(image.get(0, 0), expected);
}
}
#[test]
fn malformed_tga_returns_contextual_errors() {
for bytes in [vec![], vec![0; 17], vec![0; 18]] {
let error = probe_bytes("/broken.tga", bytes.into()).unwrap_err().to_string();
assert!(error.contains("/broken.tga"), "{error}");
}
let mut bytes = tga([[255, 0, 0, 255]; 4], true, false, true);
bytes.truncate(19); // Valid metadata, truncated pixel data.
let bytes: Arc<[u8]> = bytes.into();
let footage = probe_bytes("/truncated.tga", bytes.clone()).unwrap();
let pool = MediaPool::new();
pool.add_bytes(&footage.path, bytes);
let error = pool.frame_at(&footage, Tick::ZERO).unwrap_err().to_string();
assert!(error.contains("/truncated.tga"), "{error}");
}
#[test]
fn a_recognised_signature_takes_precedence_over_the_tga_extension() {
let mut bytes = std::io::Cursor::new(Vec::new());
image::DynamicImage::new_rgb8(3, 2).write_to(&mut bytes, image::ImageFormat::Png).unwrap();
let bytes: Arc<[u8]> = bytes.into_inner().into();
let footage = probe_bytes("/actually-png.tga", bytes.clone()).unwrap();
assert_eq!(footage.codec, "PNG");
let pool = MediaPool::new();
pool.add_bytes(&footage.path, bytes);
let image = pool.frame_at(&footage, Tick::ZERO).unwrap();
assert_eq!((image.width, image.height), (3, 2));
}