Files
effectcraft/crates/export/tests/roundtrip.rs
T
Brandon ThomasandClaude Opus 5.5 7f9c3a85c0 M10: render queue + export
- crates/export (effectcraft-export): render a comp's span to H.264 MP4 (+AAC),
  ProRes 422/4444 MOV (+PCM, alpha), PNG/JPEG/TIFF/EXR sequences and animated GIF
  via FilmCraft's pure-Rust encoders and MP4/MOV muxer; parallel batched frame
  rendering, progress callback with cancellation.
- project: Render Queue model (Render Settings, Output Module, output path
  templates, status) saved with the project.
- render: FootageSource::audio hook + comp audio mixdown (levels, in/out, solo,
  precomps); media implements it.
- engine: Exporter hook, background/blocking render job, renderQueue.* commands
  (add Cmd+M, remove, setRender, setRenderSettings, setOutputModule, setOutput,
  move, duplicate, render, stop, list, formats).
- host wires the exporter; CLI `render` subcommand.
- ui-egui: After Effects-style Render Queue panel with automation ids.
- tests: encode/decode round trips for every format (FilmCraft decoders,
  ffmpeg/ffprobe oracle when installed), audio, cancel, engine commands.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 22:02:06 -04:00

366 lines
15 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, comp: cid, settings: &s, output: om, path: &path };
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, comp: cid, settings: &s, output: &om, path: &path };
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, comp: cid, settings: &s, output: &om, path: &path };
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);
}
}