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>
This commit is contained in:
Brandon Thomas
2026-10-01 22:02:06 -04:00
co-authored by Claude Opus 5.5
parent dee16181fe
commit 7f9c3a85c0
26 changed files with 3629 additions and 37 deletions
+121 -3
View File
@@ -2,27 +2,141 @@
//!
//! ```text
//! effectcraft-cli frame [project.ecproj | --demo] [--comp NAME] [--time S | --frame N] [--scale K] --out out.png
//! effectcraft-cli render [project.ecproj | --demo] [--comp NAME] --out FILE [--format F] [--start S] [--end S]
//! [--work-area] [--fps N] [--resolution full|half|third|quarter|K] [--quality best|draft] [--channels rgb|rgba]
//! [--jpeg-quality N] [--bitrate KBPS] [--prores proxy|lt|standard|hq|4444|4444xq] [--audio auto|on|off]
//! effectcraft-cli render project.ecproj --queue (renders the project's Render Queue)
//! effectcraft-cli run [project.ecproj | --demo] '<command-id>' '<json params>' … (prints results)
//! effectcraft-cli commands (lists commands)
//! ```
//!
//! `render` formats: h264 (.mp4), prores (.mov), png / jpeg / tiff / exr sequences (`name_[#####].png`), gif.
//! The format defaults to the `--out` extension; the time span to the whole comp.
use effectcraft_engine::Session;
use effectcraft_engine::project::render_queue::RenderStatus;
use effectcraft_render::RenderOpts;
use effectcraft_time::Tick;
use serde_json::{Value, json};
fn usage() -> ! {
eprintln!("usage: effectcraft-cli <frame|run|commands> [args] (see --help in source docs)");
eprintln!(
"usage: effectcraft-cli <frame|render|run|commands> [args]
frame [project.ecproj|--demo] [--comp NAME] [--time S|--frame N] [--scale K] --out out.png
render [project.ecproj|--demo] [--comp NAME] --out FILE [--format h264|prores|png|jpeg|tiff|exr|gif]
[--start S] [--end S] [--work-area] [--fps N] [--resolution full|half|third|quarter|K]
[--quality best|draft] [--channels rgb|rgba] [--jpeg-quality N] [--bitrate KBPS]
[--prores proxy|lt|standard|hq|4444|4444xq] [--audio auto|on|off]
render project.ecproj --queue (render the project's Render Queue)
run [project.ecproj|--demo] '<command-id>' '<json params>' …
commands"
);
std::process::exit(2)
}
fn fail(msg: impl std::fmt::Display) -> ! {
eprintln!("effectcraft: {msg}");
std::process::exit(1)
}
/// Queue `--comp` (or the active comp) with the given settings unless `--queue`, then render the
/// queue with a progress line on stderr. Exit status 1 if any item fails.
fn render(s: &mut Session, rest: &[String]) {
if !rest.iter().any(|a| a == "--queue") {
let out = opt(rest, "--out").unwrap_or_else(|| fail("render: --out FILE is required (or --queue)"));
let mut p = json!({"output": out});
if let Some(c) = opt(rest, "--comp") {
p["comp"] = json!(c);
}
let ext = std::path::Path::new(out).extension().map(|e| e.to_string_lossy().to_string());
if let Some(f) = opt(rest, "--format").map(str::to_string).or(ext) {
p["format"] = json!(f);
}
let num = |k: &str| opt(rest, k).map(|v| v.parse::<f64>().unwrap_or_else(|_| fail(format!("{k}: not a number: {v}"))));
match (num("--start"), num("--end")) {
(None, None) => p["timeSpan"] = json!(if rest.iter().any(|a| a == "--work-area") { "workArea" } else { "comp" }),
(a, b) => {
p["start"] = json!(a.unwrap_or(0.0));
if let Some(b) = b {
p["end"] = json!(b);
}
}
}
if let Some(v) = num("--fps") {
p["frameRate"] = json!(v);
}
if let Some(r) = opt(rest, "--resolution").or(opt(rest, "--scale")) {
p["resolution"] = r.parse::<f64>().map(|v| json!(v)).unwrap_or(json!(r));
}
for (flag, key) in [("--quality", "quality"), ("--channels", "channels"), ("--prores", "proresProfile"), ("--audio", "audio")] {
if let Some(v) = opt(rest, flag) {
p[key] = json!(v);
}
}
if let Some(v) = num("--jpeg-quality") {
p["quality"] = json!(v);
}
if let Some(v) = num("--bitrate") {
p["bitrate"] = json!(v);
}
// Render exactly this item: unqueue whatever the project's queue already holds.
for k in 0..s.project.render_queue.len() {
let _ = s.execute("renderQueue.setRender", json!({"index": k + 1, "render": false}));
}
let r = s.execute("renderQueue.add", p).unwrap_or_else(|e| fail(e));
eprintln!(
"{} → {} ({}×{}, {} frames, {})",
r["compName"].as_str().unwrap_or("?"),
r["outputPath"].as_str().unwrap_or("?"),
r["width"],
r["height"],
r["frames"],
r["outputModuleSummary"].as_str().unwrap_or("")
);
}
s.execute("renderQueue.render", json!({"wait": false})).unwrap_or_else(|e| fail(e));
let tty = std::io::IsTerminal::is_terminal(&std::io::stderr());
while s.is_rendering() {
std::thread::sleep(std::time::Duration::from_millis(100));
if tty && let Some(p) = s.render_progress() {
let left = p.remaining.map(|r| format!(", ~{r:.1}s left")).unwrap_or_default();
eprint!("\r [{}/{}] frame {}/{} {:.1}s{left}\x1b[K", (p.items_done + 1).min(p.items_total), p.items_total, p.done, p.total, p.item_elapsed);
}
s.poll_render();
}
s.poll_render();
if tty {
eprintln!();
}
let mut failed = false;
for it in s.project.render_queue.iter().filter(|i| i.render && i.started.is_some()) {
let name = s.project.item(it.comp).map(|i| i.name.as_str()).unwrap_or("?");
match &it.status {
RenderStatus::Done => eprintln!("done: {name} → {} in {:.2}s", it.last_output.as_deref().unwrap_or("?"), it.render_time.unwrap_or(0.0)),
RenderStatus::Failed(e) => {
failed = true;
eprintln!("failed: {name}: {e}");
}
other => {
failed = true;
eprintln!("{name}: {}", other.label());
}
}
}
if failed {
std::process::exit(1);
}
}
fn open(s: &mut Session, args: &[String]) {
if let Some(p) = args.iter().find(|a| a.ends_with(".ecproj")) {
if let Err(e) = s.execute("file.open", json!({"path": p})) {
eprintln!("effectcraft: {e}");
std::process::exit(1);
}
} else if args.iter().any(|a| a == "--demo") || true {
} else {
// `--demo` (or no project): the built-in demo project.
let _ = s.execute("file.openDemoProject", json!({}));
}
}
@@ -35,8 +149,12 @@ fn main() {
let args: Vec<String> = std::env::args().skip(1).collect();
let Some(cmd) = args.first().cloned() else { usage() };
let rest = &args[1..];
let mut s = Session::default();
let mut s = effectcraft_host::session();
match cmd.as_str() {
"render" => {
open(&mut s, rest);
render(&mut s, rest);
}
"frame" => {
open(&mut s, rest);
if let Some(c) = opt(rest, "--comp") {