Files
effectcraft/crates/export/tests/render_settings.rs
T
7b632bdbc1 MCP: report render progress and handle cancellation
Keep serving during long renders, recover failed workers, and remove only files opened by the interrupted export. Cover progress, cancellation, EOF and output ownership with regression tests.

Co-Authored-By: OpenAI Codex <[email protected]>
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-09 00:20:22 -07:00

388 lines
19 KiB
Rust

//! Render Settings and Output Module features end to end (through a [`Sink`], so nothing touches
//! the disk): field rendering and 3:2 pulldown, crop / region of interest / resize, alpha-only
//! and premultiplied output, the solo and guide-layer overrides, the render log, storage
//! overflow and PCM audio formats.
use std::sync::{Arc, Mutex};
use effectcraft_color::Label;
use effectcraft_export::render_queue::*;
use effectcraft_export::{Job, JobOptions, Report, Sink, StorageQuota, export};
use effectcraft_keyframe::{Keyframe, Value};
use effectcraft_project::{Comp, ItemId, ItemKind, LayerSource, Project, Solid, build};
use effectcraft_render::NoFootage;
use effectcraft_time::{FrameRate, Tick};
const W: u32 = 64;
const H: u32 = 48;
/// A comp at `rate`, black, with a white bar 8 px wide sweeping left to right (64 px/s) and a
/// static red block at the bottom left; `extra` adds layers.
fn project(rate: FrameRate, extra: impl FnOnce(&mut Project, &mut Comp)) -> (Project, ItemId) {
let mut p = Project::default();
let mut comp = Comp::new(W, H, rate, Tick::from_seconds_f64(2.0));
comp.background = [0.0, 0.0, 0.0];
let bar = p.add_item("Bar", Label::Red, None, ItemKind::Solid(Solid { color: [1.0, 1.0, 1.0], width: 8, height: 32, pixel_aspect: 1.0 }));
let mut l = build::layer(&mut p, &comp, "Bar", LayerSource::Solid { item: bar }, (8, 32), None);
l.props.prop_mut("transform/position").unwrap().keys =
vec![Keyframe::new(Tick::ZERO, Value::Vec3([0.0, 16.0, 0.0])), Keyframe::new(Tick::from_seconds_f64(1.0), Value::Vec3([64.0, 16.0, 0.0]))];
comp.layers.push(l);
let red = p.add_item("Block", Label::Red, None, ItemKind::Solid(Solid { color: [1.0, 0.0, 0.0], width: 16, height: 8, pixel_aspect: 1.0 }));
let mut b = build::layer(&mut p, &comp, "Block", LayerSource::Solid { item: red }, (16, 8), None);
b.props.prop_mut("transform/position").unwrap().value = Value::Vec3([8.0, 44.0, 0.0]);
comp.layers.push(b);
extra(&mut p, &mut comp);
let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.into()));
(p, cid)
}
type Files = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
fn custom(start: f64, end: f64) -> RenderSettings {
RenderSettings { time_span: TimeSpan::Custom { start: Tick::from_seconds_f64(start), end: Tick::from_seconds_f64(end) }, ..Default::default() }
}
fn run_with(p: &Project, cid: ItemId, s: &RenderSettings, om: &OutputModule, path: &str, options: JobOptions, cancel: bool) -> (Result<Report, String>, Files) {
let files: Files = Default::default();
let f = files.clone();
let sink: Box<Sink> = Box::new(move |path: &str, data: Vec<u8>| f.lock().unwrap().push((path.to_string(), data)));
let job = Job {
project: p,
footage: &NoFootage,
expr: None,
accel: None,
comp: cid,
settings: s,
output: om,
path,
sink: Some(&*sink),
options,
nested_switches: true,
};
let r = export(&job, &mut |pr| !(cancel && pr.done > 0)).map_err(|e| e.to_string());
(r, files)
}
fn run(p: &Project, cid: ItemId, s: &RenderSettings, om: &OutputModule, path: &str) -> Files {
let (r, files) = run_with(p, cid, s, om, path, JobOptions::default(), false);
r.expect("export");
files
}
fn png(files: &Files, i: usize) -> image::RgbaImage {
let f = files.lock().unwrap();
let pngs: Vec<&(String, Vec<u8>)> = f.iter().filter(|(p, _)| p.ends_with(".png")).collect();
let mut pngs = pngs;
pngs.sort_by(|a, b| a.0.cmp(&b.0));
image::load_from_memory(&pngs[i].1).expect("png").to_rgba8()
}
fn row(img: &image::RgbaImage, y: u32) -> Vec<u8> {
(0..img.width()).flat_map(|x| img.get_pixel(x, y).0).collect()
}
#[test]
fn field_render_interleaves_two_times() {
let (p, cid) = project(FrameRate::new(25, 1), |_, _| {});
let om = OutputModule::for_format(OutputFormat::PngSequence);
// Frame 10 at 25 fps with fields: 0.40 s and 0.42 s.
for (fr, upper) in [(FieldRender::UpperFirst, true), (FieldRender::LowerFirst, false)] {
let s = RenderSettings { field_render: fr, ..custom(0.4, 0.44) };
let fields = png(&run(&p, cid, &s, &om, "/f/field_[#####].png"), 0);
// Progressive references at 50 fps: frames 20 (0.40 s) and 21 (0.42 s).
let r = RenderSettings { frame_rate: Some(FrameRate::new(50, 1)), ..custom(0.4, 0.44) };
let refs = run(&p, cid, &r, &om, "/f/ref_[#####].png");
let (a, b) = (png(&refs, 0), png(&refs, 1));
assert_ne!(row(&a, 10), row(&b, 10), "the bar moved between the fields");
for y in 0..H {
let first = (y % 2 == 0) == upper;
assert_eq!(row(&fields, y), row(if first { &a } else { &b }, y), "{fr:?}: line {y}");
}
}
}
#[test]
fn pulldown_follows_the_cadence() {
let ntsc = FrameRate::new(30000, 1001);
let (p, cid) = project(ntsc, |_, _| {});
let om = OutputModule::for_format(OutputFormat::PngSequence);
let five = 5.0 * 1001.0 / 30000.0;
let s = RenderSettings { field_render: FieldRender::UpperFirst, pulldown: Pulldown::Wssww, ..custom(0.0, five) };
let out = run(&p, cid, &s, &om, "/p/pd_[#####].png");
assert_eq!(out.lock().unwrap().len(), 5);
// Film frames at 23.976 fps.
let r = RenderSettings { frame_rate: Some(FrameRate::new(24000, 1001)), ..custom(0.0, 0.2) };
let film = run(&p, cid, &r, &om, "/p/film_[#####].png");
let mut pattern = String::new();
for k in 0..5 {
let (a, b) = Pulldown::Wssww.fields(k as u64).unwrap();
pattern.push(if a == b { 'W' } else { 'S' });
let img = png(&out, k);
let (fa, fb) = (png(&film, a as usize), png(&film, b as usize));
for y in 0..H {
assert_eq!(row(&img, y), row(if y % 2 == 0 { &fa } else { &fb }, y), "frame {k} line {y}");
}
}
assert_eq!(pattern, "WSSWW");
}
#[test]
fn crop_roi_and_resize() {
let (p, cid) = project(FrameRate::new(25, 1), |_, _| {});
let s = custom(0.4, 0.44);
let full = png(&run(&p, cid, &s, &OutputModule::for_format(OutputFormat::PngSequence), "/c/full_[#####].png"), 0);
let mut om = OutputModule::for_format(OutputFormat::PngSequence);
om.crop = Crop { enabled: true, top: 4, left: 10, bottom: 6, right: 2, ..Default::default() };
let c = png(&run(&p, cid, &s, &om, "/c/crop_[#####].png"), 0);
assert_eq!((c.width(), c.height()), (52, 38));
for (x, y) in [(0, 0), (20, 11), (51, 37), (3, 30)] {
assert_eq!(c.get_pixel(x, y), full.get_pixel(x + 10, y + 4), "({x}, {y})");
}
// Region of interest at half resolution.
om.crop = Crop { enabled: true, use_roi: true, roi: Some([16.0, 8.0, 32.0, 32.0]), ..Default::default() };
let half = RenderSettings { resolution: 0.5, ..s.clone() };
let r = png(&run(&p, cid, &half, &om, "/c/roi_[#####].png"), 0);
assert_eq!((r.width(), r.height()), (16, 16));
// Resize (fixed size, low and high quality) after the crop.
om.crop = Crop::default();
for q in [ResizeQuality::Low, ResizeQuality::High] {
om.resize = Resize { enabled: true, width: 32, height: 20, lock_aspect: false, quality: q };
let z = png(&run(&p, cid, &s, &om, "/c/resize_[#####].png"), 0);
assert_eq!((z.width(), z.height()), (32, 20));
// The red block (bottom left) survives the resize.
let px = z.get_pixel(4, 18).0;
assert!(px[0] > 150 && px[1] < 80, "{q:?}: {px:?}");
}
om.resize.lock_aspect = true;
let z = png(&run(&p, cid, &s, &om, "/c/aspect_[#####].png"), 0);
assert_eq!((z.width(), z.height()), (32, 24));
}
#[test]
fn alpha_only_and_premultiplied() {
let (p, cid) = project(FrameRate::new(25, 1), |p, comp| {
// A half-transparent green layer over the right half.
let g = p.add_item("Green", Label::Green, None, ItemKind::Solid(Solid { color: [0.0, 1.0, 0.0], width: 32, height: 48, pixel_aspect: 1.0 }));
let mut l = build::layer(p, comp, "Green", LayerSource::Solid { item: g }, (32, 48), None);
l.props.prop_mut("transform/position").unwrap().value = Value::Vec3([48.0, 24.0, 0.0]);
l.props.prop_mut("transform/opacity").unwrap().value = Value::Scalar(50.0);
comp.layers.insert(0, l);
});
let s = custom(0.0, 0.04);
let mut om = OutputModule::for_format(OutputFormat::PngSequence);
om.channels = Channels::Alpha;
let a = png(&run(&p, cid, &s, &om, "/a/alpha_[#####].png"), 0);
assert_eq!(a.get_pixel(60, 2).0[0], 128, "half-opaque green");
assert_eq!(a.get_pixel(20, 2).0[0], 0, "transparent");
assert_eq!(a.get_pixel(8, 44).0[0], 255, "opaque block");
om.channels = Channels::Rgba;
let straight = png(&run(&p, cid, &s, &om, "/a/s_[#####].png"), 0);
om.alpha_mode = AlphaMode::Premultiplied;
let premul = png(&run(&p, cid, &s, &om, "/a/p_[#####].png"), 0);
assert_eq!(straight.get_pixel(60, 2).0, [0, 255, 0, 128]);
assert_eq!(premul.get_pixel(60, 2).0, [0, 128, 0, 128]);
}
#[test]
fn solo_and_guide_layer_overrides() {
let (p, cid) = project(FrameRate::new(25, 1), |p, comp| {
let g = p.add_item("Guide", Label::Green, None, ItemKind::Solid(Solid { color: [0.0, 0.0, 1.0], width: 8, height: 8, pixel_aspect: 1.0 }));
let mut l = build::layer(p, comp, "Guide", LayerSource::Solid { item: g }, (8, 8), None);
l.props.prop_mut("transform/position").unwrap().value = Value::Vec3([56.0, 44.0, 0.0]);
l.switches.guide = true;
comp.layers.insert(0, l);
// Solo the moving bar.
comp.layers[1].switches.solo = true;
});
let om = OutputModule::for_format(OutputFormat::PngSequence);
let s = custom(0.4, 0.44);
let cur = png(&run(&p, cid, &s, &om, "/s/cur_[#####].png"), 0);
assert_eq!(cur.get_pixel(8, 44).0[0], 0, "solo: the red block is hidden");
assert_eq!(cur.get_pixel(56, 44).0[2], 0, "guide layers off by default");
let off = RenderSettings { solo: CurrentOrOff::AllOff, guide_layers: CurrentOrOff::Current, ..s.clone() };
let all = png(&run(&p, cid, &off, &om, "/s/off_[#####].png"), 0);
assert_eq!(all.get_pixel(8, 44).0[0], 255, "Solo Switches: All Off");
// (with solo off the guide renders too: Guide Layers: Current Settings)
assert_eq!(all.get_pixel(56, 44).0[2], 255);
}
#[test]
fn render_logs() {
let (p, cid) = project(FrameRate::new(25, 1), |_, _| {});
let om = OutputModule::for_format(OutputFormat::PngSequence);
let s = RenderSettings { field_render: FieldRender::UpperFirst, name: "DV-ish".into(), ..custom(0.0, 0.2) };
let log_of = |files: &Files| {
files.lock().unwrap().iter().find(|(p, _)| p.ends_with("_RenderLog.txt")).map(|(p, d)| (p.clone(), String::from_utf8(d.clone()).unwrap()))
};
// Errors Only + success: no log.
let (r, files) = run_with(&p, cid, &s, &om, "/l/a_[#####].png", JobOptions::default(), false);
assert!(r.unwrap().log.is_none());
assert!(log_of(&files).is_none());
// Plus Per Frame Info: settings and one line per frame.
let opts = JobOptions { log: RenderLog::PlusPerFrameInfo, label: "#1 Comp".into(), ..Default::default() };
let (r, files) = run_with(&p, cid, &s, &om, "/l/b_[#####].png", opts, false);
let r = r.unwrap();
let (path, text) = log_of(&files).expect("log");
assert_eq!(r.log.as_deref(), Some(path.as_str()));
assert_eq!(std::path::Path::new(&path), std::path::Path::new("/l/b_RenderLog.txt"));
assert!(text.contains("Item: #1 Comp") && text.contains("Result: Done (5 frames"), "{text}");
assert!(text.contains("Field Render: Upper Field First") && text.contains("Template: DV-ish") && text.contains("Format: PNG Sequence"), "{text}");
assert_eq!(text.lines().filter(|l| l.contains("rendered in")).count(), 5, "{text}");
assert!(text.contains("Frame 0 (0.000 s): rendered in") && text.contains("Frame 4 (0.160 s)"), "{text}");
// Plus Settings: no per-frame lines.
let opts = JobOptions { log: RenderLog::PlusSettings, ..Default::default() };
let (_, files) = run_with(&p, cid, &s, &om, "/l/c.gif", opts, false);
let (_, text) = log_of(&files).expect("log");
assert!(text.contains("Render Settings:") && !text.contains("rendered in"), "{text}");
// Errors Only + failure (the composition is gone): the error is logged.
let gif = OutputModule::for_format(OutputFormat::Gif);
let (r, files) = run_with(&p, ItemId(424_242), &s, &gif, "/l/d.gif", JobOptions::default(), false);
assert!(r.is_err());
let (path, text) = log_of(&files).expect("error log");
assert_eq!(std::path::Path::new(&path), std::path::Path::new("/l/d_RenderLog.txt"));
assert!(text.contains("Result: Failed") && text.contains("Error: composition not found") && !text.contains("Render Settings:"), "{text}");
// A user stop is not an error: no log.
let (r, files) = run_with(&p, cid, &s, &gif, "/l/e.gif", JobOptions::default(), true);
assert!(r.is_err());
assert!(log_of(&files).is_none());
}
/// The primary folder holds `limit` bytes; overflow folders are unlimited.
struct Quota {
limit: u64,
used: Mutex<u64>,
}
impl StorageQuota for Quota {
fn has_room(&self, path: &str, bytes: u64) -> bool {
if !std::path::Path::new(path).starts_with("/primary") {
return true;
}
let mut u = self.used.lock().unwrap();
if *u + bytes > self.limit {
return false;
}
*u += bytes;
true
}
}
#[test]
fn storage_overflow_moves_files_to_the_overflow_folder() {
let (p, cid) = project(FrameRate::new(25, 1), |_, _| {});
let om = OutputModule::for_format(OutputFormat::PngSequence);
let s = RenderSettings { skip_existing: false, ..custom(0.0, 0.4) };
let q = Quota { limit: 1, used: Mutex::new(0) };
let opts = JobOptions { storage: Some(&q), overflow: vec!["/overflow".into()], log: RenderLog::PlusSettings, ..Default::default() };
let (r, files) = run_with(&p, cid, &s, &om, "/primary/seq_[#####].png", opts, false);
let r = r.unwrap();
assert_eq!(r.overflow.len(), 10, "{:?}", r.overflow);
let f = files.lock().unwrap();
assert_eq!(
f.iter()
.filter(|(p, _)| std::path::Path::new(p).parent() == Some(std::path::Path::new("/overflow"))
&& std::path::Path::new(p).file_name().is_some_and(|n| n.to_string_lossy().starts_with("seq_"))
&& p.ends_with(".png"))
.count(),
10
);
let log = f.iter().find(|(p, _)| p.ends_with("_RenderLog.txt")).map(|(_, d)| String::from_utf8_lossy(d).to_string()).unwrap();
assert!(log.contains("Storage overflow: 10 file(s)"), "{log}");
drop(f);
// Use Storage Overflow off: everything stays in place.
let off = RenderSettings { storage_overflow: false, ..s };
let q = Quota { limit: 1, used: Mutex::new(0) };
let opts = JobOptions { storage: Some(&q), overflow: vec!["/overflow".into()], ..Default::default() };
let (r, files) = run_with(&p, cid, &off, &om, "/primary/seq_[#####].png", opts, false);
assert!(r.unwrap().overflow.is_empty());
assert!(files.lock().unwrap().iter().all(|(p, _)| std::path::Path::new(p).starts_with("/primary")));
}
#[test]
fn wav_and_aiff_channels_and_formats() {
let (p, cid) = project(FrameRate::new(25, 1), |_, _| {});
let s = custom(0.0, 0.5);
let mut om = OutputModule::for_format(OutputFormat::Wav);
om.audio_channels = 1;
om.audio_format = AudioFormat::S24;
om.audio_sample_rate = 44_100;
let files = run(&p, cid, &s, &om, "/w/a.wav");
let d = files.lock().unwrap()[0].1.clone();
assert_eq!(&d[..4], b"RIFF");
let u16_at = |o: usize| u16::from_le_bytes([d[o], d[o + 1]]);
let u32_at = |o: usize| u32::from_le_bytes([d[o], d[o + 1], d[o + 2], d[o + 3]]);
assert_eq!((u16_at(20), u16_at(22), u32_at(24), u16_at(32), u16_at(34)), (1, 1, 44_100, 3, 24));
assert_eq!(u32_at(40), 22_050 * 3, "half a second of mono 24-bit");
om.audio_channels = 2;
om.audio_format = AudioFormat::F32;
let files = run(&p, cid, &s, &om, "/w/b.wav");
let d = files.lock().unwrap()[0].1.clone();
let u16_at = |o: usize| u16::from_le_bytes([d[o], d[o + 1]]);
assert_eq!((u16_at(20), u16_at(22), u16_at(32), u16_at(34)), (3, 2, 8, 32), "IEEE float stereo");
// AIFF has no float: 24-bit.
let mut aiff = OutputModule::for_format(OutputFormat::Aiff);
aiff.audio_format = AudioFormat::F32;
let files = run(&p, cid, &s, &aiff, "/w/c.aif");
let d = files.lock().unwrap()[0].1.clone();
assert_eq!(&d[8..12], b"AIFF");
assert_eq!(u16::from_be_bytes([d[26], d[27]]), 24, "sample size");
}
#[test]
fn cancelled_export_removes_only_files_it_opened() {
use effectcraft_export::ExportError;
let dir = std::env::temp_dir().join(format!("ec-cancel-owned-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let (p, cid) = project(FrameRate::new(25, 1), |_, _| {});
struct Overflow;
impl StorageQuota for Overflow {
fn has_room(&self, path: &str, _: u64) -> bool {
path.contains("overflow")
}
}
for overflow in [false, true] {
let output_dir = if overflow { dir.join("overflow") } else { dir.clone() };
std::fs::create_dir_all(&output_dir).unwrap();
let settings = RenderSettings { skip_existing: true, storage_overflow: overflow, ..Default::default() };
for format in [OutputFormat::PngSequence, OutputFormat::H264] {
let om = OutputModule::for_format(format);
let path = dir.join(if format.is_sequence() { "clip_[#####].png" } else { "clip.mp4" });
let path = path.to_str().unwrap();
let decoys = ["clip.aep", "clip2.psd", "clip000.mp4", "clip_99999.png", "clip_00000.png", "clip.txt", "clip_RenderLog.txt"];
for name in decoys {
std::fs::write(output_dir.join(name), b"keep").unwrap();
}
// Skip-existing checks the primary destination even when the new frames overflow.
std::fs::write(dir.join("clip_00000.png"), b"keep").unwrap();
let result = export(
&Job {
project: &p,
footage: &NoFootage,
expr: None,
accel: None,
comp: cid,
settings: &settings,
output: &om,
path,
sink: None,
nested_switches: true,
options: JobOptions {
log: RenderLog::PlusPerFrameInfo,
storage: Some(&Overflow),
overflow: vec![output_dir.to_string_lossy().into_owned()],
..Default::default()
},
},
&mut |progress| progress.done == 0,
);
assert!(matches!(result, Err(ExportError::Cancelled)), "{result:?}");
for name in decoys {
assert_eq!(std::fs::read(output_dir.join(name)).unwrap(), b"keep", "{name}");
}
let remaining: Vec<_> = std::fs::read_dir(&output_dir).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect();
assert_eq!(remaining.len(), decoys.len(), "partial files remain: {remaining:?}");
}
}
std::fs::remove_dir_all(dir).unwrap();
}