mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-31 21:48:20 -06:00
Merge main into enhance/avi-import
This commit is contained in:
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
Reference in New Issue
Block a user