From 7f9c3a85c005723fe262c588b19ba98d8a520d90 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 22:02:06 -0400 Subject: [PATCH] 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 --- Cargo.lock | 33 ++ Cargo.toml | 19 +- apps/effectcraft-cli/Cargo.toml | 1 + apps/effectcraft-cli/src/main.rs | 124 ++++- crates/engine/src/commands/mod.rs | 4 +- crates/engine/src/commands/render_queue.rs | 428 ++++++++++++++ crates/engine/src/lib.rs | 17 + crates/engine/src/render_queue.rs | 344 ++++++++++++ crates/engine/src/rq_tests.rs | 164 ++++++ crates/export/Cargo.toml | 29 + crates/export/src/encode.rs | 362 ++++++++++++ crates/export/src/lib.rs | 350 ++++++++++++ crates/export/tests/roundtrip.rs | 365 ++++++++++++ crates/host/Cargo.toml | 1 + crates/host/src/lib.rs | 85 ++- crates/media/src/pool.rs | 3 + crates/project/src/lib.rs | 6 +- crates/project/src/render_queue.rs | 525 ++++++++++++++++++ crates/render/src/audio.rs | 203 +++++++ crates/render/src/lib.rs | 6 + crates/ui-egui/src/lib.rs | 4 + crates/ui-egui/src/menus.rs | 19 + crates/ui-egui/src/panels/misc.rs | 24 +- crates/ui-egui/src/panels/mod.rs | 3 +- crates/ui-egui/src/panels/render_queue.rs | 504 +++++++++++++++++ crates/ui-egui/tests/render_queue_snapshot.rs | 43 ++ 26 files changed, 3629 insertions(+), 37 deletions(-) create mode 100644 crates/engine/src/commands/render_queue.rs create mode 100644 crates/engine/src/render_queue.rs create mode 100644 crates/engine/src/rq_tests.rs create mode 100644 crates/export/Cargo.toml create mode 100644 crates/export/src/encode.rs create mode 100644 crates/export/src/lib.rs create mode 100644 crates/export/tests/roundtrip.rs create mode 100644 crates/project/src/render_queue.rs create mode 100644 crates/render/src/audio.rs create mode 100644 crates/ui-egui/src/panels/render_queue.rs create mode 100644 crates/ui-egui/tests/render_queue_snapshot.rs diff --git a/Cargo.lock b/Cargo.lock index b29b9181..210ba196 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 4165cfef..b042eff0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 diff --git a/apps/effectcraft-cli/Cargo.toml b/apps/effectcraft-cli/Cargo.toml index c5bc28d0..ab97e6c5 100644 --- a/apps/effectcraft-cli/Cargo.toml +++ b/apps/effectcraft-cli/Cargo.toml @@ -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 } diff --git a/apps/effectcraft-cli/src/main.rs b/apps/effectcraft-cli/src/main.rs index dc010d58..d7f806b1 100644 --- a/apps/effectcraft-cli/src/main.rs +++ b/apps/effectcraft-cli/src/main.rs @@ -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] '' '' … (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 [args] (see --help in source docs)"); + eprintln!( + "usage: effectcraft-cli [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] '' '' … + 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::().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::().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 = 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") { diff --git a/crates/engine/src/commands/mod.rs b/crates/engine/src/commands/mod.rs index 1a7b41a8..452d8b71 100644 --- a/crates/engine/src/commands/mod.rs +++ b/crates/engine/src/commands/mod.rs @@ -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))) } diff --git a/crates/engine/src/commands/render_queue.rs b/crates/engine/src/commands/render_queue.rs new file mode 100644 index 00000000..10c44eb7 --- /dev/null +++ b/crates/engine/src/commands/render_queue.rs @@ -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 { + 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 { + f_p(p, k).map(Tick::from_seconds_f64) +} + +/// Apply Render Settings parameters. +fn apply_settings(rs: &mut RenderSettings, p: &Value, cmd: &str) -> Result { + 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|")), + }; + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + let wait = b_p(p, "wait").unwrap_or(true); + let ids = s.start_render(wait).map_err(EngineError::Other)?; + let items: Vec = 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 { + Ok(json!({"stopped": s.stop_render()})) +} + +fn list(s: &mut Session, _: &Value) -> Result { + s.poll_render(); + let items: Vec = 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 { + let avail = s.exporter.as_ref().map(|e| e.formats()).unwrap_or_default(); + let v: Vec = 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::>()})) +} + +pub fn specs() -> Vec { + 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), + ] +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index f93caa18..e2725987 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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, pub expr: Option>, pub importer: Option>, + /// Render Queue encoder (the export layer); `None` = export unavailable. + pub exporter: Option>, + /// The running (or finished, not yet polled) render. + pub render_job: Option, pub events: Vec, /// 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) { + 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; diff --git a/crates/engine/src/render_queue.rs b/crates/engine/src/render_queue.rs new file mode 100644 index 00000000..49241e13 --- /dev/null +++ b/crates/engine/src/render_queue.rs @@ -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; + /// 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; +} + +/// 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, + /// 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, + /// 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 }, +} + +#[derive(Default)] +pub struct JobShared { + pub cancel: AtomicBool, + pub state: Mutex, + pub updates: Mutex>, +} + +fn lock(m: &Mutex) -> 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, + thread: Option>, +} + +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, + footage: Arc, + expr: Option>, + exporter: Arc, + 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 { + 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 { + 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, 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 = 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 { + 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) + } +} diff --git a/crates/engine/src/rq_tests.rs b/crates/engine/src/rq_tests.rs new file mode 100644 index 00000000..c4aa08b8 --- /dev/null +++ b/crates/engine/src/rq_tests.rs @@ -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>, +} + +impl Exporter for MockExporter { + fn formats(&self) -> Vec { + OutputFormat::ALL.to_vec() + } + fn export(&self, job: &ExportJob, progress: &mut dyn FnMut(u64, u64) -> bool) -> Result { + 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) { + 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::ALL.to_vec() + } + fn export(&self, _: &ExportJob, progress: &mut dyn FnMut(u64, u64) -> bool) -> Result { + 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); +} diff --git a/crates/export/Cargo.toml b/crates/export/Cargo.toml new file mode 100644 index 00000000..5534fd02 --- /dev/null +++ b/crates/export/Cargo.toml @@ -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 } diff --git a/crates/export/src/encode.rs b/crates/export/src/encode.rs new file mode 100644 index 00000000..e00bb93c --- /dev/null +++ b/crates/export/src/encode.rs @@ -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, + 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>; + fn flush(&mut self) -> Result>; + /// Edit-list media start (B-frame delay) in the track timescale. + fn media_start(&self) -> Option { + None + } +} + +// ---------------------------------------------------------------- H.264 + +struct H264 { + enc: filmcraft_h264enc::Encoder, + w: u32, + h: u32, + rate: FrameRate, + y: Vec, + u: Vec, + v: Vec, +} + +impl H264 { + fn new(w: u32, h: u32, rate: FrameRate, kbps: u32) -> Result { + 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) -> Vec { + 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> { + 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> { + let ps = self.enc.flush(); + Ok(self.packets(ps)) + } + fn media_start(&self) -> Option { + (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, u: &mut Vec, v: &mut Vec) { + 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> { + 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> { + 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), + Pcm, +} + +const AUDIO_CHANNELS: u32 = 2; + +fn deinterleave(buf: &[f32]) -> [Vec; 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>; + +pub(crate) fn movie(job: &Job, comp: &Comp, w: u32, h: u32, st: &mut State) -> Result { + 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 = 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 { 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> = (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| -> 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> = (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]); + } +} diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs new file mode 100644 index 00000000..051183a6 --- /dev/null +++ b/crates/export/src/lib.rs @@ -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 = std::result::Result; + +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 { + (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::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 { + 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 { + 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, iw: u32, ih: u32, w: u32, h: u32) -> Vec { + 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 { + 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> = (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 = 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 { + 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> = (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 = (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)); + } +} diff --git a/crates/export/tests/roundtrip.rs b/crates/export/tests/roundtrip.rs new file mode 100644 index 00000000..4ee077d9 --- /dev/null +++ b/crates/export/tests/roundtrip.rs @@ -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 { + 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 { + 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) -> 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); + } +} diff --git a/crates/host/Cargo.toml b/crates/host/Cargo.toml index 642fab7a..b43271c7 100644 --- a/crates/host/Cargo.toml +++ b/crates/host/Cargo.toml @@ -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 } diff --git a/crates/host/src/lib.rs b/crates/host/src/lib.rs index 15ef3f54..ffc13412 100644 --- a/crates/host/src/lib.rs +++ b/crates/host/src/lib.rs @@ -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 { + effectcraft_export::available_formats() + } + fn export(&self, job: &ExportJob, progress: &mut dyn FnMut(u64, u64) -> bool) -> Result { + 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); + } } diff --git a/crates/media/src/pool.rs b/crates/media/src/pool.rs index 37787442..fdc2c91c 100644 --- a/crates/media/src/pool.rs +++ b/crates/media/src/pool.rs @@ -454,6 +454,9 @@ impl FootageSource for MediaPool { } } } + fn audio(&self, _item: ItemId, footage: &Footage, start: Tick, frames: usize, rate: u32) -> Vec { + self.audio_samples(footage, start, frames, rate) + } } fn lock(m: &Mutex) -> std::sync::MutexGuard<'_, T> { diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index 0575e7b4..800a4808 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -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, pub next_id: u64, + /// The Render Queue (Composition ▸ Add to Render Queue). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub render_queue: Vec, } 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() } } } diff --git a/crates/project/src/render_queue.rs b/crates/project/src/render_queue.rs new file mode 100644 index 00000000..1eac5be1 --- /dev/null +++ b/crates/project/src/render_queue.rs @@ -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, + /// 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 { + 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 { + 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 { + 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, + /// Seconds the last render took. + #[serde(default)] + pub render_time: Option, + /// Resolved path of the last render (first file for sequences). + #[serde(default)] + pub last_output: Option, +} + +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)); + } +} diff --git a/crates/render/src/audio.rs b/crates/render/src/audio.rs new file mode 100644 index 00000000..1a9467b2 --- /dev/null +++ b/crates/render/src/audio.rs @@ -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 { + 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> { + None + } + fn audio(&self, _: ItemId, _: &Footage, start: Tick, frames: usize, _: u32) -> Vec { + (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]); + } +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 83acd335..dd1e9ef8 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -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>; + /// `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 { + vec![0.0; frames * 2] + } } /// No footage available (renders footage layers as transparent). diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e809e8d2..4b81029a 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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() { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 321cee16..569d4911 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -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); diff --git a/crates/ui-egui/src/panels/misc.rs b/crates/ui-egui/src/panels/misc.rs index 60205270..99edc106 100644 --- a/crates/ui-egui/src/panels/misc.rs +++ b/crates/ui-egui/src/panels/misc.rs @@ -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; diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 9d4e3848..643cdf93 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -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), } } diff --git a/crates/ui-egui/src/panels/render_queue.rs b/crates/ui-egui/src/panels/render_queue.rs new file mode 100644 index 00000000..4a340042 --- /dev/null +++ b/crates/ui-egui/src/panels/render_queue.rs @@ -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..{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 = 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, Option) = 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 = 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 = 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"); + } +} diff --git a/crates/ui-egui/tests/render_queue_snapshot.rs b/crates/ui-egui/tests/render_queue_snapshot.rs new file mode 100644 index 00000000..5029a5f2 --- /dev/null +++ b/crates/ui-egui/tests/render_queue_snapshot.rs @@ -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 = 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::))); + 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(); +}