Files
effectcraft/crates/export/tests/roundtrip.rs
T
Brandon ThomasandClaude Opus 5.5 f855a4b55b M14.5: wire the remaining settings; keyframe assistants and labels; AE menu entries
Settings: 56 of 59 not-yet-wired settings now change behaviour (nested-comp switches,
constant vertex count, related-item time sync, match-name pick whip, split-layer order,
Home on open, motion path span, internal wireframes, zoom quality, label-coloured tabs,
mask colour cycling, gradients, grid/guide styles, text engine, font preview/recent/native
names, missing frames, unlabeled alpha, drag import, default output folder, segmenting,
bit depth names, preview sample rate, disk/media/conformed-audio caches, RAM reserve and
low-memory reduction, Video Preview window, 3D reference axes/extended viewer/draft
shadows, script file gate + File.execute warning, expression editor options and error
banner). Three stay display-only and are documented.

Features: Convert Audio to Keyframes, RPF Camera Import (JSON/CSV camera data),
keyframe colour labels + Select Keyframe Label Group, Recent Animation Presets.
Menus: Edit > History, File > Import Recent Footage, Replace Footage > With Layered
Comp, Save a Copy As XML (.ecprojx, opens again), Composition > VR (Create VR
Environment, Extract Cubemap), Assign Shortcut to 3D view / workspace, Window viewer
labels + open viewers, Help > System Compatibility Report / Enable Logging / Reveal
Logging File. Dynamic menu nodes (@dynamic:…) shared by the egui and native menus.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 02:45:44 -04:00

460 lines
19 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Encode a short comp to every output format and decode it back: through EffectCraft's own
//! media layer (FilmCraft decoders) for MP4/MOV, the `image`/`gif` crates for stills and GIF,
//! and — when installed — ffprobe as an external oracle (skipped otherwise).
//!
//! The comp: 64×48 @ 10 fps, 1.2 s (12 frames), dark-blue background, a red solid of 32×48
//! centred, starting at frame 5. So frame < 5 is background everywhere; from frame 5 the centre
//! is red and the corners stay background.
use std::path::{Path, PathBuf};
use std::process::Command;
use effectcraft_color::Label;
use effectcraft_export::render_queue::{AudioOutput, Channels, OutputFormat, OutputModule, ProResProfile, RenderSettings, TimeSpan, sequence_path};
use effectcraft_export::{Job, Progress, export};
use effectcraft_media::MediaPool;
use effectcraft_project::{Comp, ItemId, ItemKind, LayerSource, Project, Solid, build};
use effectcraft_raster::Image;
use effectcraft_render::{FootageSource, NoFootage};
use effectcraft_time::{FrameRate, Tick};
const W: u32 = 64;
const H: u32 = 48;
const BG: [f32; 3] = [0.1, 0.1, 0.4];
const FRAMES: u64 = 12;
fn out_dir(name: &str) -> PathBuf {
let d = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/test-out/export").join(name);
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).expect("mkdir");
d
}
fn project(audio: Option<&Path>) -> (Project, ItemId) {
let mut p = Project::default();
let mut comp = Comp::new(W, H, FrameRate::new(10, 1), Tick::from_seconds_f64(1.2));
comp.background = BG;
let sid = p.add_item("Red", Label::Red, None, ItemKind::Solid(Solid { color: [1.0, 0.0, 0.0], width: 32, height: H, pixel_aspect: 1.0 }));
let mut l = build::layer(&mut p, &comp, "Red", LayerSource::Solid { item: sid }, (32, H), None);
l.in_point = comp.frame_rate.tick_of(5);
comp.layers.push(l);
if let Some(path) = audio {
let f = effectcraft_media::probe(path).expect("probe wav");
let fid = p.add_item("tone.wav", Label::SeaFoam, None, ItemKind::Footage(f));
let l = build::layer(&mut p, &comp, "tone", LayerSource::Footage { item: fid }, (0, 0), None);
comp.layers.push(l);
}
let cid = p.add_item("Comp 1", Label::Sandstone, None, ItemKind::Comp(comp.into()));
(p, cid)
}
fn settings() -> RenderSettings {
RenderSettings { time_span: TimeSpan::LengthOfComp, ..Default::default() }
}
fn run(p: &Project, cid: ItemId, footage: &dyn FootageSource, om: &OutputModule, path: &Path) -> effectcraft_export::Report {
let s = settings();
let path = path.to_string_lossy().to_string();
let job = Job { project: p, footage, expr: None, accel: None, comp: cid, settings: &s, output: om, path: &path, sink: None, nested_switches: true };
let mut last = Progress::default();
let mut calls = 0;
let r = export(&job, &mut |pr| {
assert!(pr.done >= last.done, "progress is monotonic");
last = *pr;
calls += 1;
true
})
.expect("export");
assert_eq!(last.done, FRAMES);
assert_eq!(last.total, FRAMES);
assert!(calls >= 2);
assert_eq!(r.frames, FRAMES);
r
}
fn px(img: &Image, x: u32, y: u32) -> [f32; 4] {
img.data[img.idx(x, y)]
}
fn near(a: [f32; 3], b: [f32; 3], tol: f32) -> bool {
a.iter().zip(b).all(|(x, y)| (x - y).abs() <= tol)
}
/// Check a decoded (premultiplied, opaque) frame `k`.
fn check_frame(img: &Image, k: u64, tol: f32) {
let corner = px(img, 2, 2);
assert!(near([corner[0], corner[1], corner[2]], BG, tol), "frame {k} corner {corner:?}");
let c = px(img, img.width / 2, img.height / 2);
let want = if k >= 5 { [1.0, 0.0, 0.0] } else { BG };
assert!(near([c[0], c[1], c[2]], want, tol), "frame {k} centre {c:?} want {want:?}");
}
fn decode_movie(path: &Path, frames: u64, tol: f32) -> effectcraft_project::Footage {
let f = effectcraft_media::probe(path).expect("probe our output");
assert_eq!((f.width, f.height), (W, H));
assert!(f.has_video);
let n = f.frame_rate.frame_at(f.duration - Tick(1)) + 1;
assert_eq!(n as u64, frames, "frame count from duration {:?} @ {:?}", f.duration, f.frame_rate);
let pool = MediaPool::new();
for k in 0..frames {
let img = pool.frame_at(&f, f.frame_rate.tick_of(k as i64)).expect("decode");
check_frame(&img, k, tol);
}
f
}
fn ffprobe(path: &Path) -> Option<String> {
let out = Command::new("ffprobe")
.args(["-v", "error", "-count_frames", "-show_entries", "stream=codec_name,width,height,nb_read_frames,pix_fmt", "-of", "compact=p=0:nk=1"])
.arg(path)
.output()
.ok()?;
if !out.status.success() {
panic!("ffprobe rejected {}: {}", path.display(), String::from_utf8_lossy(&out.stderr));
}
Some(String::from_utf8_lossy(&out.stdout).to_string())
}
/// Frame `k` decoded by ffmpeg (external oracle) as RGBA, or `None` without ffmpeg.
fn ffmpeg_frame(path: &Path, k: u64) -> Option<Image> {
let png = path.with_extension(format!("ffmpeg-{k}.png"));
let st = Command::new("ffmpeg")
.args(["-hide_banner", "-loglevel", "error", "-y", "-i"])
.arg(path)
.args(["-vf", &format!("select=eq(n\\,{k})"), "-frames:v", "1", "-pix_fmt", "rgba"])
.arg(&png)
.status()
.ok()?;
assert!(st.success(), "ffmpeg failed to decode {}", path.display());
let img = image::open(&png).expect("ffmpeg png").to_rgba8();
Some(Image::from_rgba8(img.width(), img.height(), img.as_raw()))
}
fn ffmpeg_check(path: &Path, tol: f32) {
for k in [0, 4, 5, FRAMES - 1] {
match ffmpeg_frame(path, k) {
Some(img) => check_frame(&img, k, tol),
None => {
eprintln!("ffmpeg not found: skipping oracle decode");
return;
}
}
}
}
#[test]
fn h264_mp4_roundtrip() {
let d = out_dir("h264");
let (p, cid) = project(None);
let om = OutputModule { bitrate_kbps: 4000, ..OutputModule::for_format(OutputFormat::H264) };
let path = d.join("comp.mp4");
let r = run(&p, cid, &NoFootage, &om, &path);
assert!(r.bytes > 100 && !r.audio);
decode_movie(&path, FRAMES, 0.08);
ffmpeg_check(&path, 0.08);
if let Some(s) = ffprobe(&path) {
assert!(s.contains("h264|64|48|yuv420p"), "{s}");
assert!(s.lines().next().unwrap_or("").ends_with("|12"), "{s}");
}
}
#[test]
fn h264_odd_size_is_cropped_even() {
let d = out_dir("h264-odd");
let (p, cid) = project(None);
let om = OutputModule::for_format(OutputFormat::H264);
let s = RenderSettings { resolution: 31.0 / 64.0, ..settings() };
let comp = p.comp(cid).expect("comp");
assert_eq!(s.output_size(comp).0 % 2, 1);
let path = d.join("odd.mp4").to_string_lossy().to_string();
let job =
Job { project: &p, footage: &NoFootage, expr: None, accel: None, comp: cid, settings: &s, output: &om, path: &path, sink: None, nested_switches: true };
let r = export(&job, &mut |_| true).expect("export");
assert_eq!((r.width % 2, r.height % 2), (0, 0));
}
#[test]
fn prores_mov_roundtrip() {
let d = out_dir("prores");
let (p, cid) = project(None);
let om = OutputModule::for_format(OutputFormat::ProRes);
let path = d.join("comp.mov");
run(&p, cid, &NoFootage, &om, &path);
decode_movie(&path, FRAMES, 0.03);
ffmpeg_check(&path, 0.03);
if let Some(s) = ffprobe(&path) {
assert!(s.starts_with("prores|64|48"), "{s}");
assert!(s.lines().next().unwrap_or("").ends_with("|12"), "{s}");
}
}
#[test]
fn prores_4444_with_alpha() {
let d = out_dir("prores4444");
let (p, cid) = project(None);
let om = OutputModule { channels: Channels::Rgba, prores_profile: ProResProfile::Hq, ..OutputModule::for_format(OutputFormat::ProRes) };
let path = d.join("alpha.mov");
run(&p, cid, &NoFootage, &om, &path);
let f = effectcraft_media::probe(&path).expect("probe");
let img = MediaPool::new().frame_at(&f, f.frame_rate.tick_of(7)).expect("decode");
assert!(px(&img, 2, 2)[3] < 0.02, "corner transparent: {:?}", px(&img, 2, 2));
let c = px(&img, W / 2, H / 2);
assert!(c[3] > 0.98 && c[0] > 0.95 && c[1] < 0.05, "centre opaque red: {c:?}");
if let Some(s) = ffprobe(&path) {
assert!(s.starts_with("prores|64|48|yuva444"), "{s}");
}
}
fn check_still_dir(dir: &Path, ext: &str, tol: f32, alpha: bool) {
for k in 0..FRAMES {
let path = sequence_path(&dir.join(format!("f_[#####].{ext}")).to_string_lossy(), k as i64);
let img = image::open(&path).unwrap_or_else(|e| panic!("{path}: {e}")).to_rgba8();
assert_eq!(img.dimensions(), (W, H));
let img = Image::from_rgba8(W, H, img.as_raw());
if alpha {
let corner = px(&img, 2, 2);
assert!(corner[3] < 0.01, "{path}: corner {corner:?}");
let c = px(&img, W / 2, H / 2);
assert_eq!(c[3] > 0.99, k >= 5, "{path}: centre {c:?}");
} else {
check_frame(&img, k, tol);
}
}
assert!(!dir.join(format!("f_{:05}.{ext}", FRAMES)).exists());
}
#[test]
fn image_sequences_roundtrip() {
let (p, cid) = project(None);
for (fmt, ext, tol, ch) in [
(OutputFormat::PngSequence, "png", 0.01, Channels::Rgb),
(OutputFormat::PngSequence, "png", 0.01, Channels::Rgba),
(OutputFormat::JpegSequence, "jpg", 0.06, Channels::Rgb),
(OutputFormat::TiffSequence, "tif", 0.01, Channels::Rgb),
(OutputFormat::TiffSequence, "tif", 0.01, Channels::Rgba),
] {
let d = out_dir(&format!("seq-{ext}-{ch:?}"));
let om = OutputModule { channels: ch, ..OutputModule::for_format(fmt) };
let r = run(&p, cid, &NoFootage, &om, &d.join(format!("f_[#####].{ext}")));
assert!(r.path.ends_with(&format!("f_00000.{ext}")), "{}", r.path);
check_still_dir(&d, ext, tol, ch == Channels::Rgba);
}
}
#[test]
fn exr_sequence_is_linear() {
let (p, cid) = project(None);
let d = out_dir("seq-exr");
let om = OutputModule::for_format(OutputFormat::ExrSequence);
run(&p, cid, &NoFootage, &om, &d.join("f_[#####].exr"));
let img = image::open(d.join("f_00000.exr")).expect("exr").to_rgba32f();
assert_eq!(img.dimensions(), (W, H));
let c = img.get_pixel(2, 2).0;
// sRGB 0.4 → linear ≈ 0.133
assert!((c[2] - 0.1329).abs() < 0.01 && (c[3] - 1.0).abs() < 1e-6, "{c:?}");
}
#[test]
fn gif_roundtrip() {
let (p, cid) = project(None);
let d = out_dir("gif");
let om = OutputModule::for_format(OutputFormat::Gif);
let path = d.join("anim.gif");
run(&p, cid, &NoFootage, &om, &path);
let mut opts = gif::DecodeOptions::new();
opts.set_color_output(gif::ColorOutput::RGBA);
let mut dec = opts.read_info(std::fs::File::open(&path).expect("open")).expect("gif header");
assert_eq!((dec.width() as u32, dec.height() as u32), (W, H));
let mut k = 0;
let mut total_delay = 0u32;
while let Some(f) = dec.read_next_frame().expect("frame") {
assert_eq!((f.width as u32, f.height as u32), (W, H));
total_delay += f.delay as u32;
let img = Image::from_rgba8(W, H, &f.buffer);
check_frame(&img, k, 0.06);
k += 1;
}
assert_eq!(k, FRAMES);
assert_eq!(total_delay, 120, "1.2 s in centiseconds");
if let Some(s) = ffprobe(&path) {
assert!(s.starts_with("gif|64|48"), "{s}");
}
}
#[test]
fn cancel_stops_early() {
let (p, cid) = project(None);
let d = out_dir("cancel");
let om = OutputModule::for_format(OutputFormat::PngSequence);
let s = settings();
let path = d.join("c_[#####].png").to_string_lossy().to_string();
let job =
Job { project: &p, footage: &NoFootage, expr: None, accel: None, comp: cid, settings: &s, output: &om, path: &path, sink: None, nested_switches: true };
let r = export(&job, &mut |_| false);
assert!(matches!(r, Err(effectcraft_export::ExportError::Cancelled)));
let written = std::fs::read_dir(&d).map(|r| r.count()).unwrap_or(0);
assert_eq!(written, 0, "cancelled before the first batch");
}
/// 16-bit stereo WAV with a 440 Hz sine at amplitude 0.5 (left) and silence (right).
fn write_tone(path: &Path, secs: f64) {
let sr = 48_000u32;
let n = (secs * sr as f64) as usize;
let mut data = Vec::with_capacity(n * 4);
for i in 0..n {
let s = (0.5 * (2.0 * std::f64::consts::PI * 440.0 * i as f64 / sr as f64).sin() * 32767.0).round() as i16;
data.extend_from_slice(&s.to_le_bytes());
data.extend_from_slice(&0i16.to_le_bytes());
}
let mut w = Vec::new();
w.extend_from_slice(b"RIFF");
w.extend_from_slice(&(36 + data.len() as u32).to_le_bytes());
w.extend_from_slice(b"WAVEfmt ");
w.extend_from_slice(&16u32.to_le_bytes());
w.extend_from_slice(&1u16.to_le_bytes());
w.extend_from_slice(&2u16.to_le_bytes());
w.extend_from_slice(&sr.to_le_bytes());
w.extend_from_slice(&(sr * 4).to_le_bytes());
w.extend_from_slice(&4u16.to_le_bytes());
w.extend_from_slice(&16u16.to_le_bytes());
w.extend_from_slice(b"data");
w.extend_from_slice(&(data.len() as u32).to_le_bytes());
w.extend_from_slice(&data);
std::fs::write(path, w).expect("write wav");
}
fn rms(v: impl Iterator<Item = f32>) -> f32 {
let (s, n) = v.fold((0.0f64, 0usize), |(s, n), x| (s + (x as f64) * (x as f64), n + 1));
(s / n.max(1) as f64).sqrt() as f32
}
#[test]
fn movies_carry_audio() {
let d = out_dir("audio");
let wav = d.join("tone.wav");
write_tone(&wav, 2.0);
let (p, cid) = project(Some(&wav));
let pool = MediaPool::new();
for (fmt, name) in [(OutputFormat::H264, "a.mp4"), (OutputFormat::ProRes, "a.mov")] {
let om = OutputModule { audio: AudioOutput::Auto, ..OutputModule::for_format(fmt) };
let path = d.join(name);
let r = run(&p, cid, &pool, &om, &path);
assert!(r.audio, "{name}: auto audio is on for a comp with a sound layer");
let f = effectcraft_media::probe(&path).expect("probe");
assert!(f.has_audio, "{name}");
let dec = MediaPool::new();
let s = dec.audio_samples(&f, Tick::from_seconds_f64(0.3), 24_000, 48_000);
let (l, r) = (rms(s.iter().step_by(2).copied()), rms(s.iter().skip(1).step_by(2).copied()));
// sine of amplitude 0.5 → RMS ≈ 0.354
assert!((l - 0.3536).abs() < 0.03, "{name}: left RMS {l}");
assert!(r < 0.01, "{name}: right RMS {r}");
if Command::new("ffprobe").arg("-version").output().is_ok() {
let out = Command::new("ffprobe")
.args(["-v", "error", "-select_streams", "a:0", "-show_entries", "stream=codec_name,channels,sample_rate", "-of", "compact=p=0:nk=1"])
.arg(&path)
.output()
.expect("ffprobe");
let s = String::from_utf8_lossy(&out.stdout);
let want = if fmt == OutputFormat::H264 { "aac|48000|2" } else { "pcm_s16le|48000|2" };
assert!(s.trim() == want, "{name}: {s}");
}
// Audio off.
let om = OutputModule { audio: AudioOutput::Off, ..om };
let path = d.join(format!("silent-{name}"));
assert!(!run(&p, cid, &pool, &om, &path).audio);
assert!(!effectcraft_media::probe(&path).expect("probe").has_audio);
}
}
#[test]
fn webm_vp9_opus_roundtrip() {
let d = out_dir("webm");
let wav = d.join("tone.wav");
write_tone(&wav, 2.0);
let (p, cid) = project(Some(&wav));
let pool = MediaPool::new();
let om = OutputModule { quality: 90, audio: AudioOutput::Auto, ..OutputModule::for_format(OutputFormat::WebM) };
let path = d.join("comp.webm");
let r = run(&p, cid, &pool, &om, &path);
assert!(r.audio && r.bytes > 200, "{r:?}");
// Decoded back through FilmCraft (Matroska demuxer, VP9 and Opus decoders).
let f = decode_movie(&path, FRAMES, 0.08);
assert!(f.has_audio);
let s = MediaPool::new().audio_samples(&f, Tick::from_seconds_f64(0.3), 24_000, 48_000);
let l = rms(s.iter().step_by(2).copied());
assert!((l - 0.3536).abs() < 0.06, "left RMS {l}");
ffmpeg_check(&path, 0.08);
if let Some(s) = ffprobe(&path) {
assert!(s.contains("vp9|64|48|yuv420p"), "{s}");
assert!(s.contains("opus"), "{s}");
}
}
#[test]
fn webm_with_alpha() {
let d = out_dir("webm-alpha");
let (p, cid) = project(None);
// Transparent outside the red solid.
let om = OutputModule { channels: Channels::Rgba, ..OutputModule::for_format(OutputFormat::WebM) };
let path = d.join("alpha.webm");
run(&p, cid, &NoFootage, &om, &path);
let f = effectcraft_media::probe(&path).expect("probe our output");
assert_eq!((f.width, f.height), (W, H));
let bytes = std::fs::read(&path).unwrap();
// AlphaMode element (0x53C0) and BlockAdditions (0x75A1).
assert!(bytes.windows(3).any(|w| w == [0x53, 0xC0, 0x81]), "AlphaMode 1");
assert!(bytes.windows(2).any(|w| w == [0x75, 0xA1]), "BlockAdditions");
// ffmpeg's libvpx decoder reads the alpha channel.
let png = path.with_extension("alpha.png");
let ok = Command::new("ffmpeg")
.args(["-hide_banner", "-loglevel", "error", "-y", "-c:v", "libvpx-vp9", "-i"])
.arg(&path)
.args(["-vf", "select=eq(n\\,8)", "-frames:v", "1", "-pix_fmt", "rgba"])
.arg(&png)
.status()
.map(|s| s.success())
.unwrap_or(false);
if ok {
let img = image::open(&png).unwrap().to_rgba8();
assert!(img.get_pixel(2, 2)[3] < 20, "corner transparent");
assert!(img.get_pixel(W / 2, H / 2)[3] > 235, "centre opaque");
} else {
eprintln!("ffmpeg with libvpx not available: skipping alpha oracle");
}
}
#[test]
fn wav_and_aiff_audio_only() {
let d = out_dir("audio-only");
let wav = d.join("tone.wav");
write_tone(&wav, 2.0);
let (p, cid) = project(Some(&wav));
let pool = MediaPool::new();
for (fmt, name, magic) in [(OutputFormat::Wav, "out.wav", &b"RIFF"[..]), (OutputFormat::Aiff, "out.aif", &b"FORM"[..])] {
let om = OutputModule::for_format(fmt);
let path = d.join(name);
let r = run(&p, cid, &pool, &om, &path);
assert!(r.audio);
let bytes = std::fs::read(&path).unwrap();
assert_eq!(&bytes[..4], magic);
// 1.2 s of 48 kHz 16-bit stereo.
assert!((bytes.len() as i64 - 57_600 * 4).abs() < 200, "{name}: {}", bytes.len());
let l = if fmt == OutputFormat::Wav {
// Decoded by FilmCraft.
let f = effectcraft_media::probe(&path).expect("probe audio");
assert!(f.has_audio && !f.has_video, "{name}");
let s = MediaPool::new().audio_samples(&f, Tick::from_seconds_f64(0.3), 24_000, 48_000);
rms(s.iter().step_by(2).copied())
} else {
// AIFF: big-endian samples after the SSND chunk's offset/block size.
let at = bytes.windows(4).position(|w| w == b"SSND").expect("SSND") + 16;
let left = bytes[at..].chunks_exact(4).skip(14_400).take(24_000).map(|c| i16::from_be_bytes([c[0], c[1]]) as f32 / 32767.0);
rms(left)
};
assert!((l - 0.3536).abs() < 0.01, "{name}: left RMS {l}");
if let Some(s) = ffprobe(&path) {
assert!(s.contains("pcm_s16"), "{s}");
}
}
}