mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-25 10:36:56 -06:00
- 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>
165 lines
7.2 KiB
Rust
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);
|
|
}
|