Files
effectcraft/crates/engine/src/rq_tests.rs
T

340 lines
17 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.
//! 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": crate::tests::stubs::tmp_out("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::PathBuf::from(crate::tests::stubs::tmp_out("rq/out.mov")));
// A second item that fails, a third that is unqueued.
let b = s.execute("renderQueue.add", json!({"format": "gif", "output": crate::tests::stubs::tmp_out("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::PathBuf::from(crate::tests::stubs::tmp_out("rq/out.mov")), std::path::PathBuf::from(crate::tests::stubs::tmp_out("rq/fail.gif"))]
);
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::PathBuf::from(crate::tests::stubs::tmp_out("rq/out.mov")));
assert_eq!(q[1].status, RenderStatus::Failed("boom".into()));
assert_eq!(q[2].status.label(), "Unqueued");
let events = s.drain_events();
let toast = events.iter().find_map(|e| if let Event::Toast { message, error: true } = e { Some(message.as_str()) } else { None });
assert_eq!(toast, Some("Render finished with 1 failed item(s): boom"), "the toast says why (#568)");
// 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 targa_sequence_output_module() {
let (mut s, _) = rq_session();
let a = s.execute("renderQueue.add", json!({"format": "tga", "output": "/out/[compName]_[#####].tga", "channels": "rgba"})).unwrap();
let om = &s.project.render_queue[0].output;
assert_eq!((format!("{:?}", om.format), om.channels.label()), ("TargaSequence".to_string(), "RGB + Alpha"));
assert!(a["outputPath"].as_str().unwrap().ends_with("_[#####].tga"), "{a}");
// Uncompressed: an 18-byte header and 4 bytes a pixel with alpha, 3 without.
assert_eq!(crate::render_queue::bytes_per_frame(om, 7, 4, 10.0), 18.0 + 7.0 * 4.0 * 4.0);
s.execute("renderQueue.setOutputModule", json!({"index": 1, "channels": "rgb"})).unwrap();
assert_eq!(crate::render_queue::bytes_per_frame(&s.project.render_queue[0].output, 7, 4, 10.0), 18.0 + 7.0 * 4.0 * 3.0);
let f = s.execute("renderQueue.formats", json!({})).unwrap();
let tga = f["formats"].as_array().unwrap().iter().find(|f| f["id"] == "TargaSequence").cloned();
assert_eq!(
tga,
Some(json!({"id": "TargaSequence", "label": "Targa Sequence", "extension": "tga", "sequence": true, "alpha": true, "audio": false, "available": true}))
);
}
#[test]
fn failure_toast_names_the_first_reason() {
struct Failing;
impl Exporter for Failing {
fn formats(&self) -> Vec<OutputFormat> {
OutputFormat::ALL.to_vec()
}
fn export(&self, job: &ExportJob, _: &mut dyn FnMut(u64, u64) -> bool) -> Result<ExportResult, String> {
let name = std::path::Path::new(job.path).file_name().unwrap().to_string_lossy().into_owned();
Err(format!("cannot write {name}"))
}
}
let mut s = demo();
s.exporter = Some(Arc::new(Failing));
s.execute("renderQueue.add", json!({"format": "gif", "output": crate::tests::stubs::tmp_out("rq/first.gif")})).unwrap();
s.execute("renderQueue.add", json!({"format": "gif", "output": crate::tests::stubs::tmp_out("rq/second.gif")})).unwrap();
s.execute("renderQueue.render", json!({})).unwrap();
let events = s.drain_events();
let toast = events.iter().find_map(|e| if let Event::Toast { message, error: true } = e { Some(message.as_str()) } else { None });
assert_eq!(toast, Some("Render finished with 2 failed item(s), first: cannot write first.gif"));
}
#[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(), 14);
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");
}
#[test]
fn a_queue_run_keeps_its_jobs_warm() {
// #3335: every job started with a fresh layer cache and trimmed the GPU's texture pool when
// it ended, so each item's first frames were cold. A run's jobs share its caches; the pool is
// trimmed once, when the run ends; an item's output modules share one compositor choice.
use std::sync::atomic::{AtomicUsize, Ordering};
struct Recorder(Mutex<Vec<(Option<usize>, Option<usize>)>>);
impl Exporter for Recorder {
fn formats(&self) -> Vec<OutputFormat> {
OutputFormat::ALL.to_vec()
}
fn export(&self, job: &ExportJob, _: &mut dyn FnMut(u64, u64) -> bool) -> Result<ExportResult, String> {
let caches = job.caches.map(|c| c as *const _ as usize);
let slot = job.renderer.map(|r| {
// The first module chooses; the second sees its choice.
r.lock().unwrap().get_or_insert(effectcraft_render::Backend::Cpu);
r as *const _ as usize
});
self.0.lock().unwrap().push((caches, slot));
Ok(ExportResult { path: job.path.to_string(), ..Default::default() })
}
}
#[derive(Default)]
struct Ends(AtomicUsize);
impl effectcraft_render::Accelerator for Ends {
fn name(&self) -> String {
"ends".into()
}
fn comp_frame(&self, _: &effectcraft_render::Renderer, _: effectcraft_project::ItemId, _: effectcraft_time::Tick) -> Option<effectcraft_raster::Image> {
None
}
fn supports_effect(&self, _: &str) -> bool {
false
}
fn effects(&self, _: &[effectcraft_render::FxStep], _: &effectcraft_effects::Buf, _: Option<f32>) -> Option<effectcraft_effects::Buf> {
None
}
fn render_end(&self) {
self.0.fetch_add(1, Ordering::SeqCst);
}
}
let mut s = demo();
let ex = Arc::new(Recorder(Mutex::new(vec![])));
s.exporter = Some(ex.clone());
let ends = Arc::new(Ends::default());
s.accel = Some(ends.clone());
s.execute("renderQueue.add", json!({"output": crate::tests::stubs::tmp_out("rq/warm_a.mp4")})).unwrap();
s.execute("render.addOutputModule", json!({"format": "png"})).unwrap();
s.execute("renderQueue.add", json!({"output": crate::tests::stubs::tmp_out("rq/warm_b.mp4")})).unwrap();
s.execute("renderQueue.render", json!({})).unwrap();
let jobs = ex.0.lock().unwrap().clone();
assert_eq!(jobs.len(), 3, "{jobs:?}");
assert!(jobs.iter().all(|(c, r)| c.is_some() && r.is_some()), "{jobs:?}");
assert!(jobs.iter().all(|(c, _)| *c == jobs[0].0), "every job of the run shares its caches");
assert_eq!(jobs[0].1, jobs[1].1, "an item's output modules share one choice");
assert_eq!(ends.0.load(Ordering::SeqCst), 1, "the pool is trimmed once, after the run");
}
#[test]
fn the_gpu_frames_setting_reaches_every_render_queue_job() {
// #3335: Settings ▸ Memory & CPU ▸ GPU Frames Rendered at Once is Auto (`None`) or 1–3.
struct Seen(Mutex<Vec<Option<usize>>>);
impl Exporter for Seen {
fn formats(&self) -> Vec<OutputFormat> {
OutputFormat::ALL.to_vec()
}
fn export(&self, job: &ExportJob, _: &mut dyn FnMut(u64, u64) -> bool) -> Result<ExportResult, String> {
self.0.lock().unwrap().push(job.gpu_frames);
Ok(ExportResult { path: job.path.to_string(), ..Default::default() })
}
}
let mut s = demo();
let ex = Arc::new(Seen(Mutex::new(vec![])));
s.exporter = Some(ex.clone());
assert_eq!(s.prefs.memory.gpu_frames_in_flight, "auto");
let pages = crate::settings::prefs::pages_json();
assert!(pages.to_string().contains("memory.gpuFramesInFlight"), "the Memory & CPU page lists it");
for (value, want) in [("auto", None), ("1", Some(1)), ("2", Some(2)), ("3", Some(3))] {
s.execute("prefs.set", json!({"key": "memory.gpuFramesInFlight", "value": value})).unwrap();
assert_eq!(s.prefs.memory.gpu_frames(), want, "{value}");
s.execute("renderQueue.add", json!({"output": crate::tests::stubs::tmp_out(&format!("rq/frames_{value}.mp4"))})).unwrap();
s.execute("renderQueue.render", json!({})).unwrap();
assert_eq!(ex.0.lock().unwrap().last().copied(), Some(want), "{value}");
}
// A hand-edited settings file's nonsense is Auto.
s.prefs.memory.gpu_frames_in_flight = "9".into();
assert_eq!(s.prefs.memory.gpu_frames(), None);
}