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
parent dee16181fe
commit 7f9c3a85c0
26 changed files with 3629 additions and 37 deletions

33
Cargo.lock generated
View File

@ -1058,6 +1058,7 @@ name = "effectcraft-cli"
version = "0.1.0"
dependencies = [
"effectcraft-engine",
"effectcraft-host",
"effectcraft-render",
"effectcraft-time",
"image",
@ -1104,6 +1105,27 @@ dependencies = [
"thiserror 2.0.21",
]
[[package]]
name = "effectcraft-export"
version = "0.1.0"
dependencies = [
"effectcraft-color",
"effectcraft-media",
"effectcraft-project",
"effectcraft-raster",
"effectcraft-render",
"effectcraft-time",
"filmcraft-aac",
"filmcraft-h264enc",
"filmcraft-isobmff",
"filmcraft-prores",
"gif",
"image",
"log",
"rayon",
"thiserror 2.0.21",
]
[[package]]
name = "effectcraft-expr"
version = "0.1.0"
@ -1132,6 +1154,7 @@ name = "effectcraft-host"
version = "0.1.0"
dependencies = [
"effectcraft-engine",
"effectcraft-export",
"effectcraft-expr",
"effectcraft-media",
"effectcraft-project",
@ -1696,6 +1719,16 @@ dependencies = [
"thiserror 2.0.21",
]
[[package]]
name = "filmcraft-h264enc"
version = "0.1.0"
source = "git+https://github.com/storytold/filmcraft?rev=89cfbf24f698b0f0e538eb7795929f19bbfb1dbb#89cfbf24f698b0f0e538eb7795929f19bbfb1dbb"
dependencies = [
"filmcraft-bitstream",
"rayon",
"thiserror 2.0.21",
]
[[package]]
name = "filmcraft-hevc"
version = "0.1.0"

View File

@ -23,6 +23,7 @@ effectcraft-text = { path = "crates/text" }
effectcraft-effects = { path = "crates/effects" }
effectcraft-render = { path = "crates/render" }
effectcraft-media = { path = "crates/media" }
effectcraft-export = { path = "crates/export" }
effectcraft-expr = { path = "crates/expr" }
effectcraft-engine = { path = "crates/engine" }
effectcraft-host = { path = "crates/host" }
@ -35,6 +36,7 @@ serde = { version = "1", features = ["derive", "rc"] }
serde_json = { version = "1", features = ["float_roundtrip"] }
log = "0.4"
rayon = "1"
gif = "0.14"
proptest = "1"
# scripting (expressions)
@ -48,12 +50,17 @@ harfrust = { version = "0.12", default-features = false, features = ["std"] }
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
# media codecs + containers: FilmCraft's pure-Rust crates, pinned to a revision (plan/adr/0001).
# Only `crates/media` (and later `crates/export`) may depend on these.
# Only `crates/media` and `crates/export` may depend on these.
filmcraft-codecs = { git = "https://github.com/storytold/filmcraft", rev = "89cfbf24f698b0f0e538eb7795929f19bbfb1dbb" }
filmcraft-media = { git = "https://github.com/storytold/filmcraft", rev = "89cfbf24f698b0f0e538eb7795929f19bbfb1dbb" }
filmcraft-frame = { git = "https://github.com/storytold/filmcraft", rev = "89cfbf24f698b0f0e538eb7795929f19bbfb1dbb" }
filmcraft-color = { git = "https://github.com/storytold/filmcraft", rev = "89cfbf24f698b0f0e538eb7795929f19bbfb1dbb" }
filmcraft-time = { git = "https://github.com/storytold/filmcraft", rev = "89cfbf24f698b0f0e538eb7795929f19bbfb1dbb" }
# encoders + muxer (crates/export)
filmcraft-h264enc = { git = "https://github.com/storytold/filmcraft", rev = "89cfbf24f698b0f0e538eb7795929f19bbfb1dbb" }
filmcraft-prores = { git = "https://github.com/storytold/filmcraft", rev = "89cfbf24f698b0f0e538eb7795929f19bbfb1dbb" }
filmcraft-aac = { git = "https://github.com/storytold/filmcraft", rev = "89cfbf24f698b0f0e538eb7795929f19bbfb1dbb" }
filmcraft-isobmff = { git = "https://github.com/storytold/filmcraft", rev = "89cfbf24f698b0f0e538eb7795929f19bbfb1dbb" }
# ui
egui = "0.36"
@ -68,6 +75,10 @@ rfd = "0.17"
# filmcraft-frame = { path = "../filmcraft/crates/frame" }
# filmcraft-color = { path = "../filmcraft/crates/color" }
# filmcraft-time = { path = "../filmcraft/crates/time" }
# filmcraft-h264enc = { path = "../filmcraft/crates/h264enc" }
# filmcraft-prores = { path = "../filmcraft/crates/prores" }
# filmcraft-aac = { path = "../filmcraft/crates/aac" }
# filmcraft-isobmff = { path = "../filmcraft/crates/isobmff" }
[workspace.lints.rust]
unsafe_code = "deny"
@ -101,10 +112,16 @@ opt-level = 3
[profile.dev.package.effectcraft-media]
opt-level = 3
[profile.dev.package.effectcraft-export]
opt-level = 3
# FilmCraft codec hot loops (same settings as FilmCraft's own workspace).
[profile.dev.package.filmcraft-h264]
opt-level = 3
[profile.dev.package.filmcraft-h264enc]
opt-level = 3
[profile.dev.package.filmcraft-hevc]
opt-level = 3

View File

@ -14,6 +14,7 @@ path = "src/main.rs"
[dependencies]
effectcraft-engine = { workspace = true }
effectcraft-host = { workspace = true }
effectcraft-render = { workspace = true }
effectcraft-time = { workspace = true }
serde_json = { workspace = true }

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") {

View File

@ -9,6 +9,7 @@ mod help;
mod layer;
mod prop;
mod query;
mod render_queue;
mod time;
use std::sync::OnceLock;
@ -71,6 +72,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(prop::specs());
v.extend(effect::specs());
v.extend(time::specs());
v.extend(render_queue::specs());
v.extend(help::specs());
v.extend(query::specs());
v
@ -182,6 +184,6 @@ pub(crate) fn time_p(s: &Session, p: &Value, comp: Option<&Comp>) -> Tick {
s.time()
}
pub(crate) fn layer_mut<'a>(p: &'a mut effectcraft_project::Project, cid: ItemId, lid: LayerId) -> Result<&'a mut Layer> {
pub(crate) fn layer_mut(p: &mut effectcraft_project::Project, cid: ItemId, lid: LayerId) -> Result<&mut Layer> {
p.comp_mut(cid).ok_or(EngineError::NoComp)?.layer_mut(lid).ok_or(EngineError::Project(effectcraft_project::ProjectError::NoLayer(lid)))
}

View File

@ -0,0 +1,428 @@
//! Render Queue: Composition ▸ Add to Render Queue, the queue's Render Settings / Output Module /
//! Output To, Render and Stop.
//!
//! Items are addressed by `item` (the stable id from `renderQueue.add` / `renderQueue.list`) or
//! `index` (1-based, the # column).
use effectcraft_project::render_queue::{
AudioOutput, Channels, OutputFormat, OutputModule, ProResProfile, RenderQuality, RenderQueueItem, RenderSettings, RenderStatus, TimeSpan,
};
use effectcraft_time::{FrameRate, Tick};
use serde_json::{Value, json};
use super::{CommandSpec, b_p, bad, f_p, str_p};
use crate::{EngineError, Result, Session, cmd, query};
fn can_add(s: &Session) -> std::result::Result<(), String> {
not_rendering(s)?;
if s.comp_for_queue(&Value::Null).is_some() { Ok(()) } else { Err("select or open a composition".into()) }
}
fn not_rendering(s: &Session) -> std::result::Result<(), String> {
if s.is_rendering() { Err("the render queue is rendering".into()) } else { Ok(()) }
}
fn has_items(s: &Session) -> std::result::Result<(), String> {
not_rendering(s)?;
if s.project.render_queue.is_empty() { Err("the render queue is empty".into()) } else { Ok(()) }
}
fn has_queued(s: &Session) -> std::result::Result<(), String> {
not_rendering(s)?;
if s.exporter.is_none() {
return Err("export is not available in this build".into());
}
if s.project.render_queue.iter().any(RenderQueueItem::is_queued) { Ok(()) } else { Err("nothing is queued".into()) }
}
fn rendering(s: &Session) -> std::result::Result<(), String> {
if s.is_rendering() { Ok(()) } else { Err("not rendering".into()) }
}
/// Index of the item named by `item` (id) or `index` (1-based).
fn item_index(s: &Session, p: &Value, cmd: &str) -> Result<usize> {
let q = &s.project.render_queue;
if let Some(id) = p.get("item").and_then(Value::as_u64) {
return q.iter().position(|i| i.id == id).ok_or_else(|| bad(cmd, format!("no render queue item {id}")));
}
if let Some(n) = p.get("index").and_then(Value::as_u64) {
return (n as usize).checked_sub(1).filter(|i| *i < q.len()).ok_or_else(|| bad(cmd, format!("no render queue item #{n}")));
}
if q.len() == 1 {
return Ok(0);
}
Err(bad(cmd, "pass `item` (id) or `index` (1-based)"))
}
fn time_p(p: &Value, k: &str) -> Option<Tick> {
f_p(p, k).map(Tick::from_seconds_f64)
}
/// Apply Render Settings parameters.
fn apply_settings(rs: &mut RenderSettings, p: &Value, cmd: &str) -> Result<bool> {
let mut any = false;
if let Some(q) = str_p(p, "quality") {
rs.quality = match q.to_ascii_lowercase().as_str() {
"best" => RenderQuality::Best,
"draft" => RenderQuality::Draft,
_ => return Err(bad(cmd, "quality: best|draft")),
};
any = true;
}
match p.get("resolution") {
Some(Value::String(r)) => {
rs.resolution = match r.to_ascii_lowercase().as_str() {
"full" => 1.0,
"half" => 0.5,
"third" => 1.0 / 3.0,
"quarter" => 0.25,
_ => return Err(bad(cmd, "resolution: full|half|third|quarter|<scale>")),
};
any = true;
}
Some(Value::Number(n)) => {
rs.resolution = n.as_f64().unwrap_or(1.0).clamp(0.01, 4.0);
any = true;
}
_ => {}
}
let (start, end) = (time_p(p, "start"), time_p(p, "end"));
if let Some(ts) = str_p(p, "timeSpan") {
rs.time_span = match ts.to_ascii_lowercase().replace([' ', '_', '-'], "").as_str() {
"workarea" | "workareaonly" => TimeSpan::WorkArea,
"comp" | "lengthofcomp" | "full" => TimeSpan::LengthOfComp,
"custom" => TimeSpan::Custom { start: start.unwrap_or(Tick::ZERO), end: end.unwrap_or(Tick::from_seconds_f64(1.0)) },
_ => return Err(bad(cmd, "timeSpan: workArea|comp|custom")),
};
any = true;
} else if start.is_some() || end.is_some() {
let (a, b) = match rs.time_span {
TimeSpan::Custom { start, end } => (start, end),
_ => (Tick::ZERO, Tick(i64::MAX / 4)),
};
rs.time_span = TimeSpan::Custom { start: start.unwrap_or(a), end: end.unwrap_or(b) };
any = true;
}
if let TimeSpan::Custom { start, end } = rs.time_span
&& end <= start
{
return Err(bad(cmd, "end must be after start"));
}
match p.get("frameRate") {
Some(Value::Null) => {
rs.frame_rate = None;
any = true;
}
Some(v) => {
let f = v.as_f64().filter(|f| *f > 0.0 && *f <= 1000.0).ok_or_else(|| bad(cmd, "frameRate: fps > 0 or null (comp rate)"))?;
rs.frame_rate = Some(FrameRate::from_f64(f));
any = true;
}
None => {}
}
if let Some(b) = b_p(p, "motionBlur") {
rs.motion_blur = b;
any = true;
}
if let Some(b) = b_p(p, "skipExisting") {
rs.skip_existing = b;
any = true;
}
Ok(any)
}
/// Apply Output Module parameters.
fn apply_output(om: &mut OutputModule, p: &Value, cmd: &str) -> Result<bool> {
let mut any = false;
if let Some(f) = str_p(p, "format") {
let f = OutputFormat::from_name(f).ok_or_else(|| bad(cmd, "format: h264|prores|png|jpeg|tiff|exr|gif"))?;
om.set_format(f);
any = true;
}
if let Some(c) = str_p(p, "channels") {
om.channels = match c.to_ascii_lowercase().replace([' ', '+'], "").as_str() {
"rgb" => Channels::Rgb,
"rgba" | "rgbalpha" => Channels::Rgba,
_ => return Err(bad(cmd, "channels: rgb|rgba")),
};
if om.channels == Channels::Rgba && !om.format.supports_alpha() {
return Err(bad(cmd, format!("{} has no alpha channel", om.format.label())));
}
any = true;
}
if let Some(q) = f_p(p, "quality") {
om.quality = q.clamp(1.0, 100.0) as u8;
any = true;
}
if let Some(b) = f_p(p, "bitrate") {
om.bitrate_kbps = b.clamp(100.0, 500_000.0) as u32;
any = true;
}
if let Some(pp) = str_p(p, "proresProfile") {
om.prores_profile = ProResProfile::from_name(pp).ok_or_else(|| bad(cmd, "proresProfile: proxy|lt|standard|hq|4444|4444xq"))?;
any = true;
}
if let Some(a) = p.get("audio") {
om.audio = match a {
Value::Bool(true) => AudioOutput::On,
Value::Bool(false) => AudioOutput::Off,
Value::String(s) => match s.to_ascii_lowercase().as_str() {
"auto" => AudioOutput::Auto,
"on" => AudioOutput::On,
"off" => AudioOutput::Off,
_ => return Err(bad(cmd, "audio: auto|on|off")),
},
_ => return Err(bad(cmd, "audio: auto|on|off")),
};
any = true;
}
if let Some(r) = p.get("sampleRate").and_then(Value::as_u64) {
om.audio_sample_rate = (r as u32).clamp(8_000, 192_000);
any = true;
}
if let Some(l) = b_p(p, "loop") {
om.gif_loop = l;
any = true;
}
if let Some(o) = str_p(p, "output").or(str_p(p, "path")) {
om.output = o.to_string();
if p.get("format").is_none()
&& let Some(f) = OutputFormat::from_path(o).filter(|f| *f != om.format && !o.contains("[fileExtension]"))
{
// `out.mov` with an H.264 module → ProRes, like picking a file type in Output To.
let keep = om.output.clone();
om.set_format(f);
om.output = keep;
}
any = true;
}
Ok(any)
}
/// A finished item that is edited goes back to Queued (AE re-queues on change).
fn requeue(it: &mut RenderQueueItem) {
if it.render && !matches!(it.status, RenderStatus::Rendering) {
it.status = RenderStatus::Queued;
}
}
fn item_json(s: &Session, it: &RenderQueueItem, index: usize) -> Value {
let comp = s.project.comp(it.comp);
let name = s.project.item(it.comp).map(|i| i.name.clone());
let (w, h) = comp.map(|c| it.settings.output_size(c)).map(|(w, h)| it.output.format.coded_size(w, h)).unwrap_or((0, 0));
let frames = comp.map(|c| it.settings.frame_count(c)).unwrap_or(0);
let mut v = serde_json::to_value(it).unwrap_or(Value::Null);
if let Some(o) = v.as_object_mut() {
o.insert("index".into(), json!(index + 1));
o.insert("compName".into(), json!(name));
o.insert("statusLabel".into(), json!(it.status.label()));
o.insert("outputPath".into(), json!(s.resolve_output(it)));
o.insert("renderSettingsSummary".into(), json!(it.settings.summary()));
o.insert("outputModuleSummary".into(), json!(it.output.summary()));
o.insert("width".into(), json!(w));
o.insert("height".into(), json!(h));
o.insert("frames".into(), json!(frames));
}
v
}
fn add(s: &mut Session, p: &Value) -> Result<Value> {
let cid = s.comp_for_queue(p).ok_or(EngineError::NoComp)?;
let mut it = RenderQueueItem::new(0, cid);
if let Some(f) = str_p(p, "format") {
let f = OutputFormat::from_name(f).ok_or_else(|| bad("renderQueue.add", "format: h264|prores|png|jpeg|tiff|exr|gif"))?;
it.output = OutputModule::for_format(f);
}
apply_settings(&mut it.settings, p, "renderQueue.add")?;
let mut op = p.clone();
if let Some(o) = op.as_object_mut() {
o.remove("format");
}
apply_output(&mut it.output, &op, "renderQueue.add")?;
let id = s.edit("Add to Render Queue", None, |proj, _| {
it.id = proj.render_queue.iter().map(|i| i.id).max().unwrap_or(0) + 1;
let id = it.id;
proj.render_queue.push(it);
Ok(id)
})?;
let idx = s.project.render_queue.len() - 1;
let mut v = item_json(s, &s.project.render_queue[idx], idx);
v["item"] = json!(id);
Ok(v)
}
fn remove(s: &mut Session, p: &Value) -> Result<Value> {
let i = item_index(s, p, "renderQueue.remove")?;
s.edit("Remove from Render Queue", None, |proj, _| {
proj.render_queue.remove(i);
Ok(())
})?;
Ok(json!({"remaining": s.project.render_queue.len()}))
}
fn set_render(s: &mut Session, p: &Value) -> Result<Value> {
let i = item_index(s, p, "renderQueue.setRender")?;
let on = b_p(p, "render").unwrap_or(!s.project.render_queue[i].render);
s.edit("Render Queue: Render", None, |proj, _| {
let it = &mut proj.render_queue[i];
it.render = on;
it.status = if on { RenderStatus::Queued } else { RenderStatus::Unqueued };
Ok(())
})?;
Ok(json!({"render": on}))
}
fn set_render_settings(s: &mut Session, p: &Value) -> Result<Value> {
let i = item_index(s, p, "renderQueue.setRenderSettings")?;
let mut rs = s.project.render_queue[i].settings.clone();
if !apply_settings(&mut rs, p, "renderQueue.setRenderSettings")? {
return Err(bad("renderQueue.setRenderSettings", "nothing to change"));
}
s.edit("Render Settings", None, |proj, _| {
let it = &mut proj.render_queue[i];
it.settings = rs;
requeue(it);
Ok(())
})?;
Ok(item_json(s, &s.project.render_queue[i], i))
}
fn set_output_module(s: &mut Session, p: &Value) -> Result<Value> {
let i = item_index(s, p, "renderQueue.setOutputModule")?;
let mut om = s.project.render_queue[i].output.clone();
if !apply_output(&mut om, p, "renderQueue.setOutputModule")? {
return Err(bad("renderQueue.setOutputModule", "nothing to change"));
}
s.edit("Output Module Settings", None, |proj, _| {
let it = &mut proj.render_queue[i];
it.output = om;
requeue(it);
Ok(())
})?;
Ok(item_json(s, &s.project.render_queue[i], i))
}
fn set_output(s: &mut Session, p: &Value) -> Result<Value> {
let i = item_index(s, p, "renderQueue.setOutput")?;
let path = str_p(p, "path").or(str_p(p, "output")).ok_or_else(|| bad("renderQueue.setOutput", "pass `path` (file path or template)"))?;
let mut om = s.project.render_queue[i].output.clone();
apply_output(&mut om, &json!({"output": path}), "renderQueue.setOutput")?;
s.edit("Output To", None, |proj, _| {
let it = &mut proj.render_queue[i];
it.output = om;
requeue(it);
Ok(())
})?;
Ok(item_json(s, &s.project.render_queue[i], i))
}
fn move_item(s: &mut Session, p: &Value) -> Result<Value> {
let i = item_index(s, p, "renderQueue.move")?;
let n = s.project.render_queue.len();
let to = p.get("to").and_then(Value::as_u64).ok_or_else(|| bad("renderQueue.move", "pass `to` (1-based position)"))? as usize;
let to = to.clamp(1, n) - 1;
s.edit("Reorder Render Queue", None, |proj, _| {
let it = proj.render_queue.remove(i);
proj.render_queue.insert(to, it);
Ok(())
})?;
Ok(json!({"index": to + 1}))
}
fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
let i = item_index(s, p, "renderQueue.duplicate")?;
let id = s.edit("Duplicate Render Item", None, |proj, _| {
let mut it = proj.render_queue[i].clone();
it.id = proj.render_queue.iter().map(|i| i.id).max().unwrap_or(0) + 1;
it.render = true;
it.status = RenderStatus::Queued;
it.started = None;
it.render_time = None;
it.last_output = None;
let id = it.id;
proj.render_queue.insert(i + 1, it);
Ok(id)
})?;
Ok(json!({"item": id, "index": i + 2}))
}
fn render(s: &mut Session, p: &Value) -> Result<Value> {
let wait = b_p(p, "wait").unwrap_or(true);
let ids = s.start_render(wait).map_err(EngineError::Other)?;
let items: Vec<Value> = s.project.render_queue.iter().enumerate().filter(|(_, i)| ids.contains(&i.id)).map(|(k, i)| item_json(s, i, k)).collect();
Ok(json!({"rendering": s.is_rendering(), "items": items}))
}
fn stop(s: &mut Session, _: &Value) -> Result<Value> {
Ok(json!({"stopped": s.stop_render()}))
}
fn list(s: &mut Session, _: &Value) -> Result<Value> {
s.poll_render();
let items: Vec<Value> = s.project.render_queue.iter().enumerate().map(|(k, i)| item_json(s, i, k)).collect();
Ok(json!({"items": items, "rendering": s.is_rendering(), "progress": s.render_progress()}))
}
fn formats(s: &mut Session, _: &Value) -> Result<Value> {
let avail = s.exporter.as_ref().map(|e| e.formats()).unwrap_or_default();
let v: Vec<Value> = OutputFormat::ALL
.iter()
.map(|f| {
json!({
"id": format!("{f:?}"),
"label": f.label(),
"extension": f.extension(),
"sequence": f.is_sequence(),
"alpha": f.supports_alpha(),
"audio": f.supports_audio(),
"available": avail.contains(f),
})
})
.collect();
Ok(json!({"formats": v, "proresProfiles": ProResProfile::ALL.iter().map(|p| p.label()).collect::<Vec<_>>()}))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"renderQueue.add",
"Add to Render Queue",
["Composition"],
Some("Cmd+M"),
"{comp?: id|name, format?: h264|prores|png|jpeg|tiff|exr|gif, output?: path|template, …renderSettings, …outputModule}",
can_add,
add
),
cmd!("renderQueue.remove", "Remove from Render Queue", [], None, "{item?: id, index?: n}", has_items, remove),
cmd!("renderQueue.setRender", "Render Queue: Render Checkbox", [], None, "{item?|index?, render?: bool (toggles)}", has_items, set_render),
cmd!(
"renderQueue.setRenderSettings",
"Render Settings…",
[],
None,
"{item?|index?, quality?: best|draft, resolution?: full|half|third|quarter|scale, timeSpan?: workArea|comp|custom, start?: s, end?: s, frameRate?: fps|null, motionBlur?: bool, skipExisting?: bool}",
has_items,
set_render_settings
),
cmd!(
"renderQueue.setOutputModule",
"Output Module Settings…",
[],
None,
"{item?|index?, format?, channels?: rgb|rgba, quality?: 1-100, bitrate?: kbps, proresProfile?: proxy|lt|standard|hq|4444|4444xq, audio?: auto|on|off, sampleRate?, loop?: bool, output?}",
has_items,
set_output_module
),
cmd!(
"renderQueue.setOutput",
"Output To…",
[],
None,
"{item?|index?, path: file path or template like [compName].[fileExtension]}",
has_items,
set_output
),
cmd!("renderQueue.move", "Move in Render Queue", [], None, "{item?|index?, to: n (1-based)}", has_items, move_item),
cmd!("renderQueue.duplicate", "Duplicate Render Item", [], None, "{item?|index?}", has_items, duplicate),
cmd!("renderQueue.render", "Render", [], None, "{wait?: bool (default true; the UI renders in the background)}", has_queued, render),
cmd!("renderQueue.stop", "Stop Rendering", [], None, "{}", rendering, stop),
query!("renderQueue.list", "Render Queue Items", "{}", list),
query!("renderQueue.formats", "Output Formats", "{}", formats),
]
}

View File

@ -11,6 +11,7 @@
pub mod commands;
pub mod demo;
pub mod links;
pub mod render_queue;
use std::sync::Arc;
@ -29,6 +30,7 @@ pub use effectcraft_keyframe as keyframe;
pub use effectcraft_project as project;
pub use effectcraft_render as render;
pub use effectcraft_time as time;
pub use render_queue::{ExportJob, ExportResult, Exporter, JobState};
#[derive(Debug, thiserror::Error)]
pub enum EngineError {
@ -132,6 +134,10 @@ pub struct Session {
pub footage: Arc<dyn FootageSource>,
pub expr: Option<Arc<dyn ExprHost>>,
pub importer: Option<Arc<dyn Importer>>,
/// Render Queue encoder (the export layer); `None` = export unavailable.
pub exporter: Option<Arc<dyn Exporter>>,
/// The running (or finished, not yet polled) render.
pub render_job: Option<render_queue::RenderJob>,
pub events: Vec<Event>,
/// Commands executed: (id, params).
pub journal: Vec<(String, Value)>,
@ -150,6 +156,8 @@ impl Default for Session {
footage: Arc::new(NoFootage),
expr: None,
importer: None,
exporter: None,
render_job: None,
events: vec![],
journal: vec![],
}
@ -308,6 +316,13 @@ impl Session {
/// Replace the whole project (open/new), resetting history and state.
pub fn replace_project(&mut self, mut p: Project, path: Option<String>) {
if self.stop_render()
&& let Some(mut job) = self.render_job.take()
{
// Let the worker notice the cancel; its updates refer to the old project.
job.wait();
}
self.render_job = None;
p.fix_next_id();
self.project = Arc::new(p);
self.history = History::default();
@ -322,6 +337,8 @@ impl Session {
}
}
#[cfg(test)]
mod rq_tests;
#[cfg(test)]
mod tests;

View File

@ -0,0 +1,344 @@
//! Render Queue runtime: the [`Exporter`] hook (implemented by `effectcraft-export`, wired by
//! `effectcraft-host`), output path resolution, and the render job that works through the queued
//! items, in the background (desktop UI) or blocking (CLI, agents, tests).
//!
//! The queue itself (items, Render Settings, Output Modules, status) lives in the project
//! (`project.render_queue`) so it is saved with it. A running job owns a snapshot of the project;
//! it reports progress through [`JobShared`], and [`Session::poll_render`] copies item status /
//! start time / render time back into the project.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use effectcraft_project::render_queue::{OutputFormat, RenderQueueItem, RenderStatus, TemplateVars, expand_template};
use effectcraft_project::{ItemId, Project};
use effectcraft_render::{ExprHost, FootageSource};
use serde::Serialize;
use crate::{Event, Session};
/// One export handed to the [`Exporter`].
pub struct ExportJob<'a> {
pub project: &'a Project,
pub footage: &'a dyn FootageSource,
pub expr: Option<&'a dyn ExprHost>,
pub item: &'a RenderQueueItem,
/// Resolved output path (templates expanded except the `#` frame-number run).
pub path: &'a str,
}
/// What an export wrote.
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
pub struct ExportResult {
pub path: String,
pub frames: u64,
pub width: u32,
pub height: u32,
pub bytes: u64,
pub seconds: f64,
pub audio: bool,
}
/// Encodes render queue items (implemented by the export layer).
pub trait Exporter: Send + Sync {
/// Formats this build can write.
fn formats(&self) -> Vec<OutputFormat>;
/// Render and write one item (blocking). `progress(done, total)` returns `false` to cancel;
/// a cancelled export returns `Err("cancelled")`.
fn export(&self, job: &ExportJob, progress: &mut dyn FnMut(u64, u64) -> bool) -> Result<ExportResult, String>;
}
/// The error string an [`Exporter`] returns when cancelled.
pub const CANCELLED: &str = "cancelled";
/// Live progress of a render job (serde for agents / the control channel).
#[derive(Clone, Debug, Default, Serialize)]
pub struct JobState {
/// Item being rendered.
pub current: Option<u64>,
/// Frames done / total of the current item.
pub done: u64,
pub total: u64,
/// Seconds since the job started / since the current item started.
pub elapsed: f64,
pub item_elapsed: f64,
/// Estimated seconds left for the current item.
pub remaining: Option<f64>,
/// Items rendered so far / items in this job.
pub items_done: usize,
pub items_total: usize,
pub finished: bool,
}
/// Per-item status changes from the job thread, applied to the project by `poll_render`.
#[derive(Clone, Debug)]
pub enum ItemUpdate {
Started { id: u64, unix: u64 },
Finished { id: u64, status: RenderStatus, seconds: f64, output: Option<String> },
}
#[derive(Default)]
pub struct JobShared {
pub cancel: AtomicBool,
pub state: Mutex<JobState>,
pub updates: Mutex<Vec<ItemUpdate>>,
}
fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
m.lock().unwrap_or_else(|e| e.into_inner())
}
impl JobShared {
pub fn snapshot(&self) -> JobState {
lock(&self.state).clone()
}
}
/// A running (or finished, not yet polled) render job.
pub struct RenderJob {
pub shared: Arc<JobShared>,
thread: Option<std::thread::JoinHandle<()>>,
}
impl RenderJob {
pub fn is_finished(&self) -> bool {
lock(&self.shared.state).finished
}
pub fn cancel(&self) {
self.shared.cancel.store(true, Ordering::Relaxed);
}
/// Block until the worker thread ends.
pub fn wait(&mut self) {
if let Some(t) = self.thread.take() {
let _ = t.join();
}
}
}
/// Everything the worker needs, detached from the session.
struct Work {
project: Arc<Project>,
footage: Arc<dyn FootageSource>,
expr: Option<Arc<dyn ExprHost>>,
exporter: Arc<dyn Exporter>,
items: Vec<(RenderQueueItem, String)>,
}
fn unix_now() -> u64 {
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
}
fn run_work(w: Work, shared: &JobShared) {
let t0 = std::time::Instant::now();
{
let mut s = lock(&shared.state);
s.items_total = w.items.len();
}
for (k, (item, path)) in w.items.iter().enumerate() {
if shared.cancel.load(Ordering::Relaxed) {
lock(&shared.updates).push(ItemUpdate::Finished { id: item.id, status: RenderStatus::UserStopped, seconds: 0.0, output: None });
continue;
}
let t1 = std::time::Instant::now();
lock(&shared.updates).push(ItemUpdate::Started { id: item.id, unix: unix_now() });
{
let mut s = lock(&shared.state);
s.current = Some(item.id);
s.done = 0;
s.total = 0;
s.items_done = k;
s.remaining = None;
}
let job = ExportJob { project: &w.project, footage: w.footage.as_ref(), expr: w.expr.as_deref(), item, path };
let r = w.exporter.export(&job, &mut |done, total| {
let mut s = lock(&shared.state);
s.done = done;
s.total = total;
s.elapsed = t0.elapsed().as_secs_f64();
s.item_elapsed = t1.elapsed().as_secs_f64();
s.remaining = (done > 0).then(|| s.item_elapsed / done as f64 * total.saturating_sub(done) as f64);
!shared.cancel.load(Ordering::Relaxed)
});
let seconds = t1.elapsed().as_secs_f64();
let up = match r {
Ok(res) => ItemUpdate::Finished { id: item.id, status: RenderStatus::Done, seconds, output: Some(res.path) },
Err(e) if e == CANCELLED => ItemUpdate::Finished { id: item.id, status: RenderStatus::UserStopped, seconds, output: None },
Err(e) => {
log::warn!("render queue: item {} failed: {e}", item.id);
ItemUpdate::Finished { id: item.id, status: RenderStatus::Failed(e), seconds, output: None }
}
};
lock(&shared.updates).push(up);
}
let mut s = lock(&shared.state);
s.items_done = w.items.len();
s.current = None;
s.elapsed = t0.elapsed().as_secs_f64();
s.finished = true;
}
impl Session {
/// The project's name for `[projectName]` (file stem, or "Untitled Project").
pub fn project_name(&self) -> String {
self.path
.as_deref()
.and_then(|p| std::path::Path::new(p).file_stem())
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| "Untitled Project".into())
}
/// The output path of a queue item: template tokens expanded, relative paths resolved against
/// the project's folder (or the working directory). `None` when the comp is gone or the
/// output is empty ("Needs Output").
pub fn resolve_output(&self, item: &RenderQueueItem) -> Option<String> {
let comp = self.project.comp(item.comp)?;
let name = self.project.item(item.comp).map(|i| i.name.clone()).unwrap_or_default();
let tpl = item.output.output.trim();
if tpl.is_empty() {
return None;
}
let (w, h) = item.settings.output_size(comp);
let (w, h) = item.output.format.coded_size(w, h);
let rate = item.settings.rate(comp);
let n = item.settings.frame_count(comp) as i64;
let f0 = item.settings.first_frame(comp);
let pname = self.project_name();
let vars =
TemplateVars { comp_name: &name, project_name: &pname, width: w, height: h, frame_rate: rate.as_f64(), start_frame: f0, end_frame: f0 + n - 1 };
let p = expand_template(tpl, item.output.format, &vars);
let path = std::path::Path::new(&p);
if path.is_absolute() {
return Some(p);
}
let base = self.path.as_deref().and_then(|s| std::path::Path::new(s).parent().map(|d| d.to_path_buf())).filter(|d| !d.as_os_str().is_empty());
let base = base.or_else(|| std::env::current_dir().ok())?;
Some(base.join(path).to_string_lossy().to_string())
}
/// Whether a render job is running.
pub fn is_rendering(&self) -> bool {
self.render_job.as_ref().is_some_and(|j| !j.is_finished())
}
/// Live job progress.
pub fn render_progress(&self) -> Option<JobState> {
self.render_job.as_ref().map(|j| j.shared.snapshot())
}
/// Start rendering every queued item. `wait`: block until done (otherwise a background thread
/// renders; call [`Session::poll_render`] regularly). Returns the ids being rendered.
pub fn start_render(&mut self, wait: bool) -> Result<Vec<u64>, String> {
if self.is_rendering() {
return Err("a render is already in progress".into());
}
self.poll_render();
let exporter = self.exporter.clone().ok_or("export is not available in this build")?;
let formats = exporter.formats();
let mut items = Vec::new();
let mut needs_output = Vec::new();
for it in self.project.render_queue.iter().filter(|i| i.is_queued()) {
match self.resolve_output(it) {
Some(p) if formats.contains(&it.output.format) => items.push((it.clone(), p)),
Some(_) => needs_output.push((it.id, RenderStatus::Failed(format!("{} export is not available", it.output.format.label())))),
None if self.project.comp(it.comp).is_none() => needs_output.push((it.id, RenderStatus::Failed("composition missing".into()))),
None => needs_output.push((it.id, RenderStatus::NeedsOutput)),
}
}
if !needs_output.is_empty() {
let p = Arc::make_mut(&mut self.project);
for (id, st) in needs_output {
if let Some(i) = p.render_queue.iter_mut().find(|i| i.id == id) {
i.status = st;
}
}
self.bump();
}
if items.is_empty() {
return Err("nothing is queued: add a composition (Composition ▸ Add to Render Queue) and tick Render".into());
}
let ids: Vec<u64> = items.iter().map(|(i, _)| i.id).collect();
let work = Work { project: self.project.clone(), footage: self.footage.clone(), expr: self.expr.clone(), exporter, items };
let shared = Arc::new(JobShared::default());
if wait {
run_work(work, &shared);
self.render_job = Some(RenderJob { shared, thread: None });
} else {
let sh = shared.clone();
let thread = std::thread::Builder::new().name("render-queue".into()).spawn(move || run_work(work, &sh)).map_err(|e| e.to_string())?;
self.render_job = Some(RenderJob { shared, thread: Some(thread) });
}
self.poll_render();
Ok(ids)
}
/// Stop the running render (the current item becomes "User Stopped").
pub fn stop_render(&mut self) -> bool {
match &self.render_job {
Some(j) if !j.is_finished() => {
j.cancel();
true
}
_ => false,
}
}
/// Apply job updates to the queue items; drop the job once it finished. Returns whether
/// anything changed. Frontends call this every frame while rendering.
pub fn poll_render(&mut self) -> bool {
let Some(job) = &self.render_job else { return false };
let updates = std::mem::take(&mut *lock(&job.shared.updates));
let finished = job.is_finished();
let changed = !updates.is_empty();
if changed {
let p = Arc::make_mut(&mut self.project);
for u in &updates {
match u {
ItemUpdate::Started { id, unix } => {
if let Some(i) = p.render_queue.iter_mut().find(|i| i.id == *id) {
i.status = RenderStatus::Rendering;
i.started = Some(*unix);
i.render_time = None;
}
}
ItemUpdate::Finished { id, status, seconds, output } => {
if let Some(i) = p.render_queue.iter_mut().find(|i| i.id == *id) {
i.status = status.clone();
i.render_time = Some(*seconds);
if output.is_some() {
i.last_output = output.clone();
}
}
}
}
}
self.bump();
}
if finished {
let mut job = self.render_job.take().expect("job");
if let Some(t) = job.thread.take() {
let _ = t.join();
}
let st = job.shared.snapshot();
let failed = updates.iter().filter(|u| matches!(u, ItemUpdate::Finished { status: RenderStatus::Failed(_), .. })).count();
let stopped = updates.iter().any(|u| matches!(u, ItemUpdate::Finished { status: RenderStatus::UserStopped, .. }));
let msg = if failed > 0 {
format!("Render finished with {failed} failed item(s)")
} else if stopped {
"Render stopped".to_string()
} else {
format!("Rendered {} item(s) in {:.1} s", st.items_total, st.elapsed)
};
self.events.push(Event::Toast { message: msg, error: failed > 0 });
}
changed || finished
}
/// The comp to add: `comp` param, the active comp, or the first comp selected in the Project panel.
pub(crate) fn comp_for_queue(&self, p: &serde_json::Value) -> Option<ItemId> {
if p.get("comp").is_some() {
return crate::commands::comp_id(self, p).ok();
}
let sel = self.state.project_selection.iter().copied().find(|i| self.project.comp(*i).is_some());
self.active_comp_id().or(sel)
}
}

View File

@ -0,0 +1,164 @@
//! 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);
}

29
crates/export/Cargo.toml Normal file
View File

@ -0,0 +1,29 @@
[package]
name = "effectcraft-export"
description = "Render Queue jobs and output modules: H.264 MP4 (+AAC) and ProRes MOV (+PCM) via FilmCraft's pure-Rust encoders, PNG/JPEG/TIFF/EXR sequences, animated GIF"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
[lints]
workspace = true
[dependencies]
effectcraft-time = { workspace = true }
effectcraft-project = { workspace = true }
effectcraft-raster = { workspace = true }
effectcraft-render = { workspace = true }
filmcraft-h264enc = { workspace = true }
filmcraft-prores = { workspace = true }
filmcraft-aac = { workspace = true }
filmcraft-isobmff = { workspace = true }
image = { workspace = true, features = ["png", "jpeg", "tiff", "exr"] }
gif = { workspace = true }
rayon = { workspace = true }
thiserror = { workspace = true }
log = { workspace = true }
[dev-dependencies]
effectcraft-media = { workspace = true }
effectcraft-color = { workspace = true }

362
crates/export/src/encode.rs Normal file
View File

@ -0,0 +1,362 @@
//! Movie export: FilmCraft H.264 → MP4 (+ AAC) and ProRes → MOV (+ PCM), muxed by
//! FilmCraft's ISO BMFF / QuickTime writer.
use std::io::{BufWriter, Write};
use effectcraft_project::Comp;
use effectcraft_project::render_queue::{Channels, OutputFormat, ProResProfile};
use effectcraft_time::{FrameRate, TICKS_PER_SECOND, Tick};
use filmcraft_isobmff::{Brand, FourCc, Mp4Writer, PcmConfig, SampleEntry, TrackConfig, WriteSample, WriterOptions};
use rayon::prelude::*;
use crate::{ExportError, Job, Report, Result, State, batch_size, render_frame, rgba8, wants_audio};
fn enc(e: impl std::fmt::Display) -> ExportError {
ExportError::Encode(e.to_string())
}
fn mux_err(e: impl std::fmt::Display) -> ExportError {
ExportError::Io(e.to_string())
}
struct Packet {
data: Vec<u8>,
key: bool,
/// pts − dts in the track timescale.
cto: i32,
}
trait VideoEncoder {
fn sample_entry(&self) -> SampleEntry;
fn encode(&mut self, rgba: &[u8], index: u64) -> Result<Vec<Packet>>;
fn flush(&mut self) -> Result<Vec<Packet>>;
/// Edit-list media start (B-frame delay) in the track timescale.
fn media_start(&self) -> Option<i64> {
None
}
}
// ---------------------------------------------------------------- H.264
struct H264 {
enc: filmcraft_h264enc::Encoder,
w: u32,
h: u32,
rate: FrameRate,
y: Vec<u8>,
u: Vec<u8>,
v: Vec<u8>,
}
impl H264 {
fn new(w: u32, h: u32, rate: FrameRate, kbps: u32) -> Result<H264> {
let mut cfg = filmcraft_h264enc::EncoderConfig::new(w, h, rate.num as u32, rate.den as u32);
cfg.format = filmcraft_h264enc::PacketFormat::LengthPrefixed;
cfg.aud = false;
cfg.keyint = (rate.as_f64() * 2.0).round().max(1.0) as u32;
let kbps = kbps.max(100);
cfg.rate = filmcraft_h264enc::RateControl::Vbr { target_kbps: kbps, max_kbps: kbps * 3 / 2 };
let enc = filmcraft_h264enc::Encoder::new(cfg).map_err(enc)?;
Ok(H264 { enc, w, h, rate, y: vec![], u: vec![], v: vec![] })
}
fn packets(&self, ps: Vec<filmcraft_h264enc::Packet>) -> Vec<Packet> {
ps.into_iter().map(|p| Packet { data: p.data, key: p.keyframe, cto: (p.pts - p.dts) as i32 }).collect()
}
}
impl VideoEncoder for H264 {
fn sample_entry(&self) -> SampleEntry {
let cfg = filmcraft_isobmff::AvcConfig::parse(&self.enc.avcc()).unwrap_or_else(|_| {
let (sps, pps) = self.enc.sps_pps();
filmcraft_isobmff::AvcConfig::new(vec![sps], vec![pps], 4)
});
SampleEntry::avc(cfg, self.w as u16, self.h as u16)
}
fn encode(&mut self, rgba: &[u8], index: u64) -> Result<Vec<Packet>> {
rgba_to_yuv420(rgba, self.w as usize, self.h as usize, &mut self.y, &mut self.u, &mut self.v);
let cw = (self.w as usize).div_ceil(2);
let frame = filmcraft_h264enc::YuvFrame { y: &self.y, u: &self.u, v: &self.v, y_stride: self.w as usize, uv_stride: cw };
let ps = self.enc.try_encode(&frame, index as i64 * self.rate.den).map_err(enc)?;
Ok(self.packets(ps))
}
fn flush(&mut self) -> Result<Vec<Packet>> {
let ps = self.enc.flush();
Ok(self.packets(ps))
}
fn media_start(&self) -> Option<i64> {
(self.enc.delay() > 0).then_some(self.rate.den)
}
}
/// BT.709 limited-range 8-bit 4:2:0 from RGBA8 (2×2 chroma average; alpha ignored).
pub(crate) fn rgba_to_yuv420(rgba: &[u8], w: usize, h: usize, y: &mut Vec<u8>, u: &mut Vec<u8>, v: &mut Vec<u8>) {
let (cw, ch) = (w.div_ceil(2), h.div_ceil(2));
y.resize(w * h, 0);
u.resize(cw * ch, 0);
v.resize(cw * ch, 0);
y.par_chunks_mut(w * 2).zip(u.par_chunks_mut(cw).zip(v.par_chunks_mut(cw))).enumerate().for_each(|(cy, (yr, (ur, vr)))| {
let rows = yr.len() / w;
let mut acc = vec![(0f32, 0f32, 0f32); cw];
for dy in 0..rows {
let src = &rgba[(cy * 2 + dy) * w * 4..][..w * 4];
for x in 0..w {
let (r, g, b) = (src[x * 4] as f32 / 255.0, src[x * 4 + 1] as f32 / 255.0, src[x * 4 + 2] as f32 / 255.0);
let l = 0.2126 * r + 0.7152 * g + 0.0722 * b;
yr[dy * w + x] = (16.0 + 219.0 * l).round().clamp(1.0, 254.0) as u8;
let a = &mut acc[x / 2];
a.0 += (b - l) / 1.8556;
a.1 += (r - l) / 1.5748;
a.2 += 1.0;
}
}
for (cx, a) in acc.iter().enumerate() {
ur[cx] = (128.0 + 224.0 * a.0 / a.2).round().clamp(1.0, 254.0) as u8;
vr[cx] = (128.0 + 224.0 * a.1 / a.2).round().clamp(1.0, 254.0) as u8;
}
});
}
// ---------------------------------------------------------------- ProRes
struct ProRes {
enc: filmcraft_prores::Encoder,
profile: filmcraft_prores::Profile,
w: u32,
h: u32,
alpha: bool,
}
fn prores_profile(p: ProResProfile) -> filmcraft_prores::Profile {
use filmcraft_prores::Profile;
match p {
ProResProfile::Proxy => Profile::Proxy,
ProResProfile::Lt => Profile::Lt,
ProResProfile::Standard => Profile::Standard,
ProResProfile::Hq => Profile::Hq,
ProResProfile::P4444 => Profile::P4444,
ProResProfile::P4444Xq => Profile::P4444Xq,
}
}
impl ProRes {
fn new(w: u32, h: u32, profile: ProResProfile, channels: Channels) -> ProRes {
// Alpha needs a 4444 flavour.
let profile = if channels == Channels::Rgba && !profile.is_4444() { ProResProfile::P4444 } else { profile };
let profile = prores_profile(profile);
let alpha = channels == Channels::Rgba;
let mut cfg = filmcraft_prores::EncoderConfig::new(profile, w, h);
cfg.encode_alpha = alpha;
ProRes { enc: filmcraft_prores::Encoder::with_config(cfg), profile, w, h, alpha }
}
fn is_444(&self) -> bool {
matches!(self.profile, filmcraft_prores::Profile::P4444 | filmcraft_prores::Profile::P4444Xq)
}
}
impl VideoEncoder for ProRes {
fn sample_entry(&self) -> SampleEntry {
SampleEntry::prores(FourCc(self.profile.fourcc()), self.w as u16, self.h as u16)
}
fn encode(&mut self, rgba: &[u8], _index: u64) -> Result<Vec<Packet>> {
let chroma = if self.is_444() { filmcraft_prores::ChromaFormat::Yuv444 } else { filmcraft_prores::ChromaFormat::Yuv422 };
let mut fr = filmcraft_prores::Frame::new(self.w, self.h, chroma, 10, self.alpha);
rgba_to_yuv_10(rgba, self.w as usize, self.h as usize, self.is_444(), &mut fr.y, &mut fr.cb, &mut fr.cr);
if let Some(a) = fr.alpha.as_mut() {
for (d, s) in a.iter_mut().zip(rgba.chunks_exact(4)) {
*d = ((s[3] as u32 * 1023 + 127) / 255) as u16;
}
}
let data = self.enc.encode(&fr).map_err(enc)?;
Ok(vec![Packet { data, key: true, cto: 0 }])
}
fn flush(&mut self) -> Result<Vec<Packet>> {
Ok(vec![])
}
}
/// BT.709 limited-range 10-bit 4:2:2 (horizontal chroma average) or 4:4:4 from RGBA8.
fn rgba_to_yuv_10(rgba: &[u8], w: usize, h: usize, full_chroma: bool, y: &mut [u16], cb: &mut [u16], cr: &mut [u16]) {
let cw = if full_chroma { w } else { w.div_ceil(2) };
debug_assert!(y.len() >= w * h && rgba.len() >= w * h * 4);
y.par_chunks_mut(w).zip(cb.par_chunks_mut(cw).zip(cr.par_chunks_mut(cw))).enumerate().for_each(|(row, (yr, (cbr, crr)))| {
let src = &rgba[row * w * 4..(row + 1) * w * 4];
let mut us = vec![0f32; w];
let mut vs = vec![0f32; w];
for x in 0..w {
let (r, g, b) = (src[x * 4] as f32 / 255.0, src[x * 4 + 1] as f32 / 255.0, src[x * 4 + 2] as f32 / 255.0);
let l = 0.2126 * r + 0.7152 * g + 0.0722 * b;
yr[x] = (64.0 + 876.0 * l).round().clamp(4.0, 1019.0) as u16;
us[x] = (b - l) / 1.8556;
vs[x] = (r - l) / 1.5748;
}
for cx in 0..cw {
let (u, v) = if full_chroma {
(us[cx], vs[cx])
} else {
let (a, b2) = (cx * 2, (cx * 2 + 1).min(w - 1));
((us[a] + us[b2]) * 0.5, (vs[a] + vs[b2]) * 0.5)
};
cbr[cx] = (512.0 + 896.0 * u).round().clamp(4.0, 1019.0) as u16;
crr[cx] = (512.0 + 896.0 * v).round().clamp(4.0, 1019.0) as u16;
}
});
}
// ---------------------------------------------------------------- audio
enum Audio {
Aac(Box<filmcraft_aac::Encoder>),
Pcm,
}
const AUDIO_CHANNELS: u32 = 2;
fn deinterleave(buf: &[f32]) -> [Vec<f32>; 2] {
let l = buf.chunks_exact(2).map(|p| p[0]).collect();
let r = buf.chunks_exact(2).map(|p| p[1]).collect();
[l, r]
}
// ---------------------------------------------------------------- the movie
type Writer = Mp4Writer<BufWriter<std::fs::File>>;
pub(crate) fn movie(job: &Job, comp: &Comp, w: u32, h: u32, st: &mut State) -> Result<Report> {
let rate = job.settings.rate(comp);
let fmt = job.output.format;
let channels = if fmt == OutputFormat::ProRes { job.output.channels } else { Channels::Rgb };
let mut venc: Box<dyn VideoEncoder> = match fmt {
OutputFormat::H264 => Box::new(H264::new(w, h, rate, job.output.bitrate_kbps)?),
_ => Box::new(ProRes::new(w, h, job.output.prores_profile, channels)),
};
let brand = if fmt == OutputFormat::H264 { Brand::Mp4 } else { Brand::Mov };
let batch = batch_size();
let render = |k: u64| -> Vec<u8> { rgba8(&render_frame(job, comp, k), comp, channels, w, h) };
// Encode the first batch before creating tracks: encoders finalise their config on frame 1.
let first_end = batch.min(st.total);
let first: Vec<Vec<u8>> = (0..first_end).into_par_iter().map(render).collect();
let mut pending = Vec::new();
for (k, px) in first.iter().enumerate() {
pending.extend(venc.encode(px, k as u64)?);
}
drop(first);
st.advance(first_end)?;
let file = std::fs::File::create(job.path).map_err(mux_err)?;
let opts = WriterOptions::new(brand);
let movie_ts = opts.movie_timescale.max(1) as i128;
let mut mux: Writer = Mp4Writer::new(BufWriter::new(file), opts).map_err(mux_err)?;
let mut vcfg = TrackConfig::new(venc.sample_entry(), rate.num as u32);
if let Some(start) = venc.media_start() {
// An explicit edit: the convenience `media_start` edit would subtract the B-frame delay
// from the media duration and drop the last frame.
let frames_ts = st.total as i128 * rate.den as i128;
let seg = (frames_ts * movie_ts + rate.num as i128 / 2) / rate.num as i128;
vcfg.edits = vec![filmcraft_isobmff::Edit { segment_duration: seg as u64, media_time: start, media_rate: 0x10000 }];
}
let vt = mux.add_track(vcfg).map_err(mux_err)?;
let sr = job.output.audio_sample_rate.clamp(8_000, 192_000);
let with_audio = wants_audio(job);
let mut audio = None;
if with_audio {
let (a, entry, start) = if brand == Brand::Mp4 {
let e = filmcraft_aac::Encoder::new(filmcraft_aac::EncoderConfig::cbr(sr, AUDIO_CHANNELS as usize, 320_000)).map_err(enc)?;
let entry = SampleEntry::aac(e.audio_specific_config(), AUDIO_CHANNELS, sr);
let priming = e.priming_samples() as i64;
(Audio::Aac(Box::new(e)), entry, Some(priming))
} else {
let pcm = PcmConfig { bits: 16, float: false, big_endian: false, signed: true, channels: AUDIO_CHANNELS, sample_rate: sr as f64 };
(Audio::Pcm, SampleEntry::pcm(pcm), None)
};
let mut c = TrackConfig::new(entry, sr);
c.media_start = start;
let at = mux.add_track(c).map_err(mux_err)?;
audio = Some((a, at));
}
let (span_start, span_end) = job.settings.span(comp);
let mut cursor = span_start.to_units_floor(sr as i64);
let write_video = |mux: &mut Writer, ps: Vec<Packet>| -> Result<()> {
for p in ps {
mux.write_sample(vt, WriteSample { data: &p.data, duration: rate.den as u32, composition_offset: p.cto, is_sync: p.key }).map_err(mux_err)?;
}
Ok(())
};
let mut write_audio_until = |mux: &mut Writer, until: Tick, cursor: &mut i64| -> Result<()> {
let Some((a, at)) = audio.as_mut() else { return Ok(()) };
let end = until.to_units_floor(sr as i64);
if end <= *cursor {
return Ok(());
}
let n = (end - *cursor) as usize;
let start = Tick(((*cursor as i128 * TICKS_PER_SECOND as i128) / sr as i128) as i64);
let buf = effectcraft_render::audio::mix_comp(job.project, job.footage, job.expr, job.comp, start, n, sr);
*cursor = end;
match a {
Audio::Aac(e) => {
let planar = deinterleave(&buf);
let refs: Vec<&[f32]> = planar.iter().map(Vec::as_slice).collect();
for au in e.encode(&refs) {
mux.write_sample(*at, WriteSample { data: &au, duration: 1024, composition_offset: 0, is_sync: true }).map_err(mux_err)?;
}
}
Audio::Pcm => {
let mut pcm = Vec::with_capacity(n * 4);
for s in buf {
pcm.extend_from_slice(&((s.clamp(-1.0, 1.0) * 32767.0).round() as i16).to_le_bytes());
}
mux.write_sample(*at, WriteSample { data: &pcm, duration: n as u32, composition_offset: 0, is_sync: true }).map_err(mux_err)?;
}
}
Ok(())
};
let total = st.total;
let frame_end_time = |k: u64| -> Tick { if k >= total { span_end } else { job.settings.frame_time(comp, k) } };
write_video(&mut mux, pending)?;
write_audio_until(&mut mux, frame_end_time(first_end), &mut cursor)?;
let mut i = first_end;
while i < total {
let end = (i + batch).min(total);
let frames: Vec<Vec<u8>> = (i..end).into_par_iter().map(render).collect();
for (k, px) in frames.iter().enumerate() {
let ps = venc.encode(px, i + k as u64)?;
write_video(&mut mux, ps)?;
}
write_audio_until(&mut mux, frame_end_time(end), &mut cursor)?;
st.advance(end - i)?;
i = end;
}
write_video(&mut mux, venc.flush()?)?;
write_audio_until(&mut mux, span_end, &mut cursor)?;
if let Some((Audio::Aac(e), at)) = audio.as_mut() {
for au in e.flush() {
mux.write_sample(*at, WriteSample { data: &au, duration: 1024, composition_offset: 0, is_sync: true }).map_err(mux_err)?;
}
}
let mut out = mux.finish().map_err(mux_err)?;
out.flush().map_err(mux_err)?;
drop(out);
let bytes = std::fs::metadata(job.path).map(|m| m.len()).unwrap_or(0);
Ok(Report { path: job.path.to_string(), frames: 0, width: w, height: h, seconds: 0.0, bytes, audio: with_audio })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn yuv_of_primaries() {
let rgba = [255u8, 255, 255, 255, 0, 0, 0, 255, 255, 0, 0, 255, 0, 255, 0, 255];
let (mut y, mut u, mut v) = (vec![], vec![], vec![]);
rgba_to_yuv420(&rgba, 2, 2, &mut y, &mut u, &mut v);
assert_eq!(y[0], 235);
assert_eq!(y[1], 16);
assert_eq!((u.len(), v.len()), (1, 1));
let (mut y10, mut cb, mut cr) = (vec![0u16; 4], vec![0u16; 4], vec![0u16; 4]);
rgba_to_yuv_10(&rgba, 2, 2, true, &mut y10, &mut cb, &mut cr);
assert_eq!(y10[0], 940);
assert_eq!(y10[1], 64);
assert_eq!(cb[0], 512);
assert!(cr[2] > 900, "red has high Cr: {}", cr[2]);
}
}

350
crates/export/src/lib.rs Normal file
View File

@ -0,0 +1,350 @@
//! Render Queue jobs: render a composition's time span and write it through an output module.
//!
//! | Format | Container / files | Video | Alpha | Audio |
//! |---|---|---|---|---|
//! | H.264 | MP4 | FilmCraft H.264 High, 8-bit 4:2:0 BT.709 limited, VBR | no | AAC-LC (FilmCraft), stereo |
//! | ProRes | QuickTime MOV | FilmCraft ProRes 422 Proxy/LT/422/HQ, 10-bit; 4444/4444 XQ 4:4:4 | 4444 (16-bit alpha coding) | 16-bit PCM, stereo |
//! | PNG / JPEG / TIFF sequence | one file per frame | 8-bit | PNG, TIFF | — |
//! | OpenEXR sequence | one file per frame | 32-bit float, linear light, premultiplied | yes | — |
//! | Animated GIF | GIF89a | 256-colour palette per frame (NeuQuant) | 1-bit | — |
//!
//! Frames are rendered in parallel batches (rayon; one batch ≈ one frame per core) and handed to
//! the encoder in order. Progress is reported after each batch through a callback that can cancel
//! the job by returning `false`. Codecs and the MP4/MOV muxer are FilmCraft's pure-Rust crates
//! (plan ADR 0001); nothing outside this crate touches them.
//!
//! H.264 requires even dimensions: odd output sizes are cropped by one pixel (padded when the
//! output is a single pixel wide/high).
mod encode;
use std::path::Path;
use std::time::Instant;
use effectcraft_project::render_queue::{AudioOutput, Channels, OutputFormat, OutputModule, RenderQuality, RenderSettings, sequence_path};
use effectcraft_project::{Comp, ItemId, Project};
use effectcraft_raster::Image;
use effectcraft_render::{ExprHost, FootageSource, RenderOpts, Renderer};
use effectcraft_time::Tick;
use rayon::prelude::*;
pub use effectcraft_project::render_queue;
#[derive(Debug, thiserror::Error)]
pub enum ExportError {
#[error("composition not found")]
NoComp,
#[error("cancelled")]
Cancelled,
#[error("i/o: {0}")]
Io(String),
#[error("encode: {0}")]
Encode(String),
#[error("{0}")]
Unsupported(String),
}
pub type Result<T> = std::result::Result<T, ExportError>;
fn io(e: impl std::fmt::Display) -> ExportError {
ExportError::Io(e.to_string())
}
/// One export: a comp, its Render Settings and Output Module, and the resolved output path.
pub struct Job<'a> {
pub project: &'a Project,
pub footage: &'a dyn FootageSource,
pub expr: Option<&'a dyn ExprHost>,
pub comp: ItemId,
pub settings: &'a RenderSettings,
pub output: &'a OutputModule,
/// Output file. For image sequences a run of `#` (or `[#####]`) becomes the frame number; a
/// path without one gets `_NNNNN` before the extension.
pub path: &'a str,
}
/// Progress after a batch of frames.
#[derive(Clone, Copy, Debug, Default)]
pub struct Progress {
pub done: u64,
pub total: u64,
pub elapsed: f64,
}
impl Progress {
pub fn fraction(&self) -> f64 {
if self.total == 0 { 0.0 } else { self.done as f64 / self.total as f64 }
}
/// Estimated seconds remaining.
pub fn remaining(&self) -> Option<f64> {
(self.done > 0).then(|| self.elapsed / self.done as f64 * self.total.saturating_sub(self.done) as f64)
}
}
/// What an export produced.
#[derive(Clone, Debug, PartialEq)]
pub struct Report {
/// The movie file, or the first file of a sequence.
pub path: String,
pub frames: u64,
pub width: u32,
pub height: u32,
pub seconds: f64,
pub bytes: u64,
pub audio: bool,
}
/// Formats this build can write (all of them; kept as the hook for optional encoders).
pub fn available_formats() -> Vec<OutputFormat> {
OutputFormat::ALL.to_vec()
}
/// The size of the written frames for a job (H.264 rounds to even).
pub fn output_size(comp: &Comp, settings: &RenderSettings, output: &OutputModule) -> (u32, u32) {
let (w, h) = settings.output_size(comp);
output.format.coded_size(w, h)
}
/// Whether the job writes an audio track.
pub fn wants_audio(job: &Job) -> bool {
job.output.format.supports_audio()
&& match job.output.audio {
AudioOutput::On => true,
AudioOutput::Off => false,
AudioOutput::Auto => effectcraft_render::audio::comp_has_audio(job.project, job.comp),
}
}
/// Run the export (blocking). `progress` is called after every batch; returning `false` cancels.
pub fn export(job: &Job, progress: &mut dyn FnMut(&Progress) -> bool) -> Result<Report> {
let comp = job.project.comp(job.comp).ok_or(ExportError::NoComp)?;
let t0 = Instant::now();
let total = job.settings.frame_count(comp);
let mut st = State { t0, total, done: 0, progress };
st.advance(0)?;
if let Some(dir) = Path::new(job.path).parent().filter(|d| !d.as_os_str().is_empty()) {
std::fs::create_dir_all(dir).map_err(io)?;
}
let (w, h) = output_size(comp, job.settings, job.output);
let mut report = match job.output.format {
OutputFormat::H264 | OutputFormat::ProRes => encode::movie(job, comp, w, h, &mut st)?,
OutputFormat::Gif => gif_export(job, comp, w, h, &mut st)?,
f if f.is_sequence() => sequence(job, comp, w, h, &mut st)?,
f => return Err(ExportError::Unsupported(f.label().into())),
};
report.seconds = t0.elapsed().as_secs_f64();
report.frames = total;
(report.width, report.height) = (w, h);
Ok(report)
}
pub(crate) struct State<'p> {
t0: Instant,
total: u64,
done: u64,
progress: &'p mut dyn FnMut(&Progress) -> bool,
}
impl State<'_> {
fn snapshot(&self) -> Progress {
Progress { done: self.done, total: self.total, elapsed: self.t0.elapsed().as_secs_f64() }
}
/// Count `n` more frames; `Err(Cancelled)` when the callback says stop.
pub(crate) fn advance(&mut self, n: u64) -> Result<()> {
self.done += n;
let p = self.snapshot();
if (self.progress)(&p) { Ok(()) } else { Err(ExportError::Cancelled) }
}
}
/// Frames per parallel batch.
pub(crate) fn batch_size() -> u64 {
rayon::current_num_threads().clamp(2, 16) as u64
}
/// Render output frame `i` of the job at the settings' resolution.
pub(crate) fn render_frame(job: &Job, comp: &Comp, i: u64) -> Image {
let t: Tick = job.settings.frame_time(comp, i);
let opts = RenderOpts {
scale: job.settings.resolution.clamp(0.01, 4.0),
motion_blur: job.settings.motion_blur,
guides: false,
draft: job.settings.quality == RenderQuality::Draft,
};
let mut r = Renderer::new(job.project, job.footage, opts);
r.expr = job.expr;
r.comp_frame(job.comp, t)
}
/// 8-bit RGBA of the requested channels (RGB: over the comp background, opaque; RGBA: straight),
/// cropped/padded to `w`×`h`.
pub(crate) fn rgba8(img: &Image, comp: &Comp, channels: Channels, w: u32, h: u32) -> Vec<u8> {
let px = match channels {
Channels::Rgb => img.to_rgba8_over(comp.background),
Channels::Rgba => img.to_rgba8(),
};
fit(px, img.width, img.height, w, h)
}
fn fit(px: Vec<u8>, iw: u32, ih: u32, w: u32, h: u32) -> Vec<u8> {
if iw == w && ih == h {
return px;
}
let mut out = vec![0u8; (w * h * 4) as usize];
for y in 0..h.min(ih) as usize {
let n = w.min(iw) as usize * 4;
out[y * w as usize * 4..y * w as usize * 4 + n].copy_from_slice(&px[y * iw as usize * 4..y * iw as usize * 4 + n]);
}
out
}
/// The comp frame number of output frame 0 (sequence numbering follows comp frames, like AE).
fn first_frame_number(job: &Job, comp: &Comp) -> i64 {
job.settings.first_frame(comp)
}
fn sequence(job: &Job, comp: &Comp, w: u32, h: u32, st: &mut State) -> Result<Report> {
let f0 = first_frame_number(job, comp);
let fmt = job.output.format;
let channels = if fmt.supports_alpha() { job.output.channels } else { Channels::Rgb };
let batch = batch_size();
let mut bytes = 0u64;
let mut i = 0;
while i < st.total {
let end = (i + batch).min(st.total);
let written: Vec<Result<u64>> = (i..end)
.into_par_iter()
.map(|k| {
let path = sequence_path(job.path, f0 + k as i64);
if job.settings.skip_existing && Path::new(&path).exists() {
return Ok(0);
}
let img = render_frame(job, comp, k);
write_still(&path, fmt, &img, comp, channels, w, h, job.output.quality)?;
Ok(std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0))
})
.collect();
for r in written {
bytes += r?;
}
st.advance(end - i)?;
i = end;
}
Ok(Report { path: sequence_path(job.path, f0), frames: 0, width: w, height: h, seconds: 0.0, bytes, audio: false })
}
#[allow(clippy::too_many_arguments)]
fn write_still(path: &str, fmt: OutputFormat, img: &Image, comp: &Comp, channels: Channels, w: u32, h: u32, quality: u8) -> Result<()> {
use image::{ExtendedColorType, ImageEncoder, ImageFormat};
let file = || std::fs::File::create(path).map(std::io::BufWriter::new).map_err(io);
match fmt {
OutputFormat::ExrSequence => {
// Linear light, premultiplied (the EXR convention); RGB keeps alpha = 1 over the background.
let lin = |v: f32| if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) };
let mut data = Vec::with_capacity((w * h * 4) as usize);
for y in 0..h {
for x in 0..w {
let p = if x < img.width && y < img.height { img.data[img.idx(x, y)] } else { [0.0; 4] };
let (c, a) = match channels {
Channels::Rgba => {
let a = p[3].clamp(0.0, 1.0);
let s = |c: f32| if a > 0.0 { lin((c / a).clamp(0.0, 1.0)) * a } else { 0.0 };
([s(p[0]), s(p[1]), s(p[2])], a)
}
Channels::Rgb => {
let k = 1.0 - p[3].clamp(0.0, 1.0);
let c = |i: usize| lin((p[i] + comp.background[i] * k).clamp(0.0, 1.0));
([c(0), c(1), c(2)], 1.0)
}
};
data.extend_from_slice(&[c[0], c[1], c[2], a]);
}
}
let buf = image::Rgba32FImage::from_raw(w, h, data).ok_or_else(|| ExportError::Encode("exr buffer".into()))?;
image::DynamicImage::ImageRgba32F(buf).save_with_format(path, ImageFormat::OpenExr).map_err(|e| ExportError::Encode(e.to_string()))
}
OutputFormat::JpegSequence => {
let px = rgba8(img, comp, Channels::Rgb, w, h);
let rgb: Vec<u8> = px.chunks_exact(4).flat_map(|p| [p[0], p[1], p[2]]).collect();
image::codecs::jpeg::JpegEncoder::new_with_quality(file()?, quality.clamp(1, 100))
.write_image(&rgb, w, h, ExtendedColorType::Rgb8)
.map_err(|e| ExportError::Encode(e.to_string()))
}
OutputFormat::PngSequence | OutputFormat::TiffSequence => {
let px = rgba8(img, comp, channels, w, h);
let (data, ct) = match channels {
Channels::Rgba => (px, ExtendedColorType::Rgba8),
Channels::Rgb => (px.chunks_exact(4).flat_map(|p| [p[0], p[1], p[2]]).collect(), ExtendedColorType::Rgb8),
};
if fmt == OutputFormat::PngSequence {
image::codecs::png::PngEncoder::new(file()?).write_image(&data, w, h, ct).map_err(|e| ExportError::Encode(e.to_string()))
} else {
// The TIFF encoder needs Seek.
let f = std::fs::File::create(path).map_err(io)?;
image::codecs::tiff::TiffEncoder::new(std::io::BufWriter::new(f)).write_image(&data, w, h, ct).map_err(|e| ExportError::Encode(e.to_string()))
}
}
f => Err(ExportError::Unsupported(format!("{} is not a still format", f.label()))),
}
}
fn gif_export(job: &Job, comp: &Comp, w: u32, h: u32, st: &mut State) -> Result<Report> {
if w > u16::MAX as u32 || h > u16::MAX as u32 {
return Err(ExportError::Unsupported("GIF frames are limited to 65535 px".into()));
}
let file = std::fs::File::create(job.path).map_err(io)?;
let mut enc = gif::Encoder::new(std::io::BufWriter::new(file), w as u16, h as u16, &[]).map_err(|e| ExportError::Encode(e.to_string()))?;
enc.set_repeat(if job.output.gif_loop { gif::Repeat::Infinite } else { gif::Repeat::Finite(0) }).map_err(|e| ExportError::Encode(e.to_string()))?;
let rate = job.settings.rate(comp);
// GIF delays are in 1/100 s: carry the rounding error so long animations keep their timing.
let cs_per_frame = 100.0 * rate.den as f64 / rate.num as f64;
let mut clock = 0.0f64;
let mut emitted = 0u64;
let batch = batch_size();
let mut i = 0;
while i < st.total {
let end = (i + batch).min(st.total);
let frames: Vec<gif::Frame<'static>> = (i..end)
.into_par_iter()
.map(|k| {
let img = render_frame(job, comp, k);
let mut px = rgba8(&img, comp, job.output.channels, w, h);
gif::Frame::from_rgba_speed(w as u16, h as u16, &mut px, 10)
})
.collect();
for mut f in frames {
clock += cs_per_frame;
let d = (clock.round() as u64).saturating_sub(emitted).max(1);
emitted += d;
f.delay = d.min(u16::MAX as u64) as u16;
if job.output.channels == Channels::Rgba {
f.dispose = gif::DisposalMethod::Background;
}
enc.write_frame(&f).map_err(|e| ExportError::Encode(e.to_string()))?;
}
st.advance(end - i)?;
i = end;
}
drop(enc);
let bytes = std::fs::metadata(job.path).map(|m| m.len()).unwrap_or(0);
Ok(Report { path: job.path.to_string(), frames: 0, width: w, height: h, seconds: 0.0, bytes, audio: false })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fit_crops_and_pads() {
let px: Vec<u8> = (0..3 * 3 * 4).map(|v| v as u8).collect();
let c = fit(px.clone(), 3, 3, 2, 2);
assert_eq!(c.len(), 16);
assert_eq!(&c[0..8], &px[0..8]);
assert_eq!(&c[8..16], &px[12..20]);
let p = fit(px, 3, 3, 4, 4);
assert_eq!(p.len(), 64);
assert_eq!(&p[12..16], &[0, 0, 0, 0]);
assert_eq!(OutputFormat::H264.coded_size(1, 101), (2, 100));
assert_eq!(OutputFormat::Gif.coded_size(1, 101), (1, 101));
}
}

View File

@ -0,0 +1,365 @@
//! Encode a short comp to every output format and decode it back: through EffectCraft's own
//! media layer (FilmCraft decoders) for MP4/MOV, the `image`/`gif` crates for stills and GIF,
//! and — when installed — ffprobe as an external oracle (skipped otherwise).
//!
//! The comp: 64×48 @ 10 fps, 1.2 s (12 frames), dark-blue background, a red solid of 32×48
//! centred, starting at frame 5. So frame < 5 is background everywhere; from frame 5 the centre
//! is red and the corners stay background.
use std::path::{Path, PathBuf};
use std::process::Command;
use effectcraft_color::Label;
use effectcraft_export::render_queue::{AudioOutput, Channels, OutputFormat, OutputModule, ProResProfile, RenderSettings, TimeSpan, sequence_path};
use effectcraft_export::{Job, Progress, export};
use effectcraft_media::MediaPool;
use effectcraft_project::{Comp, ItemId, ItemKind, LayerSource, Project, Solid, build};
use effectcraft_raster::Image;
use effectcraft_render::{FootageSource, NoFootage};
use effectcraft_time::{FrameRate, Tick};
const W: u32 = 64;
const H: u32 = 48;
const BG: [f32; 3] = [0.1, 0.1, 0.4];
const FRAMES: u64 = 12;
fn out_dir(name: &str) -> PathBuf {
let d = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/test-out/export").join(name);
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).expect("mkdir");
d
}
fn project(audio: Option<&Path>) -> (Project, ItemId) {
let mut p = Project::default();
let mut comp = Comp::new(W, H, FrameRate::new(10, 1), Tick::from_seconds_f64(1.2));
comp.background = BG;
let sid = p.add_item("Red", Label::Red, None, ItemKind::Solid(Solid { color: [1.0, 0.0, 0.0], width: 32, height: H, pixel_aspect: 1.0 }));
let mut l = build::layer(&mut p, &comp, "Red", LayerSource::Solid { item: sid }, (32, H), None);
l.in_point = comp.frame_rate.tick_of(5);
comp.layers.push(l);
if let Some(path) = audio {
let f = effectcraft_media::probe(path).expect("probe wav");
let fid = p.add_item("tone.wav", Label::SeaFoam, None, ItemKind::Footage(f));
let l = build::layer(&mut p, &comp, "tone", LayerSource::Footage { item: fid }, (0, 0), None);
comp.layers.push(l);
}
let cid = p.add_item("Comp 1", Label::Sandstone, None, ItemKind::Comp(comp.into()));
(p, cid)
}
fn settings() -> RenderSettings {
RenderSettings { time_span: TimeSpan::LengthOfComp, ..Default::default() }
}
fn run(p: &Project, cid: ItemId, footage: &dyn FootageSource, om: &OutputModule, path: &Path) -> effectcraft_export::Report {
let s = settings();
let path = path.to_string_lossy().to_string();
let job = Job { project: p, footage, expr: None, comp: cid, settings: &s, output: om, path: &path };
let mut last = Progress::default();
let mut calls = 0;
let r = export(&job, &mut |pr| {
assert!(pr.done >= last.done, "progress is monotonic");
last = *pr;
calls += 1;
true
})
.expect("export");
assert_eq!(last.done, FRAMES);
assert_eq!(last.total, FRAMES);
assert!(calls >= 2);
assert_eq!(r.frames, FRAMES);
r
}
fn px(img: &Image, x: u32, y: u32) -> [f32; 4] {
img.data[img.idx(x, y)]
}
fn near(a: [f32; 3], b: [f32; 3], tol: f32) -> bool {
a.iter().zip(b).all(|(x, y)| (x - y).abs() <= tol)
}
/// Check a decoded (premultiplied, opaque) frame `k`.
fn check_frame(img: &Image, k: u64, tol: f32) {
let corner = px(img, 2, 2);
assert!(near([corner[0], corner[1], corner[2]], BG, tol), "frame {k} corner {corner:?}");
let c = px(img, img.width / 2, img.height / 2);
let want = if k >= 5 { [1.0, 0.0, 0.0] } else { BG };
assert!(near([c[0], c[1], c[2]], want, tol), "frame {k} centre {c:?} want {want:?}");
}
fn decode_movie(path: &Path, frames: u64, tol: f32) -> effectcraft_project::Footage {
let f = effectcraft_media::probe(path).expect("probe our output");
assert_eq!((f.width, f.height), (W, H));
assert!(f.has_video);
let n = f.frame_rate.frame_at(f.duration - Tick(1)) + 1;
assert_eq!(n as u64, frames, "frame count from duration {:?} @ {:?}", f.duration, f.frame_rate);
let pool = MediaPool::new();
for k in 0..frames {
let img = pool.frame_at(&f, f.frame_rate.tick_of(k as i64)).expect("decode");
check_frame(&img, k, tol);
}
f
}
fn ffprobe(path: &Path) -> Option<String> {
let out = Command::new("ffprobe")
.args(["-v", "error", "-count_frames", "-show_entries", "stream=codec_name,width,height,nb_read_frames,pix_fmt", "-of", "compact=p=0:nk=1"])
.arg(path)
.output()
.ok()?;
if !out.status.success() {
panic!("ffprobe rejected {}: {}", path.display(), String::from_utf8_lossy(&out.stderr));
}
Some(String::from_utf8_lossy(&out.stdout).to_string())
}
/// Frame `k` decoded by ffmpeg (external oracle) as RGBA, or `None` without ffmpeg.
fn ffmpeg_frame(path: &Path, k: u64) -> Option<Image> {
let png = path.with_extension(format!("ffmpeg-{k}.png"));
let st = Command::new("ffmpeg")
.args(["-hide_banner", "-loglevel", "error", "-y", "-i"])
.arg(path)
.args(["-vf", &format!("select=eq(n\\,{k})"), "-frames:v", "1", "-pix_fmt", "rgba"])
.arg(&png)
.status()
.ok()?;
assert!(st.success(), "ffmpeg failed to decode {}", path.display());
let img = image::open(&png).expect("ffmpeg png").to_rgba8();
Some(Image::from_rgba8(img.width(), img.height(), img.as_raw()))
}
fn ffmpeg_check(path: &Path, tol: f32) {
for k in [0, 4, 5, FRAMES - 1] {
match ffmpeg_frame(path, k) {
Some(img) => check_frame(&img, k, tol),
None => {
eprintln!("ffmpeg not found: skipping oracle decode");
return;
}
}
}
}
#[test]
fn h264_mp4_roundtrip() {
let d = out_dir("h264");
let (p, cid) = project(None);
let om = OutputModule { bitrate_kbps: 4000, ..OutputModule::for_format(OutputFormat::H264) };
let path = d.join("comp.mp4");
let r = run(&p, cid, &NoFootage, &om, &path);
assert!(r.bytes > 100 && !r.audio);
decode_movie(&path, FRAMES, 0.08);
ffmpeg_check(&path, 0.08);
if let Some(s) = ffprobe(&path) {
assert!(s.contains("h264|64|48|yuv420p"), "{s}");
assert!(s.lines().next().unwrap_or("").ends_with("|12"), "{s}");
}
}
#[test]
fn h264_odd_size_is_cropped_even() {
let d = out_dir("h264-odd");
let (p, cid) = project(None);
let om = OutputModule::for_format(OutputFormat::H264);
let s = RenderSettings { resolution: 31.0 / 64.0, ..settings() };
let comp = p.comp(cid).expect("comp");
assert_eq!(s.output_size(comp).0 % 2, 1);
let path = d.join("odd.mp4").to_string_lossy().to_string();
let job = Job { project: &p, footage: &NoFootage, expr: None, comp: cid, settings: &s, output: &om, path: &path };
let r = export(&job, &mut |_| true).expect("export");
assert_eq!((r.width % 2, r.height % 2), (0, 0));
}
#[test]
fn prores_mov_roundtrip() {
let d = out_dir("prores");
let (p, cid) = project(None);
let om = OutputModule::for_format(OutputFormat::ProRes);
let path = d.join("comp.mov");
run(&p, cid, &NoFootage, &om, &path);
decode_movie(&path, FRAMES, 0.03);
ffmpeg_check(&path, 0.03);
if let Some(s) = ffprobe(&path) {
assert!(s.starts_with("prores|64|48"), "{s}");
assert!(s.lines().next().unwrap_or("").ends_with("|12"), "{s}");
}
}
#[test]
fn prores_4444_with_alpha() {
let d = out_dir("prores4444");
let (p, cid) = project(None);
let om = OutputModule { channels: Channels::Rgba, prores_profile: ProResProfile::Hq, ..OutputModule::for_format(OutputFormat::ProRes) };
let path = d.join("alpha.mov");
run(&p, cid, &NoFootage, &om, &path);
let f = effectcraft_media::probe(&path).expect("probe");
let img = MediaPool::new().frame_at(&f, f.frame_rate.tick_of(7)).expect("decode");
assert!(px(&img, 2, 2)[3] < 0.02, "corner transparent: {:?}", px(&img, 2, 2));
let c = px(&img, W / 2, H / 2);
assert!(c[3] > 0.98 && c[0] > 0.95 && c[1] < 0.05, "centre opaque red: {c:?}");
if let Some(s) = ffprobe(&path) {
assert!(s.starts_with("prores|64|48|yuva444"), "{s}");
}
}
fn check_still_dir(dir: &Path, ext: &str, tol: f32, alpha: bool) {
for k in 0..FRAMES {
let path = sequence_path(&dir.join(format!("f_[#####].{ext}")).to_string_lossy(), k as i64);
let img = image::open(&path).unwrap_or_else(|e| panic!("{path}: {e}")).to_rgba8();
assert_eq!(img.dimensions(), (W, H));
let img = Image::from_rgba8(W, H, img.as_raw());
if alpha {
let corner = px(&img, 2, 2);
assert!(corner[3] < 0.01, "{path}: corner {corner:?}");
let c = px(&img, W / 2, H / 2);
assert_eq!(c[3] > 0.99, k >= 5, "{path}: centre {c:?}");
} else {
check_frame(&img, k, tol);
}
}
assert!(!dir.join(format!("f_{:05}.{ext}", FRAMES)).exists());
}
#[test]
fn image_sequences_roundtrip() {
let (p, cid) = project(None);
for (fmt, ext, tol, ch) in [
(OutputFormat::PngSequence, "png", 0.01, Channels::Rgb),
(OutputFormat::PngSequence, "png", 0.01, Channels::Rgba),
(OutputFormat::JpegSequence, "jpg", 0.06, Channels::Rgb),
(OutputFormat::TiffSequence, "tif", 0.01, Channels::Rgb),
(OutputFormat::TiffSequence, "tif", 0.01, Channels::Rgba),
] {
let d = out_dir(&format!("seq-{ext}-{ch:?}"));
let om = OutputModule { channels: ch, ..OutputModule::for_format(fmt) };
let r = run(&p, cid, &NoFootage, &om, &d.join(format!("f_[#####].{ext}")));
assert!(r.path.ends_with(&format!("f_00000.{ext}")), "{}", r.path);
check_still_dir(&d, ext, tol, ch == Channels::Rgba);
}
}
#[test]
fn exr_sequence_is_linear() {
let (p, cid) = project(None);
let d = out_dir("seq-exr");
let om = OutputModule::for_format(OutputFormat::ExrSequence);
run(&p, cid, &NoFootage, &om, &d.join("f_[#####].exr"));
let img = image::open(d.join("f_00000.exr")).expect("exr").to_rgba32f();
assert_eq!(img.dimensions(), (W, H));
let c = img.get_pixel(2, 2).0;
// sRGB 0.4 → linear ≈ 0.133
assert!((c[2] - 0.1329).abs() < 0.01 && (c[3] - 1.0).abs() < 1e-6, "{c:?}");
}
#[test]
fn gif_roundtrip() {
let (p, cid) = project(None);
let d = out_dir("gif");
let om = OutputModule::for_format(OutputFormat::Gif);
let path = d.join("anim.gif");
run(&p, cid, &NoFootage, &om, &path);
let mut opts = gif::DecodeOptions::new();
opts.set_color_output(gif::ColorOutput::RGBA);
let mut dec = opts.read_info(std::fs::File::open(&path).expect("open")).expect("gif header");
assert_eq!((dec.width() as u32, dec.height() as u32), (W, H));
let mut k = 0;
let mut total_delay = 0u32;
while let Some(f) = dec.read_next_frame().expect("frame") {
assert_eq!((f.width as u32, f.height as u32), (W, H));
total_delay += f.delay as u32;
let img = Image::from_rgba8(W, H, &f.buffer);
check_frame(&img, k, 0.06);
k += 1;
}
assert_eq!(k, FRAMES);
assert_eq!(total_delay, 120, "1.2 s in centiseconds");
if let Some(s) = ffprobe(&path) {
assert!(s.starts_with("gif|64|48"), "{s}");
}
}
#[test]
fn cancel_stops_early() {
let (p, cid) = project(None);
let d = out_dir("cancel");
let om = OutputModule::for_format(OutputFormat::PngSequence);
let s = settings();
let path = d.join("c_[#####].png").to_string_lossy().to_string();
let job = Job { project: &p, footage: &NoFootage, expr: None, comp: cid, settings: &s, output: &om, path: &path };
let r = export(&job, &mut |_| false);
assert!(matches!(r, Err(effectcraft_export::ExportError::Cancelled)));
let written = std::fs::read_dir(&d).map(|r| r.count()).unwrap_or(0);
assert_eq!(written, 0, "cancelled before the first batch");
}
/// 16-bit stereo WAV with a 440 Hz sine at amplitude 0.5 (left) and silence (right).
fn write_tone(path: &Path, secs: f64) {
let sr = 48_000u32;
let n = (secs * sr as f64) as usize;
let mut data = Vec::with_capacity(n * 4);
for i in 0..n {
let s = (0.5 * (2.0 * std::f64::consts::PI * 440.0 * i as f64 / sr as f64).sin() * 32767.0).round() as i16;
data.extend_from_slice(&s.to_le_bytes());
data.extend_from_slice(&0i16.to_le_bytes());
}
let mut w = Vec::new();
w.extend_from_slice(b"RIFF");
w.extend_from_slice(&(36 + data.len() as u32).to_le_bytes());
w.extend_from_slice(b"WAVEfmt ");
w.extend_from_slice(&16u32.to_le_bytes());
w.extend_from_slice(&1u16.to_le_bytes());
w.extend_from_slice(&2u16.to_le_bytes());
w.extend_from_slice(&sr.to_le_bytes());
w.extend_from_slice(&(sr * 4).to_le_bytes());
w.extend_from_slice(&4u16.to_le_bytes());
w.extend_from_slice(&16u16.to_le_bytes());
w.extend_from_slice(b"data");
w.extend_from_slice(&(data.len() as u32).to_le_bytes());
w.extend_from_slice(&data);
std::fs::write(path, w).expect("write wav");
}
fn rms(v: impl Iterator<Item = f32>) -> f32 {
let (s, n) = v.fold((0.0f64, 0usize), |(s, n), x| (s + (x as f64) * (x as f64), n + 1));
(s / n.max(1) as f64).sqrt() as f32
}
#[test]
fn movies_carry_audio() {
let d = out_dir("audio");
let wav = d.join("tone.wav");
write_tone(&wav, 2.0);
let (p, cid) = project(Some(&wav));
let pool = MediaPool::new();
for (fmt, name) in [(OutputFormat::H264, "a.mp4"), (OutputFormat::ProRes, "a.mov")] {
let om = OutputModule { audio: AudioOutput::Auto, ..OutputModule::for_format(fmt) };
let path = d.join(name);
let r = run(&p, cid, &pool, &om, &path);
assert!(r.audio, "{name}: auto audio is on for a comp with a sound layer");
let f = effectcraft_media::probe(&path).expect("probe");
assert!(f.has_audio, "{name}");
let dec = MediaPool::new();
let s = dec.audio_samples(&f, Tick::from_seconds_f64(0.3), 24_000, 48_000);
let (l, r) = (rms(s.iter().step_by(2).copied()), rms(s.iter().skip(1).step_by(2).copied()));
// sine of amplitude 0.5 → RMS ≈ 0.354
assert!((l - 0.3536).abs() < 0.03, "{name}: left RMS {l}");
assert!(r < 0.01, "{name}: right RMS {r}");
if Command::new("ffprobe").arg("-version").output().is_ok() {
let out = Command::new("ffprobe")
.args(["-v", "error", "-select_streams", "a:0", "-show_entries", "stream=codec_name,channels,sample_rate", "-of", "compact=p=0:nk=1"])
.arg(&path)
.output()
.expect("ffprobe");
let s = String::from_utf8_lossy(&out.stdout);
let want = if fmt == OutputFormat::H264 { "aac|48000|2" } else { "pcm_s16le|48000|2" };
assert!(s.trim() == want, "{name}: {s}");
}
// Audio off.
let om = OutputModule { audio: AudioOutput::Off, ..om };
let path = d.join(format!("silent-{name}"));
assert!(!run(&p, cid, &pool, &om, &path).audio);
assert!(!effectcraft_media::probe(&path).expect("probe").has_audio);
}
}

View File

@ -14,6 +14,7 @@ effectcraft-engine = { workspace = true }
effectcraft-project = { workspace = true }
effectcraft-media = { workspace = true }
effectcraft-expr = { workspace = true }
effectcraft-export = { workspace = true }
[dev-dependencies]
serde_json = { workspace = true }

View File

@ -1,9 +1,11 @@
//! A fully wired [`Session`]: footage decoding through `effectcraft-media` (FilmCraft's codecs),
//! the media importer and the expression engine. Frontends (desktop, CLI, MCP, web) start here.
//! the media importer, the expression engine and Render Queue export through
//! `effectcraft-export` (FilmCraft's encoders). Frontends (desktop, CLI, MCP, web) start here.
use std::sync::Arc;
use effectcraft_engine::{Importer, Session};
use effectcraft_engine::project::render_queue::OutputFormat;
use effectcraft_engine::{ExportJob, ExportResult, Exporter, Importer, Session};
use effectcraft_project::Footage;
struct MediaImporter;
@ -14,13 +16,40 @@ impl Importer for MediaImporter {
}
}
/// A new session with media, import and expressions enabled.
/// Render Queue export via `effectcraft-export`.
pub struct FileExporter;
impl Exporter for FileExporter {
fn formats(&self) -> Vec<OutputFormat> {
effectcraft_export::available_formats()
}
fn export(&self, job: &ExportJob, progress: &mut dyn FnMut(u64, u64) -> bool) -> Result<ExportResult, String> {
let j = effectcraft_export::Job {
project: job.project,
footage: job.footage,
expr: job.expr,
comp: job.item.comp,
settings: &job.item.settings,
output: &job.item.output,
path: job.path,
};
match effectcraft_export::export(&j, &mut |p| progress(p.done, p.total)) {
Ok(r) => Ok(ExportResult { path: r.path, frames: r.frames, width: r.width, height: r.height, bytes: r.bytes, seconds: r.seconds, audio: r.audio }),
Err(effectcraft_export::ExportError::Cancelled) => Err(effectcraft_engine::render_queue::CANCELLED.into()),
Err(e) => Err(e.to_string()),
}
}
}
/// A new session with media, import, expressions and export enabled.
pub fn session() -> Session {
let mut s = Session::default();
s.footage = Arc::new(effectcraft_media::MediaPool::new());
s.importer = Some(Arc::new(MediaImporter));
s.expr = Some(Arc::new(effectcraft_expr::Expressions));
s
Session {
exporter: Some(Arc::new(FileExporter)),
footage: Arc::new(effectcraft_media::MediaPool::new()),
importer: Some(Arc::new(MediaImporter)),
expr: Some(Arc::new(effectcraft_expr::Expressions)),
..Default::default()
}
}
#[cfg(test)]
@ -37,4 +66,44 @@ mod tests {
let img = s.render(cid, s.time(), effectcraft_engine::render::RenderOpts { scale: 0.25, ..Default::default() });
assert!(img.data.iter().any(|p| p[3] > 0.5));
}
#[test]
fn render_queue_exports_demo_comp() {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/test-out/host-rq");
let _ = std::fs::remove_dir_all(&dir);
let mut s = super::session();
s.execute("file.openDemoProject", json!({})).unwrap();
let out = dir.join("[compName]_[width]x[height].[fileExtension]");
let r = s
.execute(
"renderQueue.add",
json!({"format": "h264", "output": out.to_string_lossy(), "resolution": 0.125, "timeSpan": "custom", "start": 0.0, "end": 0.4}),
)
.unwrap();
let path = r["outputPath"].as_str().unwrap().to_string();
assert!(path.ends_with("_240x134.mp4"), "{path}");
// A second item: PNG sequence, rendered in the background.
s.execute("renderQueue.add", json!({"format": "png", "output": dir.join("seq_[###].png").to_string_lossy(), "resolution": 0.0625, "timeSpan": "custom", "start": 0.0, "end": 0.2})).unwrap();
let r = s.execute("renderQueue.render", json!({"wait": false})).unwrap();
assert_eq!(r["items"].as_array().unwrap().len(), 2);
let t0 = std::time::Instant::now();
while s.is_rendering() {
assert!(t0.elapsed().as_secs() < 300, "render timed out");
std::thread::sleep(std::time::Duration::from_millis(20));
s.poll_render();
}
s.poll_render();
let list = s.execute("renderQueue.list", json!({})).unwrap();
for it in list["items"].as_array().unwrap() {
assert_eq!(it["statusLabel"], "Done", "{it}");
assert!(it["render_time"].as_f64().is_some());
assert!(std::path::Path::new(it["last_output"].as_str().unwrap()).exists(), "{it}");
}
assert!(std::path::Path::new(&path).metadata().unwrap().len() > 500);
assert!(dir.join("seq_000.png").exists() && dir.join("seq_005.png").exists() && !dir.join("seq_006.png").exists());
// Rendering again needs a re-queue.
assert!(s.execute("renderQueue.render", json!({})).is_err());
s.execute("renderQueue.setRender", json!({"index": 1, "render": true})).unwrap();
assert_eq!(s.project.render_queue[0].status, effectcraft_engine::project::render_queue::RenderStatus::Queued);
}
}

View File

@ -454,6 +454,9 @@ impl FootageSource for MediaPool {
}
}
}
fn audio(&self, _item: ItemId, footage: &Footage, start: Tick, frames: usize, rate: u32) -> Vec<f32> {
self.audio_samples(footage, start, frames, rate)
}
}
fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {

View File

@ -6,6 +6,7 @@
pub mod build;
pub mod props;
pub mod render_queue;
use std::collections::BTreeMap;
use std::sync::Arc;
@ -586,11 +587,14 @@ pub struct Project {
pub settings: ProjectSettings,
pub items: BTreeMap<ItemId, Item>,
pub next_id: u64,
/// The Render Queue (Composition ▸ Add to Render Queue).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub render_queue: Vec<render_queue::RenderQueueItem>,
}
impl Default for Project {
fn default() -> Self {
Project { schema: SCHEMA_VERSION, settings: ProjectSettings::default(), items: BTreeMap::new(), next_id: 1 }
Project { schema: SCHEMA_VERSION, settings: ProjectSettings::default(), items: BTreeMap::new(), next_id: 1, render_queue: Vec::new() }
}
}

View File

@ -0,0 +1,525 @@
//! The Render Queue: compositions queued for export, each with Render Settings (what to render:
//! quality, resolution, time span, frame rate) and an Output Module (how to write it: format,
//! channels, codec options, output path). Saved with the project, like After Effects.
//!
//! Output paths are templates: `[compName].[fileExtension]` (the default), with the tokens
//! `[compName]`, `[projectName]`, `[fileExtension]`, `[width]`, `[height]`, `[frameRate]`,
//! `[startFrame]`, `[endFrame]`, `[outputModuleName]` and, for image sequences, a run of `#`
//! (`[#####]`) that becomes the zero-padded frame number. A relative result is resolved against the
//! project's folder (or the working directory for unsaved projects).
use effectcraft_time::{FrameRate, Tick};
use serde::{Deserialize, Serialize};
use crate::{Comp, ItemId};
/// The default output path template.
pub const DEFAULT_TEMPLATE: &str = "[compName].[fileExtension]";
/// The default template for image sequences.
pub const DEFAULT_SEQUENCE_TEMPLATE: &str = "[compName]_[#####].[fileExtension]";
/// Render Settings ▸ Quality.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum RenderQuality {
#[default]
Best,
Draft,
}
/// Render Settings ▸ Time Span.
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind")]
pub enum TimeSpan {
/// The composition's work area.
#[default]
WorkArea,
/// The whole composition.
LengthOfComp,
/// An explicit span `[start, end)` in comp time.
Custom { start: Tick, end: Tick },
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct RenderSettings {
pub quality: RenderQuality,
/// Output scale (1 = Full, 0.5 = Half, 1/3 = Third, 0.25 = Quarter).
pub resolution: f64,
pub time_span: TimeSpan,
/// `None` = use the comp's frame rate.
pub frame_rate: Option<FrameRate>,
/// Motion blur for layers with the switch on (and the comp's Enable Motion Blur).
pub motion_blur: bool,
/// Image sequences: skip frames whose file already exists.
pub skip_existing: bool,
}
impl Default for RenderSettings {
fn default() -> Self {
RenderSettings {
quality: RenderQuality::Best,
resolution: 1.0,
time_span: TimeSpan::WorkArea,
frame_rate: None,
motion_blur: true,
skip_existing: false,
}
}
}
impl RenderSettings {
/// The comp-time span `[start, end)` to render.
pub fn span(&self, comp: &Comp) -> (Tick, Tick) {
let (a, b) = match self.time_span {
TimeSpan::WorkArea => comp.work_area,
TimeSpan::LengthOfComp => (Tick::ZERO, comp.duration),
TimeSpan::Custom { start, end } => (start, end),
};
let a = a.clamp(Tick::ZERO, comp.duration);
let b = b.clamp(a, comp.duration);
(a, b)
}
pub fn rate(&self, comp: &Comp) -> FrameRate {
self.frame_rate.unwrap_or(comp.frame_rate)
}
/// First output-rate frame starting at or after `t`.
fn ceil_frame(r: FrameRate, t: Tick) -> i64 {
let f = r.frame_at(t);
if r.tick_of(f) < t { f + 1 } else { f }
}
/// Output-rate frame number of output frame 0 (the first frame starting inside the span).
pub fn first_frame(&self, comp: &Comp) -> i64 {
Self::ceil_frame(self.rate(comp), self.span(comp).0)
}
/// Number of output frames: those starting in `[start, end)` (at least 1).
pub fn frame_count(&self, comp: &Comp) -> u64 {
let (_, b) = self.span(comp);
let n = Self::ceil_frame(self.rate(comp), b) - self.first_frame(comp);
n.max(1) as u64
}
/// Comp time of output frame `i`.
pub fn frame_time(&self, comp: &Comp, i: u64) -> Tick {
self.rate(comp).tick_of(self.first_frame(comp) + i as i64)
}
pub fn output_size(&self, comp: &Comp) -> (u32, u32) {
let s = self.resolution.clamp(0.01, 4.0);
(((comp.width as f64 * s).round() as u32).max(1), ((comp.height as f64 * s).round() as u32).max(1))
}
pub fn resolution_label(&self) -> &'static str {
match self.resolution {
r if (r - 1.0).abs() < 1e-6 => "Full",
r if (r - 0.5).abs() < 1e-6 => "Half",
r if (r - 1.0 / 3.0).abs() < 1e-3 => "Third",
r if (r - 0.25).abs() < 1e-6 => "Quarter",
_ => "Custom",
}
}
/// One-line summary shown next to "Render Settings:" (AE shows the template name).
pub fn summary(&self) -> String {
let q = if self.quality == RenderQuality::Best { "Best Settings" } else { "Draft Settings" };
format!("{q} · {}", self.resolution_label())
}
}
/// Output Module ▸ Format.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum OutputFormat {
/// H.264 video (+ AAC audio) in MP4.
#[default]
H264,
/// Apple ProRes in QuickTime (+ PCM audio). 4444 when channels include alpha.
ProRes,
PngSequence,
JpegSequence,
TiffSequence,
/// OpenEXR sequence (32-bit float, linear light).
ExrSequence,
/// Animated GIF.
Gif,
}
impl OutputFormat {
pub const ALL: [OutputFormat; 7] = [
OutputFormat::H264,
OutputFormat::ProRes,
OutputFormat::PngSequence,
OutputFormat::JpegSequence,
OutputFormat::TiffSequence,
OutputFormat::ExrSequence,
OutputFormat::Gif,
];
pub fn label(self) -> &'static str {
match self {
OutputFormat::H264 => "H.264",
OutputFormat::ProRes => "QuickTime (ProRes)",
OutputFormat::PngSequence => "PNG Sequence",
OutputFormat::JpegSequence => "JPEG Sequence",
OutputFormat::TiffSequence => "TIFF Sequence",
OutputFormat::ExrSequence => "OpenEXR Sequence",
OutputFormat::Gif => "Animated GIF",
}
}
pub fn extension(self) -> &'static str {
match self {
OutputFormat::H264 => "mp4",
OutputFormat::ProRes => "mov",
OutputFormat::PngSequence => "png",
OutputFormat::JpegSequence => "jpg",
OutputFormat::TiffSequence => "tif",
OutputFormat::ExrSequence => "exr",
OutputFormat::Gif => "gif",
}
}
pub fn is_sequence(self) -> bool {
matches!(self, OutputFormat::PngSequence | OutputFormat::JpegSequence | OutputFormat::TiffSequence | OutputFormat::ExrSequence)
}
pub fn is_movie(self) -> bool {
matches!(self, OutputFormat::H264 | OutputFormat::ProRes)
}
pub fn supports_alpha(self) -> bool {
!matches!(self, OutputFormat::H264 | OutputFormat::JpegSequence)
}
pub fn supports_audio(self) -> bool {
self.is_movie()
}
/// The written frame size for a rendered size: H.264 needs even dimensions (rounded down,
/// at least 2).
pub fn coded_size(self, w: u32, h: u32) -> (u32, u32) {
let even = |v: u32| if v < 2 { 2 } else { v & !1 };
if self == OutputFormat::H264 { (even(w), even(h)) } else { (w, h) }
}
/// Parse a format name: `h264`, `mp4`, `prores`, `mov`, `png`, `jpeg`/`jpg`, `tiff`/`tif`,
/// `exr`, `gif`, or a label.
pub fn from_name(s: &str) -> Option<OutputFormat> {
let n = s.trim().to_ascii_lowercase().replace([' ', '.', '-', '_'], "");
Some(match n.as_str() {
"h264" | "mp4" | "avc" => OutputFormat::H264,
"prores" | "mov" | "quicktime" | "quicktimeprores" => OutputFormat::ProRes,
"png" | "pngsequence" => OutputFormat::PngSequence,
"jpg" | "jpeg" | "jpegsequence" => OutputFormat::JpegSequence,
"tif" | "tiff" | "tiffsequence" => OutputFormat::TiffSequence,
"exr" | "openexr" | "openexrsequence" => OutputFormat::ExrSequence,
"gif" | "animatedgif" => OutputFormat::Gif,
_ => return None,
})
}
/// Guess from a file name's extension.
pub fn from_path(path: &str) -> Option<OutputFormat> {
let ext = std::path::Path::new(path).extension()?.to_string_lossy().to_ascii_lowercase();
OutputFormat::from_name(&ext)
}
}
/// Output Module ▸ Channels.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum Channels {
/// Opaque, composited over the comp background colour.
#[default]
Rgb,
/// With straight (unmatted) alpha.
Rgba,
}
/// Output Module ▸ Audio Output.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum AudioOutput {
/// On when the comp has audible layers.
#[default]
Auto,
On,
Off,
}
/// ProRes flavour.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum ProResProfile {
Proxy,
Lt,
Standard,
#[default]
Hq,
P4444,
P4444Xq,
}
impl ProResProfile {
pub const ALL: [ProResProfile; 6] =
[ProResProfile::Proxy, ProResProfile::Lt, ProResProfile::Standard, ProResProfile::Hq, ProResProfile::P4444, ProResProfile::P4444Xq];
pub fn label(self) -> &'static str {
match self {
ProResProfile::Proxy => "Apple ProRes 422 Proxy",
ProResProfile::Lt => "Apple ProRes 422 LT",
ProResProfile::Standard => "Apple ProRes 422",
ProResProfile::Hq => "Apple ProRes 422 HQ",
ProResProfile::P4444 => "Apple ProRes 4444",
ProResProfile::P4444Xq => "Apple ProRes 4444 XQ",
}
}
pub fn is_4444(self) -> bool {
matches!(self, ProResProfile::P4444 | ProResProfile::P4444Xq)
}
pub fn from_name(s: &str) -> Option<ProResProfile> {
let n = s.to_ascii_lowercase().replace([' ', '-', '_'], "");
ProResProfile::ALL.into_iter().find(|p| {
let l = p.label().to_ascii_lowercase().replace(' ', "");
l == n || l.trim_start_matches("appleprores") == n.trim_start_matches("appleprores") || format!("{p:?}").to_ascii_lowercase() == n
})
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct OutputModule {
pub format: OutputFormat,
pub channels: Channels,
/// Output path or template (see the module docs).
pub output: String,
/// JPEG quality 1–100.
pub quality: u8,
/// H.264 target bitrate.
pub bitrate_kbps: u32,
pub prores_profile: ProResProfile,
pub audio: AudioOutput,
pub audio_sample_rate: u32,
/// GIF: loop forever.
pub gif_loop: bool,
}
impl Default for OutputModule {
fn default() -> Self {
OutputModule {
format: OutputFormat::H264,
channels: Channels::Rgb,
output: DEFAULT_TEMPLATE.into(),
quality: 90,
bitrate_kbps: 10_000,
prores_profile: ProResProfile::Hq,
audio: AudioOutput::Auto,
audio_sample_rate: 48_000,
gif_loop: true,
}
}
}
impl OutputModule {
pub fn for_format(format: OutputFormat) -> OutputModule {
let mut m = OutputModule { format, ..Default::default() };
if format.is_sequence() {
m.output = DEFAULT_SEQUENCE_TEMPLATE.into();
}
m
}
/// Change the format, keeping the output name but fixing its extension / frame-number token.
pub fn set_format(&mut self, format: OutputFormat) {
let old = self.format;
self.format = format;
if !format.supports_alpha() {
self.channels = Channels::Rgb;
}
if self.output.contains("[fileExtension]") {
if format.is_sequence() && !self.output.contains('#') {
self.output = self.output.replace(".[fileExtension]", "_[#####].[fileExtension]");
} else if !format.is_sequence() && old.is_sequence() {
self.output = self.output.replace("_[#####]", "").replace("[#####]", "");
}
return;
}
let p = std::path::Path::new(&self.output);
let mut stem = p.with_extension("").to_string_lossy().to_string();
if format.is_sequence() && !stem.contains('#') {
stem.push_str("_[#####]");
} else if !format.is_sequence() {
stem = stem.replace("_[#####]", "").replace("[#####]", "");
}
self.output = format!("{stem}.{}", format.extension());
}
/// One-line summary shown next to "Output Module:".
pub fn summary(&self) -> String {
let ch = match self.channels {
Channels::Rgb => "RGB",
Channels::Rgba => "RGB + Alpha",
};
match self.format {
OutputFormat::H264 => format!("H.264 · {} kbps", self.bitrate_kbps),
OutputFormat::ProRes => format!(
"{} · {ch}",
if self.channels == Channels::Rgba && !self.prores_profile.is_4444() { "Apple ProRes 4444" } else { self.prores_profile.label() }
),
f => format!("{} · {ch}", f.label()),
}
}
}
/// Render Queue item status (the Status column).
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub enum RenderStatus {
/// Render checkbox off.
Unqueued,
#[default]
Queued,
NeedsOutput,
Rendering,
Done,
UserStopped,
Failed(String),
}
impl RenderStatus {
pub fn label(&self) -> &str {
match self {
RenderStatus::Unqueued => "Unqueued",
RenderStatus::Queued => "Queued",
RenderStatus::NeedsOutput => "Needs Output",
RenderStatus::Rendering => "Rendering",
RenderStatus::Done => "Done",
RenderStatus::UserStopped => "User Stopped",
RenderStatus::Failed(_) => "Failed",
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RenderQueueItem {
/// Stable id (unique within the queue).
pub id: u64,
pub comp: ItemId,
/// The Render checkbox.
pub render: bool,
#[serde(default)]
pub status: RenderStatus,
#[serde(default)]
pub settings: RenderSettings,
#[serde(default)]
pub output: OutputModule,
/// Unix seconds when the last render of this item started.
#[serde(default)]
pub started: Option<u64>,
/// Seconds the last render took.
#[serde(default)]
pub render_time: Option<f64>,
/// Resolved path of the last render (first file for sequences).
#[serde(default)]
pub last_output: Option<String>,
}
impl RenderQueueItem {
pub fn new(id: u64, comp: ItemId) -> RenderQueueItem {
RenderQueueItem {
id,
comp,
render: true,
status: RenderStatus::Queued,
settings: RenderSettings::default(),
output: OutputModule::default(),
started: None,
render_time: None,
last_output: None,
}
}
/// Will be rendered by the next Render.
pub fn is_queued(&self) -> bool {
self.render && matches!(self.status, RenderStatus::Queued)
}
}
/// Values for template tokens.
pub struct TemplateVars<'a> {
pub comp_name: &'a str,
pub project_name: &'a str,
pub width: u32,
pub height: u32,
pub frame_rate: f64,
pub start_frame: i64,
pub end_frame: i64,
}
/// Expand template tokens (all but the `#` frame-number run).
pub fn expand_template(template: &str, format: OutputFormat, v: &TemplateVars) -> String {
let fps = if (v.frame_rate - v.frame_rate.round()).abs() < 1e-6 { format!("{}", v.frame_rate.round() as i64) } else { format!("{:.2}", v.frame_rate) };
template
.replace("[compName]", &sanitize(v.comp_name))
.replace("[projectName]", &sanitize(v.project_name))
.replace("[fileExtension]", format.extension())
.replace("[width]", &v.width.to_string())
.replace("[height]", &v.height.to_string())
.replace("[frameRate]", &fps)
.replace("[startFrame]", &v.start_frame.to_string())
.replace("[endFrame]", &v.end_frame.to_string())
.replace("[outputModuleName]", format.label())
}
/// Replace the first run of `#` (optionally in brackets, `[####]`) with the zero-padded frame
/// number. Paths without a run get `_NNNNN` before the extension.
pub fn sequence_path(path: &str, frame: i64) -> String {
if let Some(start) = path.find('#') {
let len = path[start..].chars().take_while(|c| *c == '#').count();
let (mut a, mut b) = (start, start + len);
if a > 0 && path.as_bytes()[a - 1] == b'[' && path.as_bytes().get(b) == Some(&b']') {
a -= 1;
b += 1;
}
return format!("{}{:0width$}{}", &path[..a], frame, &path[b..], width = len);
}
let p = std::path::Path::new(path);
match p.extension() {
Some(ext) => format!("{}_{frame:05}.{}", p.with_extension("").to_string_lossy(), ext.to_string_lossy()),
None => format!("{path}_{frame:05}"),
}
}
/// Make a name safe as a file name component.
pub fn sanitize(name: &str) -> String {
name.chars().map(|c| if matches!(c, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|') { '_' } else { c }).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn templates_and_sequences() {
let v = TemplateVars { comp_name: "Main/Title", project_name: "P", width: 1920, height: 1080, frame_rate: 29.97, start_frame: 0, end_frame: 9 };
assert_eq!(expand_template(DEFAULT_TEMPLATE, OutputFormat::H264, &v), "Main_Title.mp4");
assert_eq!(expand_template("[compName]_[width]x[height]@[frameRate].[fileExtension]", OutputFormat::Gif, &v), "Main_Title_1920x1080@29.97.gif");
assert_eq!(sequence_path("out/a_[#####].png", 42), "out/a_00042.png");
assert_eq!(sequence_path("a###.tif", 7), "a007.tif");
assert_eq!(sequence_path("a.png", 3), "a_00003.png");
}
#[test]
fn set_format_fixes_names() {
let mut m = OutputModule::default();
m.set_format(OutputFormat::PngSequence);
assert_eq!(m.output, "[compName]_[#####].[fileExtension]");
m.set_format(OutputFormat::ProRes);
assert_eq!(m.output, DEFAULT_TEMPLATE);
m.output = "/tmp/x.mov".into();
m.set_format(OutputFormat::JpegSequence);
assert_eq!(m.output, "/tmp/x_[#####].jpg");
assert_eq!(OutputFormat::from_name("TIFF"), Some(OutputFormat::TiffSequence));
assert_eq!(ProResProfile::from_name("4444"), Some(ProResProfile::P4444));
assert_eq!(ProResProfile::from_name("hq"), Some(ProResProfile::Hq));
}
#[test]
fn spans() {
let mut c = Comp::new(100, 50, FrameRate::new(25, 1), Tick::from_seconds_f64(4.0));
c.work_area = (Tick::from_seconds_f64(1.0), Tick::from_seconds_f64(2.0));
let mut s = RenderSettings::default();
assert_eq!(s.frame_count(&c), 25);
assert_eq!(s.frame_time(&c, 0), Tick::from_seconds_f64(1.0));
s.time_span = TimeSpan::LengthOfComp;
assert_eq!(s.frame_count(&c), 100);
s.frame_rate = Some(FrameRate::new(10, 1));
assert_eq!(s.frame_count(&c), 40);
s.resolution = 0.5;
assert_eq!(s.output_size(&c), (50, 25));
// NTSC: 1 s – 2 s holds the 30 frames that start inside it.
let c = Comp::new(100, 50, FrameRate::new(30000, 1001), Tick::from_seconds_f64(4.0));
let s = RenderSettings { time_span: TimeSpan::Custom { start: Tick::from_seconds_f64(1.0), end: Tick::from_seconds_f64(2.0) }, ..Default::default() };
assert_eq!(s.frame_count(&c), 30);
assert_eq!(s.first_frame(&c), 30);
assert!(s.frame_time(&c, 0) >= Tick::from_seconds_f64(1.0));
}
}

203
crates/render/src/audio.rs Normal file
View File

@ -0,0 +1,203 @@
//! Audio mixdown of a composition: every audible footage layer (and, recursively, precomp layer)
//! summed with its Audio Levels (dB, per channel), respecting in/out points, the Audio switch and
//! solo. Levels are evaluated once per call (callers mix in short blocks, e.g. one video frame).
//! Time-stretched layers are resampled nearest-sample; reversed (negative stretch) layers are
//! silent for now.
use effectcraft_project::{Comp, ItemId, ItemKind, Layer, LayerSource, Project};
use effectcraft_time::{TICKS_PER_SECOND, Tick};
use crate::{EvalCtx, ExprHost, FootageSource};
const MAX_DEPTH: usize = 16;
/// `frames` stereo sample frames of `comp`'s mix from comp time `start` at `rate` Hz,
/// interleaved (L R L R …).
pub fn mix_comp(project: &Project, footage: &dyn FootageSource, expr: Option<&dyn ExprHost>, comp: ItemId, start: Tick, frames: usize, rate: u32) -> Vec<f32> {
let mut out = vec![0.0f32; frames * 2];
mix_into(project, footage, expr, comp, start, rate, &mut out, 1.0, 1.0, 0);
out
}
/// Whether the comp has anything audible (footage with audio, or a precomp that has).
pub fn comp_has_audio(project: &Project, comp: ItemId) -> bool {
fn walk(project: &Project, comp: ItemId, depth: usize) -> bool {
let Some(c) = project.comp(comp) else { return false };
depth < MAX_DEPTH
&& c.layers.iter().any(|l| {
l.switches.audio
&& match &l.source {
LayerSource::Footage { item } => {
matches!(project.item(*item).map(|i| &i.kind), Some(ItemKind::Footage(f)) if f.has_audio && !f.missing)
}
LayerSource::Comp { item } => walk(project, *item, depth + 1),
_ => false,
}
})
}
walk(project, comp, 0)
}
fn audible(project: &Project, l: &Layer) -> bool {
l.switches.audio
&& match &l.source {
LayerSource::Footage { item } => matches!(project.item(*item).map(|i| &i.kind), Some(ItemKind::Footage(f)) if f.has_audio),
LayerSource::Comp { .. } => true,
_ => false,
}
}
fn db_to_gain(db: f64) -> f32 {
if db <= -96.0 { 0.0 } else { 10f64.powf(db / 20.0) as f32 }
}
fn units_ceil(t: Tick, rate: u32) -> i64 {
let n = t.0 as i128 * rate as i128;
let d = TICKS_PER_SECOND as i128;
(n.div_euclid(d) + i128::from(n.rem_euclid(d) != 0)) as i64
}
fn sample_tick(i: i64, rate: u32) -> Tick {
Tick(((i as i128 * TICKS_PER_SECOND as i128) / rate as i128) as i64)
}
#[allow(clippy::too_many_arguments)]
fn mix_into(
project: &Project,
footage: &dyn FootageSource,
expr: Option<&dyn ExprHost>,
comp_id: ItemId,
start: Tick,
rate: u32,
out: &mut [f32],
gl: f32,
gr: f32,
depth: usize,
) {
let Some(comp) = project.comp(comp_id) else { return };
if depth >= MAX_DEPTH || rate == 0 {
return;
}
let frames = out.len() / 2;
let any_solo = comp.layers.iter().any(|l| l.switches.solo && audible(project, l));
let s0 = start.to_units_floor(rate as i64);
for l in &comp.layers {
if !audible(project, l) || (any_solo && !l.switches.solo) || l.stretch <= 0.0 {
continue;
}
// Sample range of the block that falls inside the layer's [in, out).
let a = (units_ceil(l.in_point, rate) - s0).clamp(0, frames as i64) as usize;
let b = (units_ceil(l.out_point, rate) - s0).clamp(0, frames as i64) as usize;
if a >= b {
continue;
}
let t_a = sample_tick(s0 + a as i64, rate);
let (ll, lr) = levels(project, comp_id, comp, l, t_a, expr);
let (ll, lr) = (ll * gl, lr * gr);
if ll == 0.0 && lr == 0.0 {
continue;
}
let n = b - a;
let speed = 100.0 / l.stretch;
let src_n = ((n as f64 * speed).ceil() as usize).max(1);
let lt = l.layer_time(t_a);
let buf = match &l.source {
LayerSource::Footage { item } => match project.item(*item).map(|i| &i.kind) {
Some(ItemKind::Footage(f)) => footage.audio(*item, f, lt, src_n, rate),
_ => continue,
},
LayerSource::Comp { item } => {
if project.comp_contains(*item, comp_id) {
continue;
}
let mut sub = vec![0.0f32; src_n * 2];
mix_into(project, footage, expr, *item, lt, rate, &mut sub, 1.0, 1.0, depth + 1);
sub
}
_ => continue,
};
let same = (speed - 1.0).abs() < 1e-9;
for i in 0..n {
let j = if same { i } else { ((i as f64 * speed) as usize).min(src_n - 1) };
if j * 2 + 1 >= buf.len() {
break;
}
out[(a + i) * 2] += buf[j * 2] * ll;
out[(a + i) * 2 + 1] += buf[j * 2 + 1] * lr;
}
}
}
fn levels(project: &Project, comp_id: ItemId, comp: &Comp, l: &Layer, t: Tick, expr: Option<&dyn ExprHost>) -> (f32, f32) {
let Some(g) = l.props.sub("audio") else { return (1.0, 1.0) };
let mut ctx = EvalCtx::new(project, comp_id, comp, t);
ctx.expr = expr;
let [a, b] = ctx.v2(l, g, "levels", [0.0, 0.0]);
(db_to_gain(a), db_to_gain(b))
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use effectcraft_project::{Footage, FootageKind, build};
use effectcraft_time::FrameRate;
use super::*;
use crate::Image;
/// A 1 kHz-ish constant "tone": every sample is 0.25 (left) / -0.25 (right).
struct Dc;
impl FootageSource for Dc {
fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option<Arc<Image>> {
None
}
fn audio(&self, _: ItemId, _: &Footage, start: Tick, frames: usize, _: u32) -> Vec<f32> {
(0..frames).flat_map(|_| if start >= Tick::ZERO { [0.25, -0.25] } else { [0.0, 0.0] }).collect()
}
}
#[test]
fn mixes_layers_with_levels_and_in_out() {
let mut p = Project::default();
let f = Footage {
path: "x.wav".into(),
kind: FootageKind::Audio,
width: 0,
height: 0,
pixel_aspect: 1.0,
frame_rate: FrameRate::new(25, 1),
native_rate: None,
duration: Tick::from_seconds_f64(10.0),
has_video: false,
has_audio: true,
alpha: Default::default(),
premul_color: [0.0; 3],
loop_count: 1,
codec: String::new(),
missing: false,
sequence: vec![],
};
let fid = p.add_item("x.wav", effectcraft_color::Label::SeaFoam, None, ItemKind::Footage(f));
let comp = Comp::new(64, 64, FrameRate::new(25, 1), Tick::from_seconds_f64(2.0));
let mut l1 = build::layer(&mut p, &comp, "a", LayerSource::Footage { item: fid }, (0, 0), None);
l1.in_point = Tick::from_seconds_f64(1.0);
let mut l2 = build::layer(&mut p, &comp, "b", LayerSource::Footage { item: fid }, (0, 0), None);
if let Some(g) = l2.props.sub_mut("audio")
&& let Some(pr) = g.get_mut("levels")
{
pr.value = effectcraft_project::Value::Vec2([-6.0206, -96.0]);
}
let mut comp = comp;
comp.layers = vec![l1, l2];
let cid = p.add_item("C", effectcraft_color::Label::Sandstone, None, ItemKind::Comp(comp.into()));
assert!(comp_has_audio(&p, cid));
let rate = 1000;
let m = mix_comp(&p, &Dc, None, cid, Tick::from_seconds_f64(0.5), 1000, rate);
// first half: only layer b (half gain left, muted right)
assert!((m[0] - 0.125).abs() < 1e-3 && m[1].abs() < 1e-6, "{} {}", m[0], m[1]);
// second half (≥ 1 s): a + b
let i = 700 * 2;
assert!((m[i] - 0.375).abs() < 1e-3 && (m[i + 1] + 0.25).abs() < 1e-3, "{} {}", m[i], m[i + 1]);
}
}

View File

@ -6,6 +6,7 @@
//! with motion blur sub-samples) → **track matte** → **blend** with the layer's mode and opacity.
//! Adjustment layers run their effects on everything below, limited by their own bounds/masks.
pub mod audio;
pub mod eval;
pub mod masks;
pub mod shapes;
@ -27,6 +28,11 @@ use rayon::prelude::*;
pub trait FootageSource: Send + Sync {
/// The frame of `item` at source time `t`, straight from the file (any size).
fn frame(&self, item: ItemId, footage: &Footage, t: Tick) -> Option<Arc<Image>>;
/// `frames` stereo sample frames of `item`'s audio from source time `start` at `rate` Hz,
/// interleaved L R L R… (silence where there is none). Default: no audio.
fn audio(&self, _item: ItemId, _footage: &Footage, _start: Tick, frames: usize, _rate: u32) -> Vec<f32> {
vec![0.0; frames * 2]
}
}
/// No footage available (renders footage layers as transparent).

View File

@ -400,6 +400,10 @@ impl EffectcraftApp {
let ctx = ui.ctx().clone();
self.auto.begin_frame();
self.frames.set_context(&ctx);
if self.session.render_job.is_some() {
self.session.poll_render();
ctx.request_repaint_after(std::time::Duration::from_millis(100));
}
self.handle_events(&ctx);
if let Some(rx) = self.command_inbox.take() {
while let Ok(id) = rx.try_recv() {

View File

@ -256,6 +256,25 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V
{
return file_dialog(app, id);
}
if id == "renderQueue.render" && params.get("wait").is_none() {
// The UI renders in the background; the panel shows progress.
let mut p = params.clone();
if let Some(m) = p.as_object_mut() {
m.insert("wait".into(), json!(false));
} else {
p = json!({"wait": false});
}
app.show_panel(PanelKind::RenderQueue);
return app.session.execute(id, p).map_err(|e| e.to_string());
}
if id == "renderQueue.add" {
let r = app.session.execute(id, params).map_err(|e| e.to_string());
match &r {
Ok(_) => app.show_panel(PanelKind::RenderQueue),
Err(e) => app.ui.status = e.clone(),
}
return r;
}
if id == "layer.rename" && params.get("name").is_none() {
crate::panels::timeline::begin_rename(app, ctx);
return Ok(Value::Null);

View File

@ -1,7 +1,6 @@
//! Smaller panels: Preview, Audio, History, Markers, Wiggler, Render Queue, the Home screen and
//! placeholders.
//! Smaller panels: Preview, Audio, History, Markers, Wiggler, the Home screen and placeholders.
use egui::{Align2, Color32, Rect, Sense, Stroke, pos2, vec2};
use egui::{Align2, Color32, Rect, Sense, pos2, vec2};
use serde_json::json;
use crate::dock::PanelKind;
@ -185,25 +184,6 @@ pub fn wiggler(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
app.auto.add("wiggler.apply", b, "Apply");
}
/// Render Queue: comps queued for export with output settings.
pub fn render_queue(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let t = app.tokens;
let p = ui.painter().with_clip_rect(rect);
let hdr = Rect::from_min_size(rect.min, vec2(rect.width(), 40.0));
p.text(pos2(hdr.min.x + 14.0, hdr.center().y), Align2::LEFT_CENTER, "Render Queue", Tokens::semibold(13.0), t.text);
let b = Rect::from_min_size(pos2(hdr.max.x - 100.0, hdr.min.y + 8.0), vec2(84.0, 24.0));
let _ = widgets::text_button(ui, b, "Render", true, &t, egui::Id::new("rq-render"));
app.auto.add("renderQueue.render", b, "Render");
p.line_segment([hdr.left_bottom(), hdr.right_bottom()], Stroke::new(1.0, t.separator));
p.text(
pos2(rect.min.x + 14.0, hdr.max.y + 20.0),
Align2::LEFT_CENTER,
"Composition ▸ Add to Render Queue (⌃⌘M) to queue a render.",
Tokens::ui(12.0),
t.text_faint,
);
}
/// Home screen: new/open, recent, and community links.
pub fn start_screen(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let t = app.tokens;

View File

@ -6,6 +6,7 @@ pub mod effects_presets;
pub mod info;
pub mod misc;
pub mod project;
pub mod render_queue;
pub mod text_panels;
pub mod timeline;
pub mod viewer;
@ -63,7 +64,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: PanelKind, rect: Rec
PanelKind::History => misc::history(app, ui, rect),
PanelKind::Markers => misc::markers(app, ui, rect),
PanelKind::Wiggler => misc::wiggler(app, ui, rect),
PanelKind::RenderQueue => misc::render_queue(app, ui, rect),
PanelKind::RenderQueue => render_queue::show(app, ui, rect),
other => misc::placeholder(app, ui, rect, other),
}
}

View File

@ -0,0 +1,504 @@
//! Render Queue panel, laid out like After Effects': a Current Render strip (progress bar,
//! elapsed / estimated remaining, Stop and Render buttons), the queue list (twirl, Render
//! checkbox, label, #, Comp Name, Status, Started, Render Time) with expandable Render Settings,
//! Output Module and Output To rows, and a status footer.
//!
//! Every control dispatches an engine `renderQueue.*` command (so agents can do the same) and
//! registers an automation id: `renderQueue.render`, `renderQueue.stop`,
//! `renderQueue.item.<n>.{row,twirl,render,renderSettings,outputModule,outputTo,outputPath}`.
use effectcraft_engine::project::render_queue::{AudioOutput, Channels, OutputFormat, ProResProfile, RenderQuality, RenderQueueItem, RenderStatus, TimeSpan};
use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2};
use serde_json::{Value, json};
use crate::theme::Tokens;
use crate::{EffectcraftApp, widgets};
const TOP_H: f32 = 74.0;
const HEAD_H: f32 = 22.0;
const ROW_H: f32 = 24.0;
const FOOT_H: f32 = 24.0;
/// `h:mm:ss` for a duration in seconds.
pub fn hms(secs: f64) -> String {
let s = secs.max(0.0).round() as u64;
format!("{}:{:02}:{:02}", s / 3600, s / 60 % 60, s % 60)
}
/// Unix seconds → `YYYY-MM-DD hh:mm:ss` (UTC).
pub fn utc_stamp(unix: u64) -> String {
let days = (unix / 86_400) as i64;
let rem = unix % 86_400;
// Civil-from-days (proleptic Gregorian).
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = yoe + era * 400 + i64::from(m <= 2);
format!("{y:04}-{m:02}-{d:02} {:02}:{:02}:{:02}", rem / 3600, rem / 60 % 60, rem % 60)
}
fn status_color(t: &Tokens, s: &RenderStatus) -> Color32 {
match s {
RenderStatus::Done => Color32::from_rgb(0x6c, 0xc0, 0x6c),
RenderStatus::Rendering => t.accent_hover,
RenderStatus::Failed(_) | RenderStatus::NeedsOutput => t.danger,
RenderStatus::UserStopped => t.warning,
RenderStatus::Unqueued => t.text_faint,
RenderStatus::Queued => t.text,
}
}
/// Render Settings menu: (label, params).
fn settings_menu(it: &RenderQueueItem) -> Vec<(String, Value)> {
let s = &it.settings;
let mark = |on: bool, l: &str| if on { format!("✓ {l}") } else { format!(" {l}") };
let mut v = vec![
(mark(s.quality == RenderQuality::Best, "Best Settings"), json!({"quality": "best"})),
(mark(s.quality == RenderQuality::Draft, "Draft Settings"), json!({"quality": "draft"})),
("-".into(), Value::Null),
];
for (l, k) in [("Full", "full"), ("Half", "half"), ("Third", "third"), ("Quarter", "quarter")] {
v.push((mark(s.resolution_label() == l, &format!("Resolution: {l}")), json!({"resolution": k})));
}
v.push(("-".into(), Value::Null));
v.push((mark(s.time_span == TimeSpan::WorkArea, "Time Span: Work Area Only"), json!({"timeSpan": "workArea"})));
v.push((mark(s.time_span == TimeSpan::LengthOfComp, "Time Span: Length of Comp"), json!({"timeSpan": "comp"})));
v.push(("-".into(), Value::Null));
v.push((mark(s.frame_rate.is_none(), "Frame Rate: Use comp's frame rate"), json!({"frameRate": null})));
for fps in [12.0, 15.0, 23.976, 24.0, 25.0, 29.97, 30.0, 50.0, 60.0] {
let on = s.frame_rate.is_some_and(|r| (r.as_f64() - fps).abs() < 0.01);
v.push((mark(on, &format!("Frame Rate: {fps}")), json!({"frameRate": fps})));
}
v.push(("-".into(), Value::Null));
v.push((mark(s.motion_blur, "Motion Blur: On for Checked Layers"), json!({"motionBlur": !s.motion_blur})));
v.push((mark(s.skip_existing, "Skip Existing Files"), json!({"skipExisting": !s.skip_existing})));
v
}
/// Output Module menu: (label, params).
fn output_menu(it: &RenderQueueItem, available: &[OutputFormat]) -> Vec<(String, Value)> {
let o = &it.output;
let mark = |on: bool, l: &str| if on { format!("✓ {l}") } else { format!(" {l}") };
let mut v = Vec::new();
for f in OutputFormat::ALL {
if available.contains(&f) {
v.push((mark(o.format == f, &format!("Format: {}", f.label())), json!({"format": format!("{f:?}")})));
}
}
v.push(("-".into(), Value::Null));
v.push((mark(o.channels == Channels::Rgb, "Channels: RGB"), json!({"channels": "rgb"})));
if o.format.supports_alpha() {
v.push((mark(o.channels == Channels::Rgba, "Channels: RGB + Alpha"), json!({"channels": "rgba"})));
}
match o.format {
OutputFormat::ProRes => {
v.push(("-".into(), Value::Null));
for p in ProResProfile::ALL {
v.push((mark(o.prores_profile == p, p.label()), json!({"proresProfile": format!("{p:?}")})));
}
}
OutputFormat::H264 => {
v.push(("-".into(), Value::Null));
for kbps in [2_000, 5_000, 10_000, 20_000, 40_000] {
v.push((mark(o.bitrate_kbps == kbps, &format!("Bitrate: {} Mbps", kbps / 1000)), json!({"bitrate": kbps})));
}
}
OutputFormat::JpegSequence => {
v.push(("-".into(), Value::Null));
for q in [60u8, 80, 90, 100] {
v.push((mark(o.quality == q, &format!("Quality: {q}")), json!({"quality": q})));
}
}
OutputFormat::Gif => {
v.push(("-".into(), Value::Null));
v.push((mark(o.gif_loop, "Loop Forever"), json!({"loop": !o.gif_loop})));
}
_ => {}
}
if o.format.supports_audio() {
v.push(("-".into(), Value::Null));
for (a, l) in [(AudioOutput::Auto, "auto"), (AudioOutput::On, "on"), (AudioOutput::Off, "off")] {
let label = match a {
AudioOutput::Auto => "Audio Output: Auto",
AudioOutput::On => "Audio Output: On (48 kHz stereo)",
AudioOutput::Off => "Audio Output: Off",
};
v.push((mark(o.audio == a, label), json!({"audio": l})));
}
}
v
}
const OUTPUT_TEMPLATES: [&str; 4] = [
"[compName].[fileExtension]",
"[compName]_[width]x[height].[fileExtension]",
"[projectName]_[compName].[fileExtension]",
"[compName]/[compName].[fileExtension]",
];
fn output_to_menu(it: &RenderQueueItem) -> Vec<(String, Value)> {
let mut v: Vec<(String, Value)> = OUTPUT_TEMPLATES
.iter()
.map(|t| {
let t = if it.output.format.is_sequence() { t.replace(".[fileExtension]", "_[#####].[fileExtension]") } else { t.to_string() };
(t.clone(), json!({"path": t}))
})
.collect();
v.push(("-".into(), Value::Null));
v.push(("Choose File…".into(), json!({"choose": true})));
v
}
pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let t = app.tokens;
let p = ui.painter().with_clip_rect(rect);
let queue: Vec<RenderQueueItem> = app.session.project.render_queue.clone();
let progress = app.session.render_progress();
let rendering = app.session.is_rendering();
let available = app.session.exporter.as_ref().map(|e| e.formats()).unwrap_or_default();
let mut actions: Vec<(&'static str, Value)> = Vec::new();
let state_id = egui::Id::new("rq-ui");
let (mut open, mut selected): (Vec<u64>, Option<u64>) = ui.data(|d| d.get_temp(state_id).unwrap_or_default());
// ---------------------------------------------------------- Current Render strip
let top = Rect::from_min_size(rect.min, vec2(rect.width(), TOP_H));
p.rect_filled(top, 0.0, t.tl_ruler_bg);
let x0 = rect.min.x + 12.0;
p.text(pos2(x0, top.min.y + 16.0), Align2::LEFT_CENTER, "Current Render", Tokens::semibold(12.5), t.text);
let cur_item = progress.as_ref().and_then(|p| p.current).and_then(|id| queue.iter().find(|i| i.id == id));
let cur_name = cur_item.and_then(|i| app.session.project.item(i.comp)).map(|i| i.name.clone());
if let Some(pr) = &progress {
let rem = pr.remaining.map(hms).unwrap_or_else(|| "–".into());
p.text(
pos2(top.max.x - 12.0, top.min.y + 16.0),
Align2::RIGHT_CENTER,
format!("Elapsed: {} Est. Remain: {rem}", hms(pr.item_elapsed)),
Tokens::ui(11.5),
t.text_dim,
);
}
let bw = 76.0;
let render_b = Rect::from_min_size(pos2(top.max.x - 12.0 - bw, top.min.y + 34.0), vec2(bw, 26.0));
let stop_b = Rect::from_min_size(pos2(render_b.min.x - 8.0 - bw, render_b.min.y), vec2(bw, 26.0));
let can_render = !rendering && queue.iter().any(RenderQueueItem::is_queued) && !available.is_empty();
if widgets::text_button(ui, render_b, "Render", can_render, &t, egui::Id::new("rq-render")).clicked() && can_render {
actions.push(("renderQueue.render", json!({"wait": false})));
}
app.auto.add("renderQueue.render", render_b, "Render");
if rendering {
if widgets::text_button(ui, stop_b, "Stop", false, &t, egui::Id::new("rq-stop")).clicked() {
actions.push(("renderQueue.stop", json!({})));
}
app.auto.add("renderQueue.stop", stop_b, "Stop");
}
// Progress bar.
let bar =
Rect::from_min_max(pos2(x0, render_b.min.y + 4.0), pos2(if rendering { stop_b.min.x - 16.0 } else { render_b.min.x - 16.0 }, render_b.max.y - 4.0));
p.rect_filled(bar, 3.0, t.field_bg);
p.rect_stroke(bar, 3.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside);
let frac = progress.as_ref().filter(|p| p.total > 0).map(|p| (p.done as f32 / p.total as f32).clamp(0.0, 1.0)).unwrap_or(0.0);
if frac > 0.0 {
let fill = Rect::from_min_size(bar.min, vec2(bar.width() * frac, bar.height()));
p.rect_filled(fill, 3.0, t.accent);
}
let bar_text = match (&progress, &cur_name) {
(Some(pr), Some(n)) if rendering => format!("Rendering “{n}” {} / {} frames", pr.done, pr.total),
_ => {
let n = queue.iter().filter(|i| i.is_queued()).count();
if available.is_empty() {
"Export is not available in this build".to_string()
} else if n == 0 {
"Composition ▸ Add to Render Queue (Ctrl/⌘ M) to queue a render".to_string()
} else {
format!("{n} item(s) queued")
}
}
};
p.text(pos2(bar.min.x + 8.0, bar.center().y), Align2::LEFT_CENTER, bar_text, Tokens::ui(11.5), t.text);
app.auto.add("renderQueue.progress", bar, &format!("{:.0}%", frac * 100.0));
// ---------------------------------------------------------- column header
let head = Rect::from_min_size(pos2(rect.min.x, top.max.y), vec2(rect.width(), HEAD_H));
p.rect_filled(head, 0.0, t.panel_bg);
p.line_segment([head.left_bottom(), head.right_bottom()], Stroke::new(1.0, t.separator));
// Columns: twirl | Render | label | # | Comp Name | Status | Started | Render Time
let cx_twirl = rect.min.x + 4.0;
let cx_render = cx_twirl + 20.0;
let cx_label = cx_render + 48.0;
let cx_num = cx_label + 20.0;
let cx_name = cx_num + 28.0;
let w_time = 92.0;
let w_started = 156.0;
let w_status = 112.0;
let cx_time = (rect.max.x - w_time).max(cx_name + 120.0);
let cx_started = cx_time - w_started;
let cx_status = cx_started - w_status;
let hy = head.center().y;
for (x, l) in [(cx_render, "Render"), (cx_num, "#"), (cx_name, "Comp Name"), (cx_status, "Status"), (cx_started, "Started"), (cx_time, "Render Time")] {
p.text(pos2(x + 2.0, hy), Align2::LEFT_CENTER, l, Tokens::ui(11.0), t.text_dim);
}
for x in [cx_render, cx_label, cx_num, cx_name, cx_status, cx_started, cx_time] {
p.line_segment([pos2(x - 3.0, head.min.y + 4.0), pos2(x - 3.0, head.max.y - 4.0)], Stroke::new(1.0, t.separator));
}
// ---------------------------------------------------------- items
let list = Rect::from_min_max(pos2(rect.min.x, head.max.y), pos2(rect.max.x, rect.max.y - FOOT_H));
let lp = p.with_clip_rect(list);
let mut y = list.min.y;
if queue.is_empty() {
lp.text(pos2(list.center().x, list.min.y + 30.0), Align2::CENTER_CENTER, "The render queue is empty.", Tokens::ui(12.0), t.text_faint);
}
let editing_id = egui::Id::new("rq-edit-output");
let mut editing: Option<(u64, String)> = ui.data(|d| d.get_temp(editing_id));
for (k, it) in queue.iter().enumerate() {
let n = k + 1;
let aid = |s: &str| format!("renderQueue.item.{n}.{s}");
let row = Rect::from_min_size(pos2(list.min.x, y), vec2(list.width(), ROW_H));
y += ROW_H;
let is_sel = selected == Some(it.id);
lp.rect_filled(
row,
0.0,
if is_sel {
t.row_selected
} else if k % 2 == 0 {
t.row
} else {
t.row_alt
},
);
let row_resp = ui.interact(row, egui::Id::new(("rq-row", it.id)), Sense::click());
if row_resp.clicked() {
selected = Some(it.id);
app.ui.focused = crate::dock::PanelKind::RenderQueue;
}
let item_id = it.id;
row_resp.context_menu(|ui| {
if ui.button("Duplicate").clicked() {
actions.push(("renderQueue.duplicate", json!({"item": item_id})));
ui.close();
}
if ui.button("Remove").clicked() {
actions.push(("renderQueue.remove", json!({"item": item_id})));
ui.close();
}
if k > 0 && ui.button("Move Up").clicked() {
actions.push(("renderQueue.move", json!({"item": item_id, "to": n - 1})));
ui.close();
}
if n < queue.len() && ui.button("Move Down").clicked() {
actions.push(("renderQueue.move", json!({"item": item_id, "to": n + 1})));
ui.close();
}
});
app.auto.add(&aid("row"), row, &format!("#{n}"));
let is_open = open.contains(&it.id);
let tw = Rect::from_center_size(pos2(cx_twirl + 8.0, row.center().y), vec2(14.0, 14.0));
if widgets::twirl(ui, tw, is_open, egui::Id::new(("rq-twirl", it.id)), &t).clicked() {
if is_open {
open.retain(|i| *i != it.id);
} else {
open.push(it.id);
}
}
app.auto.add(&aid("twirl"), tw, "Twirl");
let cb = Rect::from_center_size(pos2(cx_render + 20.0, row.center().y), vec2(18.0, 18.0));
if widgets::checkbox(ui, cb, it.render, &t, egui::Id::new(("rq-cb", it.id))).clicked() && !rendering {
actions.push(("renderQueue.setRender", json!({"item": it.id, "render": !it.render})));
}
app.auto.add(&aid("render"), cb, if it.render { "Render: on" } else { "Render: off" });
let comp_item = app.session.project.item(it.comp);
let label_col = comp_item.map(|i| Tokens::label(i.label)).unwrap_or(t.text_faint);
lp.rect_filled(Rect::from_center_size(pos2(cx_label + 6.0, row.center().y), vec2(10.0, 10.0)), 2.0, label_col);
lp.text(pos2(cx_num + 2.0, row.center().y), Align2::LEFT_CENTER, n.to_string(), Tokens::ui(12.0), t.text_dim);
let name = comp_item.map(|i| i.name.clone()).unwrap_or_else(|| "(missing composition)".into());
lp.text(pos2(cx_name + 2.0, row.center().y), Align2::LEFT_CENTER, &name, Tokens::ui(12.0), t.text);
let status = if rendering && progress.as_ref().and_then(|p| p.current) == Some(it.id) {
let pr = progress.as_ref().expect("progress");
format!("Rendering {:.0}%", if pr.total > 0 { pr.done as f64 * 100.0 / pr.total as f64 } else { 0.0 })
} else {
it.status.label().to_string()
};
let st_rect = Rect::from_min_size(pos2(cx_status, row.min.y), vec2(w_status, ROW_H));
lp.text(pos2(cx_status + 2.0, row.center().y), Align2::LEFT_CENTER, &status, Tokens::ui(12.0), status_color(&t, &it.status));
if let RenderStatus::Failed(e) = &it.status {
ui.interact(st_rect, egui::Id::new(("rq-st", it.id)), Sense::hover()).on_hover_text(e);
}
app.auto.add(&aid("status"), st_rect, &status);
if let Some(s) = it.started {
lp.text(pos2(cx_started + 2.0, row.center().y), Align2::LEFT_CENTER, utc_stamp(s), Tokens::ui(11.5), t.text_dim);
} else {
lp.text(pos2(cx_started + 2.0, row.center().y), Align2::LEFT_CENTER, "-", Tokens::ui(11.5), t.text_faint);
}
let rt = if progress.as_ref().and_then(|p| p.current) == Some(it.id) && rendering {
progress.as_ref().map(|p| hms(p.item_elapsed)).unwrap_or_default()
} else {
it.render_time.map(|s| if s < 60.0 { format!("{s:.1} s") } else { hms(s) }).unwrap_or_else(|| "-".into())
};
lp.text(pos2(cx_time + 2.0, row.center().y), Align2::LEFT_CENTER, rt, Tokens::ui(11.5), t.text_dim);
if !is_open {
continue;
}
// Expanded: Render Settings / Output Module + Output To.
let ix = cx_name;
for sub in 0..2 {
let r = Rect::from_min_size(pos2(list.min.x, y), vec2(list.width(), ROW_H));
y += ROW_H;
lp.rect_filled(r, 0.0, t.panel_bg);
let (label, summary, key, menu): (&str, String, &str, Vec<(String, Value)>) = if sub == 0 {
("Render Settings:", it.settings.summary(), "renderSettings", settings_menu(it))
} else {
("Output Module:", it.output.summary(), "outputModule", output_menu(it, &available))
};
lp.text(pos2(cx_num - 6.0, r.center().y), Align2::LEFT_CENTER, label, Tokens::ui(11.5), t.text_dim);
let dd = Rect::from_min_size(pos2(ix + 70.0, r.min.y + 3.0), vec2(220.0, ROW_H - 6.0));
let did = egui::Id::new(("rq-dd", it.id, sub));
if widgets::dropdown(ui, dd, &summary, &t, did).clicked() && !rendering {
widgets::open_popup(ui, did);
}
let labels: Vec<String> = menu.iter().map(|(l, _)| l.clone()).collect();
if let Some(i) = widgets::popup_menu(ui, did, dd.left_bottom(), &labels, None) {
let mut params = menu[i].1.clone();
params["item"] = json!(it.id);
actions.push((if sub == 0 { "renderQueue.setRenderSettings" } else { "renderQueue.setOutputModule" }, params));
}
app.auto.add(&aid(key), dd, &summary);
if sub == 0 {
let comp = app.session.project.comp(it.comp);
if let Some(c) = comp {
let (w, h) = it.settings.output_size(c);
let (a, b) = it.settings.span(c);
let info = format!(
"{w}×{h} · {:.3} fps · {:.2}s – {:.2}s · {} frames",
it.settings.rate(c).as_f64(),
a.seconds(),
b.seconds(),
it.settings.frame_count(c)
);
lp.text(pos2(dd.max.x + 14.0, r.center().y), Align2::LEFT_CENTER, info, Tokens::ui(11.0), t.text_faint);
}
continue;
}
// Output To.
let ox = dd.max.x + 14.0;
lp.text(pos2(ox, r.center().y), Align2::LEFT_CENTER, "Output To:", Tokens::ui(11.5), t.text_dim);
let odd = Rect::from_min_size(pos2(ox + 62.0, r.min.y + 3.0), vec2(22.0, ROW_H - 6.0));
let oid = egui::Id::new(("rq-out", it.id));
if widgets::dropdown(ui, odd, "", &t, oid).clicked() && !rendering {
widgets::open_popup(ui, oid);
}
app.auto.add(&aid("outputTo"), odd, "Output To");
let omenu = output_to_menu(it);
let olabels: Vec<String> = omenu.iter().map(|(l, _)| l.clone()).collect();
if let Some(i) = widgets::popup_menu(ui, oid, odd.left_bottom(), &olabels, None) {
if omenu[i].1.get("choose").is_some() {
let default = app.session.resolve_output(it).unwrap_or_default();
let picked = app
.hooks
.pick_save
.as_ref()
.and_then(|f| f(std::path::Path::new(&default).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default().as_str()));
match picked {
Some(path) => actions.push(("renderQueue.setOutput", json!({"item": it.id, "path": path}))),
None if app.hooks.pick_save.is_none() => editing = Some((it.id, it.output.output.clone())),
None => {}
}
} else {
let mut params = omenu[i].1.clone();
params["item"] = json!(it.id);
actions.push(("renderQueue.setOutput", params));
}
}
let path_rect = Rect::from_min_max(pos2(odd.max.x + 6.0, r.min.y + 2.0), pos2(r.max.x - 8.0, r.max.y - 2.0));
if editing.as_ref().is_some_and(|(id, _)| *id == it.id) {
let mut buf = editing.as_ref().map(|(_, b)| b.clone()).unwrap_or_default();
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(path_rect));
let resp = child.add(egui::TextEdit::singleline(&mut buf).desired_width(path_rect.width()).font(Tokens::ui(11.5)));
if resp.lost_focus() {
if ui.input(|i| i.key_pressed(egui::Key::Enter)) && !buf.trim().is_empty() {
actions.push(("renderQueue.setOutput", json!({"item": it.id, "path": buf.trim()})));
}
editing = None;
} else {
resp.request_focus();
editing = Some((it.id, buf));
}
} else {
let shown = app.session.resolve_output(it).unwrap_or_else(|| "Not yet specified".into());
let resp = ui.interact(path_rect, egui::Id::new(("rq-path", it.id)), Sense::click());
let col = if resp.hovered() { t.accent_hover } else { t.hot_text };
lp.with_clip_rect(path_rect.intersect(list)).text(pos2(path_rect.min.x, r.center().y), Align2::LEFT_CENTER, &shown, Tokens::ui(11.5), col);
if resp.clicked() && !rendering {
editing = Some((it.id, it.output.output.clone()));
}
resp.on_hover_text(format!("Template: {}\nClick to edit", it.output.output));
app.auto.add(&aid("outputPath"), path_rect, &shown);
}
}
}
ui.data_mut(|d| match &editing {
Some(e) => {
d.insert_temp(editing_id, e.clone());
}
None => d.remove::<(u64, String)>(editing_id),
});
// Delete removes the selected item when the panel has focus.
if app.ui.focused == crate::dock::PanelKind::RenderQueue
&& editing.is_none()
&& !rendering
&& let Some(id) = selected
&& ui.input(|i| i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace))
&& !ui.ctx().egui_wants_keyboard_input()
{
actions.push(("renderQueue.remove", json!({"item": id})));
selected = None;
}
// ---------------------------------------------------------- footer
let foot = Rect::from_min_max(pos2(rect.min.x, rect.max.y - FOOT_H), rect.max);
p.rect_filled(foot, 0.0, t.tl_ruler_bg);
p.line_segment([foot.left_top(), foot.right_top()], Stroke::new(1.0, t.separator));
let msg = match &progress {
Some(pr) if rendering => format!("Message: Rendering {} of {}", (pr.items_done + 1).min(pr.items_total), pr.items_total),
_ => {
let done = queue.iter().filter(|i| i.status == RenderStatus::Done).count();
format!("Message: {done} of {} done", queue.len())
}
};
p.text(pos2(foot.min.x + 12.0, foot.center().y), Align2::LEFT_CENTER, msg, Tokens::ui(11.0), t.text_dim);
let started = queue.iter().filter(|i| i.started.is_some()).count();
let total: f64 = queue.iter().filter_map(|i| i.render_time).sum();
p.text(
pos2(foot.max.x - 12.0, foot.center().y),
Align2::RIGHT_CENTER,
format!("Renders Started: {started} Total Time Elapsed: {}", hms(progress.as_ref().filter(|_| rendering).map(|p| p.elapsed).unwrap_or(total))),
Tokens::ui(11.0),
t.text_dim,
);
open.retain(|id| queue.iter().any(|i| i.id == *id));
ui.data_mut(|d| d.insert_temp(state_id, (open, selected)));
let ctx = ui.ctx().clone();
for (id, params) in actions {
if let Err(e) = crate::menus::invoke(app, &ctx, id, params) {
app.ui.status = e;
}
}
}
#[cfg(test)]
mod tests {
#[test]
fn stamps() {
assert_eq!(super::utc_stamp(0), "1970-01-01 00:00:00");
assert_eq!(super::utc_stamp(1_700_000_000), "2023-11-14 22:13:20");
assert_eq!(super::hms(3725.4), "1:02:05");
}
}

View File

@ -0,0 +1,43 @@
//! Headless look at the Render Queue panel (wgpu offscreen). Ignored by default (needs a GPU
//! adapter); run with
//! `RQ_SNAPSHOT=/abs/out.png cargo test -p effectcraft-ui-egui --test render_queue_snapshot -- --ignored`.
use effectcraft_engine::Session;
use effectcraft_engine::project::render_queue::RenderStatus;
use effectcraft_ui_egui::EffectcraftApp;
use egui_kittest::Harness;
use serde_json::json;
#[test]
#[ignore]
fn render_queue_panel_snapshot() {
let mut s = Session::default();
s.execute("file.openDemoProject", json!({})).unwrap();
s.execute("renderQueue.add", json!({"output": "/renders/[compName].[fileExtension]", "resolution": "half", "timeSpan": "comp"})).unwrap();
s.execute("renderQueue.add", json!({"format": "prores", "channels": "rgba", "output": "/renders/[compName]_alpha.mov"})).unwrap();
s.execute("renderQueue.add", json!({"format": "png", "output": "/renders/frames/[compName]_[#####].png", "timeSpan": "workArea"})).unwrap();
s.execute("renderQueue.add", json!({"format": "gif", "resolution": "quarter"})).unwrap();
{
let p = std::sync::Arc::make_mut(&mut s.project);
p.render_queue[0].status = RenderStatus::Done;
p.render_queue[0].started = Some(1_790_000_000);
p.render_queue[0].render_time = Some(12.4);
p.render_queue[1].status = RenderStatus::Failed("disk full".into());
p.render_queue[1].started = Some(1_790_000_013);
p.render_queue[1].render_time = Some(0.8);
p.render_queue[3].render = false;
p.render_queue[3].status = RenderStatus::Unqueued;
}
let ids: Vec<u64> = s.project.render_queue.iter().take(3).map(|i| i.id).collect();
let mut app = Some(EffectcraftApp::new(s));
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| app.take().expect("app"));
h.ctx.data_mut(|d| d.insert_temp(egui::Id::new("rq-ui"), (ids, None::<u64>)));
h.state_mut().show_panel(effectcraft_ui_egui::dock::PanelKind::RenderQueue);
h.run_steps(4);
let img = h.render().expect("render");
let out = std::env::var("RQ_SNAPSHOT").unwrap_or_else(|_| concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/test-out/render_queue.png").into());
if let Some(d) = std::path::Path::new(&out).parent() {
std::fs::create_dir_all(d).unwrap();
}
img.save(&out).unwrap();
}