Files
effectcraft/crates/export/tests/roundtrip.rs
T
Brandon ThomasandClaude Opus 5.5 31f192e2a9 M15.1: web build (eframe wasm) + xtask web
- apps/effectcraft-web (L6): eframe web runner booting the shared EffectcraftApp with a browser
  session: in-memory file table, <input type=file> pickers and drag-and-drop for open/import,
  downloads for Save and Render Queue outputs (several files as one stored .zip), and the
  window.effectcraft promise API over the control channel. Demo project on start.
- wasm32 runtime fixes: web-time for Instant/SystemTime (render, engine, export, ui-egui);
  Frames renders queued jobs on the UI thread (Frames::pump) instead of a thread pool;
  Render Queue runs inline; relative outputs resolve under / ; control PNGs inline.
- effectcraft-export: per-job output Sink (files to memory instead of disk); host FileExporter
  carries it; test that sink exports touch no file.
- xtask web [--dev] [--serve PORT] (wasm-bindgen 0.2.129, optional wasm-opt, COOP/COEP server);
  effectcraft-web in WEB_CRATES. docs/web.md, headless Chrome smoke test, favicon (+attribution).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 00:19:35 -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, sink: None };
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, sink: None };
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, sink: None };
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);
}
}