Files
effectcraft/crates/engine/src/rq_tests.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

165 lines
7.2 KiB
Rust

//! Render Queue commands against mock exporters (real encoding is tested in `effectcraft-export`
//! and end to end in `effectcraft-host`).
use std::sync::{Arc, Mutex};
use effectcraft_project::render_queue::{OutputFormat, RenderQuality, RenderStatus};
use serde_json::json;
use crate::render_queue::CANCELLED;
use crate::{Event, ExportJob, ExportResult, Exporter, Session};
fn demo() -> Session {
let mut s = Session::default();
s.execute("file.openDemoProject", json!({})).unwrap();
s
}
/// Records exports; fails items whose path contains "fail"; honours cancellation.
struct MockExporter {
log: Mutex<Vec<String>>,
}
impl Exporter for MockExporter {
fn formats(&self) -> Vec<OutputFormat> {
OutputFormat::ALL.to_vec()
}
fn export(&self, job: &ExportJob, progress: &mut dyn FnMut(u64, u64) -> bool) -> Result<ExportResult, String> {
self.log.lock().unwrap().push(job.path.to_string());
if job.path.contains("fail") {
return Err("boom".into());
}
let comp = job.project.comp(job.item.comp).ok_or("no comp")?;
let n = job.item.settings.frame_count(comp);
for k in 0..=n {
if !progress(k, n) {
return Err(CANCELLED.into());
}
}
Ok(ExportResult { path: job.path.to_string(), frames: n, ..Default::default() })
}
}
fn rq_session() -> (Session, Arc<MockExporter>) {
let mut s = demo();
let ex = Arc::new(MockExporter { log: Default::default() });
s.exporter = Some(ex.clone());
(s, ex)
}
#[test]
fn add_configure_render() {
let (mut s, ex) = rq_session();
assert!(s.is_enabled("renderQueue.add"));
assert!(!s.is_enabled("renderQueue.render"));
let a = s.execute("renderQueue.add", json!({})).unwrap();
let name = s.project.item(s.active_comp_id().unwrap()).unwrap().name.clone();
assert_eq!(a["index"], 1);
assert_eq!(a["statusLabel"], "Queued");
assert!(a["outputPath"].as_str().unwrap().ends_with(&format!("{name}.mp4")), "{a}");
let id = a["item"].as_u64().unwrap();
// Render Settings / Output Module / Output To.
let r = s
.execute("renderQueue.setRenderSettings", json!({"item": id, "resolution": "half", "timeSpan": "custom", "start": 1.0, "end": 2.0, "frameRate": 10}))
.unwrap();
assert_eq!(r["frames"], 10);
assert_eq!(r["width"], 960);
let r = s.execute("renderQueue.setOutputModule", json!({"item": id, "format": "png", "channels": "rgba"})).unwrap();
assert!(r["outputPath"].as_str().unwrap().ends_with("_[#####].png"), "{r}");
assert!(s.execute("renderQueue.setOutputModule", json!({"item": id, "format": "jpeg", "channels": "rgba"})).is_err());
let r = s.execute("renderQueue.setOutput", json!({"item": id, "path": "/tmp/rq/out.mov"})).unwrap();
assert_eq!(r["output"]["format"], "ProRes", "Output To with .mov switches to ProRes");
assert_eq!(r["outputPath"], "/tmp/rq/out.mov");
// A second item that fails, a third that is unqueued.
let b = s.execute("renderQueue.add", json!({"format": "gif", "output": "/tmp/rq/fail.gif"})).unwrap();
let c = s.execute("renderQueue.add", json!({"output": "/tmp/rq/skip.mp4"})).unwrap();
s.execute("renderQueue.setRender", json!({"item": c["item"], "render": false})).unwrap();
assert_eq!(s.project.render_queue[2].status.label(), "Unqueued");
let r = s.execute("renderQueue.render", json!({})).unwrap();
assert_eq!(r["items"].as_array().unwrap().len(), 2);
assert_eq!(*ex.log.lock().unwrap(), vec!["/tmp/rq/out.mov".to_string(), "/tmp/rq/fail.gif".to_string()]);
let q = &s.project.render_queue;
assert_eq!(q[0].status.label(), "Done");
assert!(q[0].started.is_some() && q[0].render_time.is_some());
assert_eq!(q[0].last_output.as_deref(), Some("/tmp/rq/out.mov"));
assert_eq!(q[1].status, RenderStatus::Failed("boom".into()));
assert_eq!(q[2].status.label(), "Unqueued");
assert!(s.drain_events().iter().any(|e| matches!(e, Event::Toast { error: true, .. })));
// Reorder, duplicate, remove, undo.
s.execute("renderQueue.move", json!({"item": b["item"], "to": 1})).unwrap();
assert_eq!(s.project.render_queue[0].id, b["item"].as_u64().unwrap());
let d = s.execute("renderQueue.duplicate", json!({"index": 2})).unwrap();
assert_eq!(s.project.render_queue.len(), 4);
assert_eq!(s.project.render_queue[2].id, d["item"].as_u64().unwrap());
assert_eq!(s.project.render_queue[2].status.label(), "Queued");
s.execute("renderQueue.remove", json!({"index": 3})).unwrap();
assert_eq!(s.project.render_queue.len(), 3);
s.undo();
assert_eq!(s.project.render_queue.len(), 4);
}
#[test]
fn persists_in_project_file() {
let (mut s, _) = rq_session();
s.execute("renderQueue.add", json!({"format": "tiff", "output": "[compName]/[compName]_[####].tif", "quality": "draft"})).unwrap();
let json = s.project.to_json();
let p = effectcraft_project::Project::from_json(&json).unwrap();
assert_eq!(p.render_queue, s.project.render_queue);
assert_eq!(p.render_queue[0].settings.quality, RenderQuality::Draft);
// Projects without a queue still load.
let old = json.replace("\"render_queue\"", "\"_ignored\"");
assert!(effectcraft_project::Project::from_json(&old).unwrap().render_queue.is_empty());
}
#[test]
fn background_render_and_stop() {
struct Slow;
impl Exporter for Slow {
fn formats(&self) -> Vec<OutputFormat> {
OutputFormat::ALL.to_vec()
}
fn export(&self, _: &ExportJob, progress: &mut dyn FnMut(u64, u64) -> bool) -> Result<ExportResult, String> {
for k in 0..10_000 {
std::thread::sleep(std::time::Duration::from_millis(2));
if !progress(k, 10_000) {
return Err(CANCELLED.into());
}
}
Ok(Default::default())
}
}
let mut s = demo();
s.exporter = Some(Arc::new(Slow));
s.execute("renderQueue.add", json!({"output": "/tmp/a.mp4"})).unwrap();
s.execute("renderQueue.add", json!({"output": "/tmp/b.mp4"})).unwrap();
s.execute("renderQueue.render", json!({"wait": false})).unwrap();
assert!(s.is_rendering());
assert!(!s.is_enabled("renderQueue.add"), "queue is locked while rendering");
let t0 = std::time::Instant::now();
while s.render_progress().is_some_and(|p| p.done < 3) {
assert!(t0.elapsed().as_secs() < 20);
std::thread::sleep(std::time::Duration::from_millis(5));
}
s.poll_render();
assert_eq!(s.project.render_queue[0].status.label(), "Rendering");
s.execute("renderQueue.stop", json!({})).unwrap();
while s.is_rendering() {
assert!(t0.elapsed().as_secs() < 20);
std::thread::sleep(std::time::Duration::from_millis(5));
}
s.poll_render();
assert!(s.render_job.is_none());
assert_eq!(s.project.render_queue[0].status.label(), "User Stopped");
assert_eq!(s.project.render_queue[1].status.label(), "User Stopped");
}
#[test]
fn without_exporter() {
let mut s = demo();
s.execute("renderQueue.add", json!({})).unwrap();
assert!(!s.is_enabled("renderQueue.render"));
let f = s.execute("renderQueue.formats", json!({})).unwrap();
assert_eq!(f["formats"].as_array().unwrap().len(), 7);
assert_eq!(f["formats"][0]["available"], false);
}