effectcraft/crates/engine/src/rq_tests.rs

208 lines
9.7 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.
pub(crate) struct MockExporter {
pub(crate) 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!(std::path::Path::new(r["outputPath"].as_str().unwrap()), std::path::absolute("/tmp/rq/out.mov").unwrap());
// 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().iter().map(std::path::PathBuf::from).collect::<Vec<_>>(),
[std::path::absolute("/tmp/rq/out.mov").unwrap(), std::path::absolute("/tmp/rq/fail.gif").unwrap()]
);
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!(std::path::Path::new(q[0].last_output.as_deref().unwrap()), std::path::absolute("/tmp/rq/out.mov").unwrap());
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(), 12);
assert_eq!(f["formats"][0]["available"], false);
}
#[test]
fn queue_add_accepts_render_settings_templates_without_output_module_namesakes() {
let mut s = Session::default();
s.execute("comp.new", json!({"name":"C","width":64,"height":64,"duration":1})).unwrap();
let all = s.execute("renderQueue.templates", json!({})).unwrap();
let names = all["renderSettings"].as_array().unwrap();
assert!(!names.is_empty());
for row in names {
let name = row["name"].as_str().unwrap();
let expected = s.project.render_templates.render_settings(name).unwrap();
s.execute("renderQueue.add", json!({"comp":"C","template":name})).unwrap();
assert_eq!(s.project.render_queue.last().unwrap().settings, expected);
}
let count = s.project.render_queue.len();
assert!(s.execute("renderQueue.add", json!({"comp":"C","template":"Unknown Settings"})).is_err());
assert_eq!(s.project.render_queue.len(), count);
}
#[test]
fn locked_resize_uses_height_for_height_only_output_module_updates() {
for change in [json!({"resizeHeight": 32}), json!({"resize": {"height": 32, "lockAspect": true}})] {
let mut s = Session::default();
s.execute("comp.new", json!({"name":"Square","width":64,"height":64,"frameRate":10,"duration":1})).unwrap();
s.execute("renderQueue.add", json!({})).unwrap();
s.execute("renderQueue.setOutputModule", json!({"resize": {"width": 64, "lockAspect": true}})).unwrap();
s.execute("renderQueue.setOutputModule", change).unwrap();
let item = &s.project.render_queue[0];
let comp = s.project.comp(item.comp).unwrap();
assert_eq!(item.output.output_size(comp, &item.settings), (32, 32));
assert_eq!(item.output.resize.height, 32);
}
let mut s = Session::default();
s.execute("comp.new", json!({"name":"Square","width":64,"height":64,"duration":1})).unwrap();
s.execute("renderQueue.add", json!({})).unwrap();
s.execute("renderQueue.setOutputModule", json!({"resize": {"width": 32, "height": 54, "lockAspect": true}})).unwrap();
let item = &s.project.render_queue[0];
let comp = s.project.comp(item.comp).unwrap();
assert_eq!(item.output.output_size(comp, &item.settings), (32, 32), "explicit width retains precedence");
}