M10: render queue + export
- crates/export (effectcraft-export): render a comp's span to H.264 MP4 (+AAC), ProRes 422/4444 MOV (+PCM, alpha), PNG/JPEG/TIFF/EXR sequences and animated GIF via FilmCraft's pure-Rust encoders and MP4/MOV muxer; parallel batched frame rendering, progress callback with cancellation. - project: Render Queue model (Render Settings, Output Module, output path templates, status) saved with the project. - render: FootageSource::audio hook + comp audio mixdown (levels, in/out, solo, precomps); media implements it. - engine: Exporter hook, background/blocking render job, renderQueue.* commands (add Cmd+M, remove, setRender, setRenderSettings, setOutputModule, setOutput, move, duplicate, render, stop, list, formats). - host wires the exporter; CLI `render` subcommand. - ui-egui: After Effects-style Render Queue panel with automation ids. - tests: encode/decode round trips for every format (FilmCraft decoders, ffmpeg/ffprobe oracle when installed), audio, cancel, engine commands. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
dee16181fe
commit
7f9c3a85c0
|
|
@ -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"
|
||||
|
|
|
|||
19
Cargo.toml
19
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -2,27 +2,141 @@
|
|||
//!
|
||||
//! ```text
|
||||
//! effectcraft-cli frame [project.ecproj | --demo] [--comp NAME] [--time S | --frame N] [--scale K] --out out.png
|
||||
//! effectcraft-cli render [project.ecproj | --demo] [--comp NAME] --out FILE [--format F] [--start S] [--end S]
|
||||
//! [--work-area] [--fps N] [--resolution full|half|third|quarter|K] [--quality best|draft] [--channels rgb|rgba]
|
||||
//! [--jpeg-quality N] [--bitrate KBPS] [--prores proxy|lt|standard|hq|4444|4444xq] [--audio auto|on|off]
|
||||
//! effectcraft-cli render project.ecproj --queue (renders the project's Render Queue)
|
||||
//! effectcraft-cli run [project.ecproj | --demo] '<command-id>' '<json params>' … (prints results)
|
||||
//! effectcraft-cli commands (lists commands)
|
||||
//! ```
|
||||
//!
|
||||
//! `render` formats: h264 (.mp4), prores (.mov), png / jpeg / tiff / exr sequences (`name_[#####].png`), gif.
|
||||
//! The format defaults to the `--out` extension; the time span to the whole comp.
|
||||
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_engine::project::render_queue::RenderStatus;
|
||||
use effectcraft_render::RenderOpts;
|
||||
use effectcraft_time::Tick;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn usage() -> ! {
|
||||
eprintln!("usage: effectcraft-cli <frame|run|commands> [args] (see --help in source docs)");
|
||||
eprintln!(
|
||||
"usage: effectcraft-cli <frame|render|run|commands> [args]
|
||||
|
||||
frame [project.ecproj|--demo] [--comp NAME] [--time S|--frame N] [--scale K] --out out.png
|
||||
render [project.ecproj|--demo] [--comp NAME] --out FILE [--format h264|prores|png|jpeg|tiff|exr|gif]
|
||||
[--start S] [--end S] [--work-area] [--fps N] [--resolution full|half|third|quarter|K]
|
||||
[--quality best|draft] [--channels rgb|rgba] [--jpeg-quality N] [--bitrate KBPS]
|
||||
[--prores proxy|lt|standard|hq|4444|4444xq] [--audio auto|on|off]
|
||||
render project.ecproj --queue (render the project's Render Queue)
|
||||
run [project.ecproj|--demo] '<command-id>' '<json params>' …
|
||||
commands"
|
||||
);
|
||||
std::process::exit(2)
|
||||
}
|
||||
|
||||
fn fail(msg: impl std::fmt::Display) -> ! {
|
||||
eprintln!("effectcraft: {msg}");
|
||||
std::process::exit(1)
|
||||
}
|
||||
|
||||
/// Queue `--comp` (or the active comp) with the given settings unless `--queue`, then render the
|
||||
/// queue with a progress line on stderr. Exit status 1 if any item fails.
|
||||
fn render(s: &mut Session, rest: &[String]) {
|
||||
if !rest.iter().any(|a| a == "--queue") {
|
||||
let out = opt(rest, "--out").unwrap_or_else(|| fail("render: --out FILE is required (or --queue)"));
|
||||
let mut p = json!({"output": out});
|
||||
if let Some(c) = opt(rest, "--comp") {
|
||||
p["comp"] = json!(c);
|
||||
}
|
||||
let ext = std::path::Path::new(out).extension().map(|e| e.to_string_lossy().to_string());
|
||||
if let Some(f) = opt(rest, "--format").map(str::to_string).or(ext) {
|
||||
p["format"] = json!(f);
|
||||
}
|
||||
let num = |k: &str| opt(rest, k).map(|v| v.parse::<f64>().unwrap_or_else(|_| fail(format!("{k}: not a number: {v}"))));
|
||||
match (num("--start"), num("--end")) {
|
||||
(None, None) => p["timeSpan"] = json!(if rest.iter().any(|a| a == "--work-area") { "workArea" } else { "comp" }),
|
||||
(a, b) => {
|
||||
p["start"] = json!(a.unwrap_or(0.0));
|
||||
if let Some(b) = b {
|
||||
p["end"] = json!(b);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(v) = num("--fps") {
|
||||
p["frameRate"] = json!(v);
|
||||
}
|
||||
if let Some(r) = opt(rest, "--resolution").or(opt(rest, "--scale")) {
|
||||
p["resolution"] = r.parse::<f64>().map(|v| json!(v)).unwrap_or(json!(r));
|
||||
}
|
||||
for (flag, key) in [("--quality", "quality"), ("--channels", "channels"), ("--prores", "proresProfile"), ("--audio", "audio")] {
|
||||
if let Some(v) = opt(rest, flag) {
|
||||
p[key] = json!(v);
|
||||
}
|
||||
}
|
||||
if let Some(v) = num("--jpeg-quality") {
|
||||
p["quality"] = json!(v);
|
||||
}
|
||||
if let Some(v) = num("--bitrate") {
|
||||
p["bitrate"] = json!(v);
|
||||
}
|
||||
// Render exactly this item: unqueue whatever the project's queue already holds.
|
||||
for k in 0..s.project.render_queue.len() {
|
||||
let _ = s.execute("renderQueue.setRender", json!({"index": k + 1, "render": false}));
|
||||
}
|
||||
let r = s.execute("renderQueue.add", p).unwrap_or_else(|e| fail(e));
|
||||
eprintln!(
|
||||
"{} → {} ({}×{}, {} frames, {})",
|
||||
r["compName"].as_str().unwrap_or("?"),
|
||||
r["outputPath"].as_str().unwrap_or("?"),
|
||||
r["width"],
|
||||
r["height"],
|
||||
r["frames"],
|
||||
r["outputModuleSummary"].as_str().unwrap_or("")
|
||||
);
|
||||
}
|
||||
s.execute("renderQueue.render", json!({"wait": false})).unwrap_or_else(|e| fail(e));
|
||||
let tty = std::io::IsTerminal::is_terminal(&std::io::stderr());
|
||||
while s.is_rendering() {
|
||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||
if tty && let Some(p) = s.render_progress() {
|
||||
let left = p.remaining.map(|r| format!(", ~{r:.1}s left")).unwrap_or_default();
|
||||
eprint!("\r [{}/{}] frame {}/{} {:.1}s{left}\x1b[K", (p.items_done + 1).min(p.items_total), p.items_total, p.done, p.total, p.item_elapsed);
|
||||
}
|
||||
s.poll_render();
|
||||
}
|
||||
s.poll_render();
|
||||
if tty {
|
||||
eprintln!();
|
||||
}
|
||||
let mut failed = false;
|
||||
for it in s.project.render_queue.iter().filter(|i| i.render && i.started.is_some()) {
|
||||
let name = s.project.item(it.comp).map(|i| i.name.as_str()).unwrap_or("?");
|
||||
match &it.status {
|
||||
RenderStatus::Done => eprintln!("done: {name} → {} in {:.2}s", it.last_output.as_deref().unwrap_or("?"), it.render_time.unwrap_or(0.0)),
|
||||
RenderStatus::Failed(e) => {
|
||||
failed = true;
|
||||
eprintln!("failed: {name}: {e}");
|
||||
}
|
||||
other => {
|
||||
failed = true;
|
||||
eprintln!("{name}: {}", other.label());
|
||||
}
|
||||
}
|
||||
}
|
||||
if failed {
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
fn open(s: &mut Session, args: &[String]) {
|
||||
if let Some(p) = args.iter().find(|a| a.ends_with(".ecproj")) {
|
||||
if let Err(e) = s.execute("file.open", json!({"path": p})) {
|
||||
eprintln!("effectcraft: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
} else if args.iter().any(|a| a == "--demo") || true {
|
||||
} else {
|
||||
// `--demo` (or no project): the built-in demo project.
|
||||
let _ = s.execute("file.openDemoProject", json!({}));
|
||||
}
|
||||
}
|
||||
|
|
@ -35,8 +149,12 @@ fn main() {
|
|||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let Some(cmd) = args.first().cloned() else { usage() };
|
||||
let rest = &args[1..];
|
||||
let mut s = Session::default();
|
||||
let mut s = effectcraft_host::session();
|
||||
match cmd.as_str() {
|
||||
"render" => {
|
||||
open(&mut s, rest);
|
||||
render(&mut s, rest);
|
||||
}
|
||||
"frame" => {
|
||||
open(&mut s, rest);
|
||||
if let Some(c) = opt(rest, "--comp") {
|
||||
|
|
|
|||
|
|
@ -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)))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,428 @@
|
|||
//! Render Queue: Composition ▸ Add to Render Queue, the queue's Render Settings / Output Module /
|
||||
//! Output To, Render and Stop.
|
||||
//!
|
||||
//! Items are addressed by `item` (the stable id from `renderQueue.add` / `renderQueue.list`) or
|
||||
//! `index` (1-based, the # column).
|
||||
|
||||
use effectcraft_project::render_queue::{
|
||||
AudioOutput, Channels, OutputFormat, OutputModule, ProResProfile, RenderQuality, RenderQueueItem, RenderSettings, RenderStatus, TimeSpan,
|
||||
};
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, b_p, bad, f_p, str_p};
|
||||
use crate::{EngineError, Result, Session, cmd, query};
|
||||
|
||||
fn can_add(s: &Session) -> std::result::Result<(), String> {
|
||||
not_rendering(s)?;
|
||||
if s.comp_for_queue(&Value::Null).is_some() { Ok(()) } else { Err("select or open a composition".into()) }
|
||||
}
|
||||
fn not_rendering(s: &Session) -> std::result::Result<(), String> {
|
||||
if s.is_rendering() { Err("the render queue is rendering".into()) } else { Ok(()) }
|
||||
}
|
||||
fn has_items(s: &Session) -> std::result::Result<(), String> {
|
||||
not_rendering(s)?;
|
||||
if s.project.render_queue.is_empty() { Err("the render queue is empty".into()) } else { Ok(()) }
|
||||
}
|
||||
fn has_queued(s: &Session) -> std::result::Result<(), String> {
|
||||
not_rendering(s)?;
|
||||
if s.exporter.is_none() {
|
||||
return Err("export is not available in this build".into());
|
||||
}
|
||||
if s.project.render_queue.iter().any(RenderQueueItem::is_queued) { Ok(()) } else { Err("nothing is queued".into()) }
|
||||
}
|
||||
fn rendering(s: &Session) -> std::result::Result<(), String> {
|
||||
if s.is_rendering() { Ok(()) } else { Err("not rendering".into()) }
|
||||
}
|
||||
|
||||
/// Index of the item named by `item` (id) or `index` (1-based).
|
||||
fn item_index(s: &Session, p: &Value, cmd: &str) -> Result<usize> {
|
||||
let q = &s.project.render_queue;
|
||||
if let Some(id) = p.get("item").and_then(Value::as_u64) {
|
||||
return q.iter().position(|i| i.id == id).ok_or_else(|| bad(cmd, format!("no render queue item {id}")));
|
||||
}
|
||||
if let Some(n) = p.get("index").and_then(Value::as_u64) {
|
||||
return (n as usize).checked_sub(1).filter(|i| *i < q.len()).ok_or_else(|| bad(cmd, format!("no render queue item #{n}")));
|
||||
}
|
||||
if q.len() == 1 {
|
||||
return Ok(0);
|
||||
}
|
||||
Err(bad(cmd, "pass `item` (id) or `index` (1-based)"))
|
||||
}
|
||||
|
||||
fn time_p(p: &Value, k: &str) -> Option<Tick> {
|
||||
f_p(p, k).map(Tick::from_seconds_f64)
|
||||
}
|
||||
|
||||
/// Apply Render Settings parameters.
|
||||
fn apply_settings(rs: &mut RenderSettings, p: &Value, cmd: &str) -> Result<bool> {
|
||||
let mut any = false;
|
||||
if let Some(q) = str_p(p, "quality") {
|
||||
rs.quality = match q.to_ascii_lowercase().as_str() {
|
||||
"best" => RenderQuality::Best,
|
||||
"draft" => RenderQuality::Draft,
|
||||
_ => return Err(bad(cmd, "quality: best|draft")),
|
||||
};
|
||||
any = true;
|
||||
}
|
||||
match p.get("resolution") {
|
||||
Some(Value::String(r)) => {
|
||||
rs.resolution = match r.to_ascii_lowercase().as_str() {
|
||||
"full" => 1.0,
|
||||
"half" => 0.5,
|
||||
"third" => 1.0 / 3.0,
|
||||
"quarter" => 0.25,
|
||||
_ => return Err(bad(cmd, "resolution: full|half|third|quarter|<scale>")),
|
||||
};
|
||||
any = true;
|
||||
}
|
||||
Some(Value::Number(n)) => {
|
||||
rs.resolution = n.as_f64().unwrap_or(1.0).clamp(0.01, 4.0);
|
||||
any = true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let (start, end) = (time_p(p, "start"), time_p(p, "end"));
|
||||
if let Some(ts) = str_p(p, "timeSpan") {
|
||||
rs.time_span = match ts.to_ascii_lowercase().replace([' ', '_', '-'], "").as_str() {
|
||||
"workarea" | "workareaonly" => TimeSpan::WorkArea,
|
||||
"comp" | "lengthofcomp" | "full" => TimeSpan::LengthOfComp,
|
||||
"custom" => TimeSpan::Custom { start: start.unwrap_or(Tick::ZERO), end: end.unwrap_or(Tick::from_seconds_f64(1.0)) },
|
||||
_ => return Err(bad(cmd, "timeSpan: workArea|comp|custom")),
|
||||
};
|
||||
any = true;
|
||||
} else if start.is_some() || end.is_some() {
|
||||
let (a, b) = match rs.time_span {
|
||||
TimeSpan::Custom { start, end } => (start, end),
|
||||
_ => (Tick::ZERO, Tick(i64::MAX / 4)),
|
||||
};
|
||||
rs.time_span = TimeSpan::Custom { start: start.unwrap_or(a), end: end.unwrap_or(b) };
|
||||
any = true;
|
||||
}
|
||||
if let TimeSpan::Custom { start, end } = rs.time_span
|
||||
&& end <= start
|
||||
{
|
||||
return Err(bad(cmd, "end must be after start"));
|
||||
}
|
||||
match p.get("frameRate") {
|
||||
Some(Value::Null) => {
|
||||
rs.frame_rate = None;
|
||||
any = true;
|
||||
}
|
||||
Some(v) => {
|
||||
let f = v.as_f64().filter(|f| *f > 0.0 && *f <= 1000.0).ok_or_else(|| bad(cmd, "frameRate: fps > 0 or null (comp rate)"))?;
|
||||
rs.frame_rate = Some(FrameRate::from_f64(f));
|
||||
any = true;
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
if let Some(b) = b_p(p, "motionBlur") {
|
||||
rs.motion_blur = b;
|
||||
any = true;
|
||||
}
|
||||
if let Some(b) = b_p(p, "skipExisting") {
|
||||
rs.skip_existing = b;
|
||||
any = true;
|
||||
}
|
||||
Ok(any)
|
||||
}
|
||||
|
||||
/// Apply Output Module parameters.
|
||||
fn apply_output(om: &mut OutputModule, p: &Value, cmd: &str) -> Result<bool> {
|
||||
let mut any = false;
|
||||
if let Some(f) = str_p(p, "format") {
|
||||
let f = OutputFormat::from_name(f).ok_or_else(|| bad(cmd, "format: h264|prores|png|jpeg|tiff|exr|gif"))?;
|
||||
om.set_format(f);
|
||||
any = true;
|
||||
}
|
||||
if let Some(c) = str_p(p, "channels") {
|
||||
om.channels = match c.to_ascii_lowercase().replace([' ', '+'], "").as_str() {
|
||||
"rgb" => Channels::Rgb,
|
||||
"rgba" | "rgbalpha" => Channels::Rgba,
|
||||
_ => return Err(bad(cmd, "channels: rgb|rgba")),
|
||||
};
|
||||
if om.channels == Channels::Rgba && !om.format.supports_alpha() {
|
||||
return Err(bad(cmd, format!("{} has no alpha channel", om.format.label())));
|
||||
}
|
||||
any = true;
|
||||
}
|
||||
if let Some(q) = f_p(p, "quality") {
|
||||
om.quality = q.clamp(1.0, 100.0) as u8;
|
||||
any = true;
|
||||
}
|
||||
if let Some(b) = f_p(p, "bitrate") {
|
||||
om.bitrate_kbps = b.clamp(100.0, 500_000.0) as u32;
|
||||
any = true;
|
||||
}
|
||||
if let Some(pp) = str_p(p, "proresProfile") {
|
||||
om.prores_profile = ProResProfile::from_name(pp).ok_or_else(|| bad(cmd, "proresProfile: proxy|lt|standard|hq|4444|4444xq"))?;
|
||||
any = true;
|
||||
}
|
||||
if let Some(a) = p.get("audio") {
|
||||
om.audio = match a {
|
||||
Value::Bool(true) => AudioOutput::On,
|
||||
Value::Bool(false) => AudioOutput::Off,
|
||||
Value::String(s) => match s.to_ascii_lowercase().as_str() {
|
||||
"auto" => AudioOutput::Auto,
|
||||
"on" => AudioOutput::On,
|
||||
"off" => AudioOutput::Off,
|
||||
_ => return Err(bad(cmd, "audio: auto|on|off")),
|
||||
},
|
||||
_ => return Err(bad(cmd, "audio: auto|on|off")),
|
||||
};
|
||||
any = true;
|
||||
}
|
||||
if let Some(r) = p.get("sampleRate").and_then(Value::as_u64) {
|
||||
om.audio_sample_rate = (r as u32).clamp(8_000, 192_000);
|
||||
any = true;
|
||||
}
|
||||
if let Some(l) = b_p(p, "loop") {
|
||||
om.gif_loop = l;
|
||||
any = true;
|
||||
}
|
||||
if let Some(o) = str_p(p, "output").or(str_p(p, "path")) {
|
||||
om.output = o.to_string();
|
||||
if p.get("format").is_none()
|
||||
&& let Some(f) = OutputFormat::from_path(o).filter(|f| *f != om.format && !o.contains("[fileExtension]"))
|
||||
{
|
||||
// `out.mov` with an H.264 module → ProRes, like picking a file type in Output To.
|
||||
let keep = om.output.clone();
|
||||
om.set_format(f);
|
||||
om.output = keep;
|
||||
}
|
||||
any = true;
|
||||
}
|
||||
Ok(any)
|
||||
}
|
||||
|
||||
/// A finished item that is edited goes back to Queued (AE re-queues on change).
|
||||
fn requeue(it: &mut RenderQueueItem) {
|
||||
if it.render && !matches!(it.status, RenderStatus::Rendering) {
|
||||
it.status = RenderStatus::Queued;
|
||||
}
|
||||
}
|
||||
|
||||
fn item_json(s: &Session, it: &RenderQueueItem, index: usize) -> Value {
|
||||
let comp = s.project.comp(it.comp);
|
||||
let name = s.project.item(it.comp).map(|i| i.name.clone());
|
||||
let (w, h) = comp.map(|c| it.settings.output_size(c)).map(|(w, h)| it.output.format.coded_size(w, h)).unwrap_or((0, 0));
|
||||
let frames = comp.map(|c| it.settings.frame_count(c)).unwrap_or(0);
|
||||
let mut v = serde_json::to_value(it).unwrap_or(Value::Null);
|
||||
if let Some(o) = v.as_object_mut() {
|
||||
o.insert("index".into(), json!(index + 1));
|
||||
o.insert("compName".into(), json!(name));
|
||||
o.insert("statusLabel".into(), json!(it.status.label()));
|
||||
o.insert("outputPath".into(), json!(s.resolve_output(it)));
|
||||
o.insert("renderSettingsSummary".into(), json!(it.settings.summary()));
|
||||
o.insert("outputModuleSummary".into(), json!(it.output.summary()));
|
||||
o.insert("width".into(), json!(w));
|
||||
o.insert("height".into(), json!(h));
|
||||
o.insert("frames".into(), json!(frames));
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
fn add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cid = s.comp_for_queue(p).ok_or(EngineError::NoComp)?;
|
||||
let mut it = RenderQueueItem::new(0, cid);
|
||||
if let Some(f) = str_p(p, "format") {
|
||||
let f = OutputFormat::from_name(f).ok_or_else(|| bad("renderQueue.add", "format: h264|prores|png|jpeg|tiff|exr|gif"))?;
|
||||
it.output = OutputModule::for_format(f);
|
||||
}
|
||||
apply_settings(&mut it.settings, p, "renderQueue.add")?;
|
||||
let mut op = p.clone();
|
||||
if let Some(o) = op.as_object_mut() {
|
||||
o.remove("format");
|
||||
}
|
||||
apply_output(&mut it.output, &op, "renderQueue.add")?;
|
||||
let id = s.edit("Add to Render Queue", None, |proj, _| {
|
||||
it.id = proj.render_queue.iter().map(|i| i.id).max().unwrap_or(0) + 1;
|
||||
let id = it.id;
|
||||
proj.render_queue.push(it);
|
||||
Ok(id)
|
||||
})?;
|
||||
let idx = s.project.render_queue.len() - 1;
|
||||
let mut v = item_json(s, &s.project.render_queue[idx], idx);
|
||||
v["item"] = json!(id);
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
fn remove(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = item_index(s, p, "renderQueue.remove")?;
|
||||
s.edit("Remove from Render Queue", None, |proj, _| {
|
||||
proj.render_queue.remove(i);
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"remaining": s.project.render_queue.len()}))
|
||||
}
|
||||
|
||||
fn set_render(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = item_index(s, p, "renderQueue.setRender")?;
|
||||
let on = b_p(p, "render").unwrap_or(!s.project.render_queue[i].render);
|
||||
s.edit("Render Queue: Render", None, |proj, _| {
|
||||
let it = &mut proj.render_queue[i];
|
||||
it.render = on;
|
||||
it.status = if on { RenderStatus::Queued } else { RenderStatus::Unqueued };
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"render": on}))
|
||||
}
|
||||
|
||||
fn set_render_settings(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = item_index(s, p, "renderQueue.setRenderSettings")?;
|
||||
let mut rs = s.project.render_queue[i].settings.clone();
|
||||
if !apply_settings(&mut rs, p, "renderQueue.setRenderSettings")? {
|
||||
return Err(bad("renderQueue.setRenderSettings", "nothing to change"));
|
||||
}
|
||||
s.edit("Render Settings", None, |proj, _| {
|
||||
let it = &mut proj.render_queue[i];
|
||||
it.settings = rs;
|
||||
requeue(it);
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(item_json(s, &s.project.render_queue[i], i))
|
||||
}
|
||||
|
||||
fn set_output_module(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = item_index(s, p, "renderQueue.setOutputModule")?;
|
||||
let mut om = s.project.render_queue[i].output.clone();
|
||||
if !apply_output(&mut om, p, "renderQueue.setOutputModule")? {
|
||||
return Err(bad("renderQueue.setOutputModule", "nothing to change"));
|
||||
}
|
||||
s.edit("Output Module Settings", None, |proj, _| {
|
||||
let it = &mut proj.render_queue[i];
|
||||
it.output = om;
|
||||
requeue(it);
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(item_json(s, &s.project.render_queue[i], i))
|
||||
}
|
||||
|
||||
fn set_output(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = item_index(s, p, "renderQueue.setOutput")?;
|
||||
let path = str_p(p, "path").or(str_p(p, "output")).ok_or_else(|| bad("renderQueue.setOutput", "pass `path` (file path or template)"))?;
|
||||
let mut om = s.project.render_queue[i].output.clone();
|
||||
apply_output(&mut om, &json!({"output": path}), "renderQueue.setOutput")?;
|
||||
s.edit("Output To", None, |proj, _| {
|
||||
let it = &mut proj.render_queue[i];
|
||||
it.output = om;
|
||||
requeue(it);
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(item_json(s, &s.project.render_queue[i], i))
|
||||
}
|
||||
|
||||
fn move_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = item_index(s, p, "renderQueue.move")?;
|
||||
let n = s.project.render_queue.len();
|
||||
let to = p.get("to").and_then(Value::as_u64).ok_or_else(|| bad("renderQueue.move", "pass `to` (1-based position)"))? as usize;
|
||||
let to = to.clamp(1, n) - 1;
|
||||
s.edit("Reorder Render Queue", None, |proj, _| {
|
||||
let it = proj.render_queue.remove(i);
|
||||
proj.render_queue.insert(to, it);
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"index": to + 1}))
|
||||
}
|
||||
|
||||
fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = item_index(s, p, "renderQueue.duplicate")?;
|
||||
let id = s.edit("Duplicate Render Item", None, |proj, _| {
|
||||
let mut it = proj.render_queue[i].clone();
|
||||
it.id = proj.render_queue.iter().map(|i| i.id).max().unwrap_or(0) + 1;
|
||||
it.render = true;
|
||||
it.status = RenderStatus::Queued;
|
||||
it.started = None;
|
||||
it.render_time = None;
|
||||
it.last_output = None;
|
||||
let id = it.id;
|
||||
proj.render_queue.insert(i + 1, it);
|
||||
Ok(id)
|
||||
})?;
|
||||
Ok(json!({"item": id, "index": i + 2}))
|
||||
}
|
||||
|
||||
fn render(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let wait = b_p(p, "wait").unwrap_or(true);
|
||||
let ids = s.start_render(wait).map_err(EngineError::Other)?;
|
||||
let items: Vec<Value> = s.project.render_queue.iter().enumerate().filter(|(_, i)| ids.contains(&i.id)).map(|(k, i)| item_json(s, i, k)).collect();
|
||||
Ok(json!({"rendering": s.is_rendering(), "items": items}))
|
||||
}
|
||||
|
||||
fn stop(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
Ok(json!({"stopped": s.stop_render()}))
|
||||
}
|
||||
|
||||
fn list(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
s.poll_render();
|
||||
let items: Vec<Value> = s.project.render_queue.iter().enumerate().map(|(k, i)| item_json(s, i, k)).collect();
|
||||
Ok(json!({"items": items, "rendering": s.is_rendering(), "progress": s.render_progress()}))
|
||||
}
|
||||
|
||||
fn formats(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let avail = s.exporter.as_ref().map(|e| e.formats()).unwrap_or_default();
|
||||
let v: Vec<Value> = OutputFormat::ALL
|
||||
.iter()
|
||||
.map(|f| {
|
||||
json!({
|
||||
"id": format!("{f:?}"),
|
||||
"label": f.label(),
|
||||
"extension": f.extension(),
|
||||
"sequence": f.is_sequence(),
|
||||
"alpha": f.supports_alpha(),
|
||||
"audio": f.supports_audio(),
|
||||
"available": avail.contains(f),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Ok(json!({"formats": v, "proresProfiles": ProResProfile::ALL.iter().map(|p| p.label()).collect::<Vec<_>>()}))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"renderQueue.add",
|
||||
"Add to Render Queue",
|
||||
["Composition"],
|
||||
Some("Cmd+M"),
|
||||
"{comp?: id|name, format?: h264|prores|png|jpeg|tiff|exr|gif, output?: path|template, …renderSettings, …outputModule}",
|
||||
can_add,
|
||||
add
|
||||
),
|
||||
cmd!("renderQueue.remove", "Remove from Render Queue", [], None, "{item?: id, index?: n}", has_items, remove),
|
||||
cmd!("renderQueue.setRender", "Render Queue: Render Checkbox", [], None, "{item?|index?, render?: bool (toggles)}", has_items, set_render),
|
||||
cmd!(
|
||||
"renderQueue.setRenderSettings",
|
||||
"Render Settings…",
|
||||
[],
|
||||
None,
|
||||
"{item?|index?, quality?: best|draft, resolution?: full|half|third|quarter|scale, timeSpan?: workArea|comp|custom, start?: s, end?: s, frameRate?: fps|null, motionBlur?: bool, skipExisting?: bool}",
|
||||
has_items,
|
||||
set_render_settings
|
||||
),
|
||||
cmd!(
|
||||
"renderQueue.setOutputModule",
|
||||
"Output Module Settings…",
|
||||
[],
|
||||
None,
|
||||
"{item?|index?, format?, channels?: rgb|rgba, quality?: 1-100, bitrate?: kbps, proresProfile?: proxy|lt|standard|hq|4444|4444xq, audio?: auto|on|off, sampleRate?, loop?: bool, output?}",
|
||||
has_items,
|
||||
set_output_module
|
||||
),
|
||||
cmd!(
|
||||
"renderQueue.setOutput",
|
||||
"Output To…",
|
||||
[],
|
||||
None,
|
||||
"{item?|index?, path: file path or template like [compName].[fileExtension]}",
|
||||
has_items,
|
||||
set_output
|
||||
),
|
||||
cmd!("renderQueue.move", "Move in Render Queue", [], None, "{item?|index?, to: n (1-based)}", has_items, move_item),
|
||||
cmd!("renderQueue.duplicate", "Duplicate Render Item", [], None, "{item?|index?}", has_items, duplicate),
|
||||
cmd!("renderQueue.render", "Render", [], None, "{wait?: bool (default true; the UI renders in the background)}", has_queued, render),
|
||||
cmd!("renderQueue.stop", "Stop Rendering", [], None, "{}", rendering, stop),
|
||||
query!("renderQueue.list", "Render Queue Items", "{}", list),
|
||||
query!("renderQueue.formats", "Output Formats", "{}", formats),
|
||||
]
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@
|
|||
pub mod commands;
|
||||
pub mod demo;
|
||||
pub mod links;
|
||||
pub mod render_queue;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
|
|
@ -29,6 +30,7 @@ pub use effectcraft_keyframe as keyframe;
|
|||
pub use effectcraft_project as project;
|
||||
pub use effectcraft_render as render;
|
||||
pub use effectcraft_time as time;
|
||||
pub use render_queue::{ExportJob, ExportResult, Exporter, JobState};
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum EngineError {
|
||||
|
|
@ -132,6 +134,10 @@ pub struct Session {
|
|||
pub footage: Arc<dyn FootageSource>,
|
||||
pub expr: Option<Arc<dyn ExprHost>>,
|
||||
pub importer: Option<Arc<dyn Importer>>,
|
||||
/// Render Queue encoder (the export layer); `None` = export unavailable.
|
||||
pub exporter: Option<Arc<dyn Exporter>>,
|
||||
/// The running (or finished, not yet polled) render.
|
||||
pub render_job: Option<render_queue::RenderJob>,
|
||||
pub events: Vec<Event>,
|
||||
/// Commands executed: (id, params).
|
||||
pub journal: Vec<(String, Value)>,
|
||||
|
|
@ -150,6 +156,8 @@ impl Default for Session {
|
|||
footage: Arc::new(NoFootage),
|
||||
expr: None,
|
||||
importer: None,
|
||||
exporter: None,
|
||||
render_job: None,
|
||||
events: vec![],
|
||||
journal: vec![],
|
||||
}
|
||||
|
|
@ -308,6 +316,13 @@ impl Session {
|
|||
|
||||
/// Replace the whole project (open/new), resetting history and state.
|
||||
pub fn replace_project(&mut self, mut p: Project, path: Option<String>) {
|
||||
if self.stop_render()
|
||||
&& let Some(mut job) = self.render_job.take()
|
||||
{
|
||||
// Let the worker notice the cancel; its updates refer to the old project.
|
||||
job.wait();
|
||||
}
|
||||
self.render_job = None;
|
||||
p.fix_next_id();
|
||||
self.project = Arc::new(p);
|
||||
self.history = History::default();
|
||||
|
|
@ -322,6 +337,8 @@ impl Session {
|
|||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod rq_tests;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,344 @@
|
|||
//! Render Queue runtime: the [`Exporter`] hook (implemented by `effectcraft-export`, wired by
|
||||
//! `effectcraft-host`), output path resolution, and the render job that works through the queued
|
||||
//! items, in the background (desktop UI) or blocking (CLI, agents, tests).
|
||||
//!
|
||||
//! The queue itself (items, Render Settings, Output Modules, status) lives in the project
|
||||
//! (`project.render_queue`) so it is saved with it. A running job owns a snapshot of the project;
|
||||
//! it reports progress through [`JobShared`], and [`Session::poll_render`] copies item status /
|
||||
//! start time / render time back into the project.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use effectcraft_project::render_queue::{OutputFormat, RenderQueueItem, RenderStatus, TemplateVars, expand_template};
|
||||
use effectcraft_project::{ItemId, Project};
|
||||
use effectcraft_render::{ExprHost, FootageSource};
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::{Event, Session};
|
||||
|
||||
/// One export handed to the [`Exporter`].
|
||||
pub struct ExportJob<'a> {
|
||||
pub project: &'a Project,
|
||||
pub footage: &'a dyn FootageSource,
|
||||
pub expr: Option<&'a dyn ExprHost>,
|
||||
pub item: &'a RenderQueueItem,
|
||||
/// Resolved output path (templates expanded except the `#` frame-number run).
|
||||
pub path: &'a str,
|
||||
}
|
||||
|
||||
/// What an export wrote.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
|
||||
pub struct ExportResult {
|
||||
pub path: String,
|
||||
pub frames: u64,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub bytes: u64,
|
||||
pub seconds: f64,
|
||||
pub audio: bool,
|
||||
}
|
||||
|
||||
/// Encodes render queue items (implemented by the export layer).
|
||||
pub trait Exporter: Send + Sync {
|
||||
/// Formats this build can write.
|
||||
fn formats(&self) -> Vec<OutputFormat>;
|
||||
/// Render and write one item (blocking). `progress(done, total)` returns `false` to cancel;
|
||||
/// a cancelled export returns `Err("cancelled")`.
|
||||
fn export(&self, job: &ExportJob, progress: &mut dyn FnMut(u64, u64) -> bool) -> Result<ExportResult, String>;
|
||||
}
|
||||
|
||||
/// The error string an [`Exporter`] returns when cancelled.
|
||||
pub const CANCELLED: &str = "cancelled";
|
||||
|
||||
/// Live progress of a render job (serde for agents / the control channel).
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
pub struct JobState {
|
||||
/// Item being rendered.
|
||||
pub current: Option<u64>,
|
||||
/// Frames done / total of the current item.
|
||||
pub done: u64,
|
||||
pub total: u64,
|
||||
/// Seconds since the job started / since the current item started.
|
||||
pub elapsed: f64,
|
||||
pub item_elapsed: f64,
|
||||
/// Estimated seconds left for the current item.
|
||||
pub remaining: Option<f64>,
|
||||
/// Items rendered so far / items in this job.
|
||||
pub items_done: usize,
|
||||
pub items_total: usize,
|
||||
pub finished: bool,
|
||||
}
|
||||
|
||||
/// Per-item status changes from the job thread, applied to the project by `poll_render`.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum ItemUpdate {
|
||||
Started { id: u64, unix: u64 },
|
||||
Finished { id: u64, status: RenderStatus, seconds: f64, output: Option<String> },
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct JobShared {
|
||||
pub cancel: AtomicBool,
|
||||
pub state: Mutex<JobState>,
|
||||
pub updates: Mutex<Vec<ItemUpdate>>,
|
||||
}
|
||||
|
||||
fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
|
||||
m.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
impl JobShared {
|
||||
pub fn snapshot(&self) -> JobState {
|
||||
lock(&self.state).clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// A running (or finished, not yet polled) render job.
|
||||
pub struct RenderJob {
|
||||
pub shared: Arc<JobShared>,
|
||||
thread: Option<std::thread::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl RenderJob {
|
||||
pub fn is_finished(&self) -> bool {
|
||||
lock(&self.shared.state).finished
|
||||
}
|
||||
pub fn cancel(&self) {
|
||||
self.shared.cancel.store(true, Ordering::Relaxed);
|
||||
}
|
||||
/// Block until the worker thread ends.
|
||||
pub fn wait(&mut self) {
|
||||
if let Some(t) = self.thread.take() {
|
||||
let _ = t.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything the worker needs, detached from the session.
|
||||
struct Work {
|
||||
project: Arc<Project>,
|
||||
footage: Arc<dyn FootageSource>,
|
||||
expr: Option<Arc<dyn ExprHost>>,
|
||||
exporter: Arc<dyn Exporter>,
|
||||
items: Vec<(RenderQueueItem, String)>,
|
||||
}
|
||||
|
||||
fn unix_now() -> u64 {
|
||||
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
|
||||
}
|
||||
|
||||
fn run_work(w: Work, shared: &JobShared) {
|
||||
let t0 = std::time::Instant::now();
|
||||
{
|
||||
let mut s = lock(&shared.state);
|
||||
s.items_total = w.items.len();
|
||||
}
|
||||
for (k, (item, path)) in w.items.iter().enumerate() {
|
||||
if shared.cancel.load(Ordering::Relaxed) {
|
||||
lock(&shared.updates).push(ItemUpdate::Finished { id: item.id, status: RenderStatus::UserStopped, seconds: 0.0, output: None });
|
||||
continue;
|
||||
}
|
||||
let t1 = std::time::Instant::now();
|
||||
lock(&shared.updates).push(ItemUpdate::Started { id: item.id, unix: unix_now() });
|
||||
{
|
||||
let mut s = lock(&shared.state);
|
||||
s.current = Some(item.id);
|
||||
s.done = 0;
|
||||
s.total = 0;
|
||||
s.items_done = k;
|
||||
s.remaining = None;
|
||||
}
|
||||
let job = ExportJob { project: &w.project, footage: w.footage.as_ref(), expr: w.expr.as_deref(), item, path };
|
||||
let r = w.exporter.export(&job, &mut |done, total| {
|
||||
let mut s = lock(&shared.state);
|
||||
s.done = done;
|
||||
s.total = total;
|
||||
s.elapsed = t0.elapsed().as_secs_f64();
|
||||
s.item_elapsed = t1.elapsed().as_secs_f64();
|
||||
s.remaining = (done > 0).then(|| s.item_elapsed / done as f64 * total.saturating_sub(done) as f64);
|
||||
!shared.cancel.load(Ordering::Relaxed)
|
||||
});
|
||||
let seconds = t1.elapsed().as_secs_f64();
|
||||
let up = match r {
|
||||
Ok(res) => ItemUpdate::Finished { id: item.id, status: RenderStatus::Done, seconds, output: Some(res.path) },
|
||||
Err(e) if e == CANCELLED => ItemUpdate::Finished { id: item.id, status: RenderStatus::UserStopped, seconds, output: None },
|
||||
Err(e) => {
|
||||
log::warn!("render queue: item {} failed: {e}", item.id);
|
||||
ItemUpdate::Finished { id: item.id, status: RenderStatus::Failed(e), seconds, output: None }
|
||||
}
|
||||
};
|
||||
lock(&shared.updates).push(up);
|
||||
}
|
||||
let mut s = lock(&shared.state);
|
||||
s.items_done = w.items.len();
|
||||
s.current = None;
|
||||
s.elapsed = t0.elapsed().as_secs_f64();
|
||||
s.finished = true;
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// The project's name for `[projectName]` (file stem, or "Untitled Project").
|
||||
pub fn project_name(&self) -> String {
|
||||
self.path
|
||||
.as_deref()
|
||||
.and_then(|p| std::path::Path::new(p).file_stem())
|
||||
.map(|s| s.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| "Untitled Project".into())
|
||||
}
|
||||
|
||||
/// The output path of a queue item: template tokens expanded, relative paths resolved against
|
||||
/// the project's folder (or the working directory). `None` when the comp is gone or the
|
||||
/// output is empty ("Needs Output").
|
||||
pub fn resolve_output(&self, item: &RenderQueueItem) -> Option<String> {
|
||||
let comp = self.project.comp(item.comp)?;
|
||||
let name = self.project.item(item.comp).map(|i| i.name.clone()).unwrap_or_default();
|
||||
let tpl = item.output.output.trim();
|
||||
if tpl.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let (w, h) = item.settings.output_size(comp);
|
||||
let (w, h) = item.output.format.coded_size(w, h);
|
||||
let rate = item.settings.rate(comp);
|
||||
let n = item.settings.frame_count(comp) as i64;
|
||||
let f0 = item.settings.first_frame(comp);
|
||||
let pname = self.project_name();
|
||||
let vars =
|
||||
TemplateVars { comp_name: &name, project_name: &pname, width: w, height: h, frame_rate: rate.as_f64(), start_frame: f0, end_frame: f0 + n - 1 };
|
||||
let p = expand_template(tpl, item.output.format, &vars);
|
||||
let path = std::path::Path::new(&p);
|
||||
if path.is_absolute() {
|
||||
return Some(p);
|
||||
}
|
||||
let base = self.path.as_deref().and_then(|s| std::path::Path::new(s).parent().map(|d| d.to_path_buf())).filter(|d| !d.as_os_str().is_empty());
|
||||
let base = base.or_else(|| std::env::current_dir().ok())?;
|
||||
Some(base.join(path).to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
/// Whether a render job is running.
|
||||
pub fn is_rendering(&self) -> bool {
|
||||
self.render_job.as_ref().is_some_and(|j| !j.is_finished())
|
||||
}
|
||||
|
||||
/// Live job progress.
|
||||
pub fn render_progress(&self) -> Option<JobState> {
|
||||
self.render_job.as_ref().map(|j| j.shared.snapshot())
|
||||
}
|
||||
|
||||
/// Start rendering every queued item. `wait`: block until done (otherwise a background thread
|
||||
/// renders; call [`Session::poll_render`] regularly). Returns the ids being rendered.
|
||||
pub fn start_render(&mut self, wait: bool) -> Result<Vec<u64>, String> {
|
||||
if self.is_rendering() {
|
||||
return Err("a render is already in progress".into());
|
||||
}
|
||||
self.poll_render();
|
||||
let exporter = self.exporter.clone().ok_or("export is not available in this build")?;
|
||||
let formats = exporter.formats();
|
||||
let mut items = Vec::new();
|
||||
let mut needs_output = Vec::new();
|
||||
for it in self.project.render_queue.iter().filter(|i| i.is_queued()) {
|
||||
match self.resolve_output(it) {
|
||||
Some(p) if formats.contains(&it.output.format) => items.push((it.clone(), p)),
|
||||
Some(_) => needs_output.push((it.id, RenderStatus::Failed(format!("{} export is not available", it.output.format.label())))),
|
||||
None if self.project.comp(it.comp).is_none() => needs_output.push((it.id, RenderStatus::Failed("composition missing".into()))),
|
||||
None => needs_output.push((it.id, RenderStatus::NeedsOutput)),
|
||||
}
|
||||
}
|
||||
if !needs_output.is_empty() {
|
||||
let p = Arc::make_mut(&mut self.project);
|
||||
for (id, st) in needs_output {
|
||||
if let Some(i) = p.render_queue.iter_mut().find(|i| i.id == id) {
|
||||
i.status = st;
|
||||
}
|
||||
}
|
||||
self.bump();
|
||||
}
|
||||
if items.is_empty() {
|
||||
return Err("nothing is queued: add a composition (Composition ▸ Add to Render Queue) and tick Render".into());
|
||||
}
|
||||
let ids: Vec<u64> = items.iter().map(|(i, _)| i.id).collect();
|
||||
let work = Work { project: self.project.clone(), footage: self.footage.clone(), expr: self.expr.clone(), exporter, items };
|
||||
let shared = Arc::new(JobShared::default());
|
||||
if wait {
|
||||
run_work(work, &shared);
|
||||
self.render_job = Some(RenderJob { shared, thread: None });
|
||||
} else {
|
||||
let sh = shared.clone();
|
||||
let thread = std::thread::Builder::new().name("render-queue".into()).spawn(move || run_work(work, &sh)).map_err(|e| e.to_string())?;
|
||||
self.render_job = Some(RenderJob { shared, thread: Some(thread) });
|
||||
}
|
||||
self.poll_render();
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
/// Stop the running render (the current item becomes "User Stopped").
|
||||
pub fn stop_render(&mut self) -> bool {
|
||||
match &self.render_job {
|
||||
Some(j) if !j.is_finished() => {
|
||||
j.cancel();
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply job updates to the queue items; drop the job once it finished. Returns whether
|
||||
/// anything changed. Frontends call this every frame while rendering.
|
||||
pub fn poll_render(&mut self) -> bool {
|
||||
let Some(job) = &self.render_job else { return false };
|
||||
let updates = std::mem::take(&mut *lock(&job.shared.updates));
|
||||
let finished = job.is_finished();
|
||||
let changed = !updates.is_empty();
|
||||
if changed {
|
||||
let p = Arc::make_mut(&mut self.project);
|
||||
for u in &updates {
|
||||
match u {
|
||||
ItemUpdate::Started { id, unix } => {
|
||||
if let Some(i) = p.render_queue.iter_mut().find(|i| i.id == *id) {
|
||||
i.status = RenderStatus::Rendering;
|
||||
i.started = Some(*unix);
|
||||
i.render_time = None;
|
||||
}
|
||||
}
|
||||
ItemUpdate::Finished { id, status, seconds, output } => {
|
||||
if let Some(i) = p.render_queue.iter_mut().find(|i| i.id == *id) {
|
||||
i.status = status.clone();
|
||||
i.render_time = Some(*seconds);
|
||||
if output.is_some() {
|
||||
i.last_output = output.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.bump();
|
||||
}
|
||||
if finished {
|
||||
let mut job = self.render_job.take().expect("job");
|
||||
if let Some(t) = job.thread.take() {
|
||||
let _ = t.join();
|
||||
}
|
||||
let st = job.shared.snapshot();
|
||||
let failed = updates.iter().filter(|u| matches!(u, ItemUpdate::Finished { status: RenderStatus::Failed(_), .. })).count();
|
||||
let stopped = updates.iter().any(|u| matches!(u, ItemUpdate::Finished { status: RenderStatus::UserStopped, .. }));
|
||||
let msg = if failed > 0 {
|
||||
format!("Render finished with {failed} failed item(s)")
|
||||
} else if stopped {
|
||||
"Render stopped".to_string()
|
||||
} else {
|
||||
format!("Rendered {} item(s) in {:.1} s", st.items_total, st.elapsed)
|
||||
};
|
||||
self.events.push(Event::Toast { message: msg, error: failed > 0 });
|
||||
}
|
||||
changed || finished
|
||||
}
|
||||
|
||||
/// The comp to add: `comp` param, the active comp, or the first comp selected in the Project panel.
|
||||
pub(crate) fn comp_for_queue(&self, p: &serde_json::Value) -> Option<ItemId> {
|
||||
if p.get("comp").is_some() {
|
||||
return crate::commands::comp_id(self, p).ok();
|
||||
}
|
||||
let sel = self.state.project_selection.iter().copied().find(|i| self.project.comp(*i).is_some());
|
||||
self.active_comp_id().or(sel)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,164 @@
|
|||
//! Render Queue commands against mock exporters (real encoding is tested in `effectcraft-export`
|
||||
//! and end to end in `effectcraft-host`).
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use effectcraft_project::render_queue::{OutputFormat, RenderQuality, RenderStatus};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::render_queue::CANCELLED;
|
||||
use crate::{Event, ExportJob, ExportResult, Exporter, Session};
|
||||
|
||||
fn demo() -> Session {
|
||||
let mut s = Session::default();
|
||||
s.execute("file.openDemoProject", json!({})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
/// Records exports; fails items whose path contains "fail"; honours cancellation.
|
||||
struct MockExporter {
|
||||
log: Mutex<Vec<String>>,
|
||||
}
|
||||
|
||||
impl Exporter for MockExporter {
|
||||
fn formats(&self) -> Vec<OutputFormat> {
|
||||
OutputFormat::ALL.to_vec()
|
||||
}
|
||||
fn export(&self, job: &ExportJob, progress: &mut dyn FnMut(u64, u64) -> bool) -> Result<ExportResult, String> {
|
||||
self.log.lock().unwrap().push(job.path.to_string());
|
||||
if job.path.contains("fail") {
|
||||
return Err("boom".into());
|
||||
}
|
||||
let comp = job.project.comp(job.item.comp).ok_or("no comp")?;
|
||||
let n = job.item.settings.frame_count(comp);
|
||||
for k in 0..=n {
|
||||
if !progress(k, n) {
|
||||
return Err(CANCELLED.into());
|
||||
}
|
||||
}
|
||||
Ok(ExportResult { path: job.path.to_string(), frames: n, ..Default::default() })
|
||||
}
|
||||
}
|
||||
|
||||
fn rq_session() -> (Session, Arc<MockExporter>) {
|
||||
let mut s = demo();
|
||||
let ex = Arc::new(MockExporter { log: Default::default() });
|
||||
s.exporter = Some(ex.clone());
|
||||
(s, ex)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_configure_render() {
|
||||
let (mut s, ex) = rq_session();
|
||||
assert!(s.is_enabled("renderQueue.add"));
|
||||
assert!(!s.is_enabled("renderQueue.render"));
|
||||
let a = s.execute("renderQueue.add", json!({})).unwrap();
|
||||
let name = s.project.item(s.active_comp_id().unwrap()).unwrap().name.clone();
|
||||
assert_eq!(a["index"], 1);
|
||||
assert_eq!(a["statusLabel"], "Queued");
|
||||
assert!(a["outputPath"].as_str().unwrap().ends_with(&format!("{name}.mp4")), "{a}");
|
||||
let id = a["item"].as_u64().unwrap();
|
||||
// Render Settings / Output Module / Output To.
|
||||
let r = s
|
||||
.execute("renderQueue.setRenderSettings", json!({"item": id, "resolution": "half", "timeSpan": "custom", "start": 1.0, "end": 2.0, "frameRate": 10}))
|
||||
.unwrap();
|
||||
assert_eq!(r["frames"], 10);
|
||||
assert_eq!(r["width"], 960);
|
||||
let r = s.execute("renderQueue.setOutputModule", json!({"item": id, "format": "png", "channels": "rgba"})).unwrap();
|
||||
assert!(r["outputPath"].as_str().unwrap().ends_with("_[#####].png"), "{r}");
|
||||
assert!(s.execute("renderQueue.setOutputModule", json!({"item": id, "format": "jpeg", "channels": "rgba"})).is_err());
|
||||
let r = s.execute("renderQueue.setOutput", json!({"item": id, "path": "/tmp/rq/out.mov"})).unwrap();
|
||||
assert_eq!(r["output"]["format"], "ProRes", "Output To with .mov switches to ProRes");
|
||||
assert_eq!(r["outputPath"], "/tmp/rq/out.mov");
|
||||
// A second item that fails, a third that is unqueued.
|
||||
let b = s.execute("renderQueue.add", json!({"format": "gif", "output": "/tmp/rq/fail.gif"})).unwrap();
|
||||
let c = s.execute("renderQueue.add", json!({"output": "/tmp/rq/skip.mp4"})).unwrap();
|
||||
s.execute("renderQueue.setRender", json!({"item": c["item"], "render": false})).unwrap();
|
||||
assert_eq!(s.project.render_queue[2].status.label(), "Unqueued");
|
||||
let r = s.execute("renderQueue.render", json!({})).unwrap();
|
||||
assert_eq!(r["items"].as_array().unwrap().len(), 2);
|
||||
assert_eq!(*ex.log.lock().unwrap(), vec!["/tmp/rq/out.mov".to_string(), "/tmp/rq/fail.gif".to_string()]);
|
||||
let q = &s.project.render_queue;
|
||||
assert_eq!(q[0].status.label(), "Done");
|
||||
assert!(q[0].started.is_some() && q[0].render_time.is_some());
|
||||
assert_eq!(q[0].last_output.as_deref(), Some("/tmp/rq/out.mov"));
|
||||
assert_eq!(q[1].status, RenderStatus::Failed("boom".into()));
|
||||
assert_eq!(q[2].status.label(), "Unqueued");
|
||||
assert!(s.drain_events().iter().any(|e| matches!(e, Event::Toast { error: true, .. })));
|
||||
// Reorder, duplicate, remove, undo.
|
||||
s.execute("renderQueue.move", json!({"item": b["item"], "to": 1})).unwrap();
|
||||
assert_eq!(s.project.render_queue[0].id, b["item"].as_u64().unwrap());
|
||||
let d = s.execute("renderQueue.duplicate", json!({"index": 2})).unwrap();
|
||||
assert_eq!(s.project.render_queue.len(), 4);
|
||||
assert_eq!(s.project.render_queue[2].id, d["item"].as_u64().unwrap());
|
||||
assert_eq!(s.project.render_queue[2].status.label(), "Queued");
|
||||
s.execute("renderQueue.remove", json!({"index": 3})).unwrap();
|
||||
assert_eq!(s.project.render_queue.len(), 3);
|
||||
s.undo();
|
||||
assert_eq!(s.project.render_queue.len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persists_in_project_file() {
|
||||
let (mut s, _) = rq_session();
|
||||
s.execute("renderQueue.add", json!({"format": "tiff", "output": "[compName]/[compName]_[####].tif", "quality": "draft"})).unwrap();
|
||||
let json = s.project.to_json();
|
||||
let p = effectcraft_project::Project::from_json(&json).unwrap();
|
||||
assert_eq!(p.render_queue, s.project.render_queue);
|
||||
assert_eq!(p.render_queue[0].settings.quality, RenderQuality::Draft);
|
||||
// Projects without a queue still load.
|
||||
let old = json.replace("\"render_queue\"", "\"_ignored\"");
|
||||
assert!(effectcraft_project::Project::from_json(&old).unwrap().render_queue.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_render_and_stop() {
|
||||
struct Slow;
|
||||
impl Exporter for Slow {
|
||||
fn formats(&self) -> Vec<OutputFormat> {
|
||||
OutputFormat::ALL.to_vec()
|
||||
}
|
||||
fn export(&self, _: &ExportJob, progress: &mut dyn FnMut(u64, u64) -> bool) -> Result<ExportResult, String> {
|
||||
for k in 0..10_000 {
|
||||
std::thread::sleep(std::time::Duration::from_millis(2));
|
||||
if !progress(k, 10_000) {
|
||||
return Err(CANCELLED.into());
|
||||
}
|
||||
}
|
||||
Ok(Default::default())
|
||||
}
|
||||
}
|
||||
let mut s = demo();
|
||||
s.exporter = Some(Arc::new(Slow));
|
||||
s.execute("renderQueue.add", json!({"output": "/tmp/a.mp4"})).unwrap();
|
||||
s.execute("renderQueue.add", json!({"output": "/tmp/b.mp4"})).unwrap();
|
||||
s.execute("renderQueue.render", json!({"wait": false})).unwrap();
|
||||
assert!(s.is_rendering());
|
||||
assert!(!s.is_enabled("renderQueue.add"), "queue is locked while rendering");
|
||||
let t0 = std::time::Instant::now();
|
||||
while s.render_progress().is_some_and(|p| p.done < 3) {
|
||||
assert!(t0.elapsed().as_secs() < 20);
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
s.poll_render();
|
||||
assert_eq!(s.project.render_queue[0].status.label(), "Rendering");
|
||||
s.execute("renderQueue.stop", json!({})).unwrap();
|
||||
while s.is_rendering() {
|
||||
assert!(t0.elapsed().as_secs() < 20);
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
s.poll_render();
|
||||
assert!(s.render_job.is_none());
|
||||
assert_eq!(s.project.render_queue[0].status.label(), "User Stopped");
|
||||
assert_eq!(s.project.render_queue[1].status.label(), "User Stopped");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_exporter() {
|
||||
let mut s = demo();
|
||||
s.execute("renderQueue.add", json!({})).unwrap();
|
||||
assert!(!s.is_enabled("renderQueue.render"));
|
||||
let f = s.execute("renderQueue.formats", json!({})).unwrap();
|
||||
assert_eq!(f["formats"].as_array().unwrap().len(), 7);
|
||||
assert_eq!(f["formats"][0]["available"], false);
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
@ -0,0 +1,362 @@
|
|||
//! Movie export: FilmCraft H.264 → MP4 (+ AAC) and ProRes → MOV (+ PCM), muxed by
|
||||
//! FilmCraft's ISO BMFF / QuickTime writer.
|
||||
|
||||
use std::io::{BufWriter, Write};
|
||||
|
||||
use effectcraft_project::Comp;
|
||||
use effectcraft_project::render_queue::{Channels, OutputFormat, ProResProfile};
|
||||
use effectcraft_time::{FrameRate, TICKS_PER_SECOND, Tick};
|
||||
use filmcraft_isobmff::{Brand, FourCc, Mp4Writer, PcmConfig, SampleEntry, TrackConfig, WriteSample, WriterOptions};
|
||||
use rayon::prelude::*;
|
||||
|
||||
use crate::{ExportError, Job, Report, Result, State, batch_size, render_frame, rgba8, wants_audio};
|
||||
|
||||
fn enc(e: impl std::fmt::Display) -> ExportError {
|
||||
ExportError::Encode(e.to_string())
|
||||
}
|
||||
fn mux_err(e: impl std::fmt::Display) -> ExportError {
|
||||
ExportError::Io(e.to_string())
|
||||
}
|
||||
|
||||
struct Packet {
|
||||
data: Vec<u8>,
|
||||
key: bool,
|
||||
/// pts − dts in the track timescale.
|
||||
cto: i32,
|
||||
}
|
||||
|
||||
trait VideoEncoder {
|
||||
fn sample_entry(&self) -> SampleEntry;
|
||||
fn encode(&mut self, rgba: &[u8], index: u64) -> Result<Vec<Packet>>;
|
||||
fn flush(&mut self) -> Result<Vec<Packet>>;
|
||||
/// Edit-list media start (B-frame delay) in the track timescale.
|
||||
fn media_start(&self) -> Option<i64> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- H.264
|
||||
|
||||
struct H264 {
|
||||
enc: filmcraft_h264enc::Encoder,
|
||||
w: u32,
|
||||
h: u32,
|
||||
rate: FrameRate,
|
||||
y: Vec<u8>,
|
||||
u: Vec<u8>,
|
||||
v: Vec<u8>,
|
||||
}
|
||||
|
||||
impl H264 {
|
||||
fn new(w: u32, h: u32, rate: FrameRate, kbps: u32) -> Result<H264> {
|
||||
let mut cfg = filmcraft_h264enc::EncoderConfig::new(w, h, rate.num as u32, rate.den as u32);
|
||||
cfg.format = filmcraft_h264enc::PacketFormat::LengthPrefixed;
|
||||
cfg.aud = false;
|
||||
cfg.keyint = (rate.as_f64() * 2.0).round().max(1.0) as u32;
|
||||
let kbps = kbps.max(100);
|
||||
cfg.rate = filmcraft_h264enc::RateControl::Vbr { target_kbps: kbps, max_kbps: kbps * 3 / 2 };
|
||||
let enc = filmcraft_h264enc::Encoder::new(cfg).map_err(enc)?;
|
||||
Ok(H264 { enc, w, h, rate, y: vec![], u: vec![], v: vec![] })
|
||||
}
|
||||
fn packets(&self, ps: Vec<filmcraft_h264enc::Packet>) -> Vec<Packet> {
|
||||
ps.into_iter().map(|p| Packet { data: p.data, key: p.keyframe, cto: (p.pts - p.dts) as i32 }).collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl VideoEncoder for H264 {
|
||||
fn sample_entry(&self) -> SampleEntry {
|
||||
let cfg = filmcraft_isobmff::AvcConfig::parse(&self.enc.avcc()).unwrap_or_else(|_| {
|
||||
let (sps, pps) = self.enc.sps_pps();
|
||||
filmcraft_isobmff::AvcConfig::new(vec![sps], vec![pps], 4)
|
||||
});
|
||||
SampleEntry::avc(cfg, self.w as u16, self.h as u16)
|
||||
}
|
||||
fn encode(&mut self, rgba: &[u8], index: u64) -> Result<Vec<Packet>> {
|
||||
rgba_to_yuv420(rgba, self.w as usize, self.h as usize, &mut self.y, &mut self.u, &mut self.v);
|
||||
let cw = (self.w as usize).div_ceil(2);
|
||||
let frame = filmcraft_h264enc::YuvFrame { y: &self.y, u: &self.u, v: &self.v, y_stride: self.w as usize, uv_stride: cw };
|
||||
let ps = self.enc.try_encode(&frame, index as i64 * self.rate.den).map_err(enc)?;
|
||||
Ok(self.packets(ps))
|
||||
}
|
||||
fn flush(&mut self) -> Result<Vec<Packet>> {
|
||||
let ps = self.enc.flush();
|
||||
Ok(self.packets(ps))
|
||||
}
|
||||
fn media_start(&self) -> Option<i64> {
|
||||
(self.enc.delay() > 0).then_some(self.rate.den)
|
||||
}
|
||||
}
|
||||
|
||||
/// BT.709 limited-range 8-bit 4:2:0 from RGBA8 (2×2 chroma average; alpha ignored).
|
||||
pub(crate) fn rgba_to_yuv420(rgba: &[u8], w: usize, h: usize, y: &mut Vec<u8>, u: &mut Vec<u8>, v: &mut Vec<u8>) {
|
||||
let (cw, ch) = (w.div_ceil(2), h.div_ceil(2));
|
||||
y.resize(w * h, 0);
|
||||
u.resize(cw * ch, 0);
|
||||
v.resize(cw * ch, 0);
|
||||
y.par_chunks_mut(w * 2).zip(u.par_chunks_mut(cw).zip(v.par_chunks_mut(cw))).enumerate().for_each(|(cy, (yr, (ur, vr)))| {
|
||||
let rows = yr.len() / w;
|
||||
let mut acc = vec![(0f32, 0f32, 0f32); cw];
|
||||
for dy in 0..rows {
|
||||
let src = &rgba[(cy * 2 + dy) * w * 4..][..w * 4];
|
||||
for x in 0..w {
|
||||
let (r, g, b) = (src[x * 4] as f32 / 255.0, src[x * 4 + 1] as f32 / 255.0, src[x * 4 + 2] as f32 / 255.0);
|
||||
let l = 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
yr[dy * w + x] = (16.0 + 219.0 * l).round().clamp(1.0, 254.0) as u8;
|
||||
let a = &mut acc[x / 2];
|
||||
a.0 += (b - l) / 1.8556;
|
||||
a.1 += (r - l) / 1.5748;
|
||||
a.2 += 1.0;
|
||||
}
|
||||
}
|
||||
for (cx, a) in acc.iter().enumerate() {
|
||||
ur[cx] = (128.0 + 224.0 * a.0 / a.2).round().clamp(1.0, 254.0) as u8;
|
||||
vr[cx] = (128.0 + 224.0 * a.1 / a.2).round().clamp(1.0, 254.0) as u8;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- ProRes
|
||||
|
||||
struct ProRes {
|
||||
enc: filmcraft_prores::Encoder,
|
||||
profile: filmcraft_prores::Profile,
|
||||
w: u32,
|
||||
h: u32,
|
||||
alpha: bool,
|
||||
}
|
||||
|
||||
fn prores_profile(p: ProResProfile) -> filmcraft_prores::Profile {
|
||||
use filmcraft_prores::Profile;
|
||||
match p {
|
||||
ProResProfile::Proxy => Profile::Proxy,
|
||||
ProResProfile::Lt => Profile::Lt,
|
||||
ProResProfile::Standard => Profile::Standard,
|
||||
ProResProfile::Hq => Profile::Hq,
|
||||
ProResProfile::P4444 => Profile::P4444,
|
||||
ProResProfile::P4444Xq => Profile::P4444Xq,
|
||||
}
|
||||
}
|
||||
|
||||
impl ProRes {
|
||||
fn new(w: u32, h: u32, profile: ProResProfile, channels: Channels) -> ProRes {
|
||||
// Alpha needs a 4444 flavour.
|
||||
let profile = if channels == Channels::Rgba && !profile.is_4444() { ProResProfile::P4444 } else { profile };
|
||||
let profile = prores_profile(profile);
|
||||
let alpha = channels == Channels::Rgba;
|
||||
let mut cfg = filmcraft_prores::EncoderConfig::new(profile, w, h);
|
||||
cfg.encode_alpha = alpha;
|
||||
ProRes { enc: filmcraft_prores::Encoder::with_config(cfg), profile, w, h, alpha }
|
||||
}
|
||||
fn is_444(&self) -> bool {
|
||||
matches!(self.profile, filmcraft_prores::Profile::P4444 | filmcraft_prores::Profile::P4444Xq)
|
||||
}
|
||||
}
|
||||
|
||||
impl VideoEncoder for ProRes {
|
||||
fn sample_entry(&self) -> SampleEntry {
|
||||
SampleEntry::prores(FourCc(self.profile.fourcc()), self.w as u16, self.h as u16)
|
||||
}
|
||||
fn encode(&mut self, rgba: &[u8], _index: u64) -> Result<Vec<Packet>> {
|
||||
let chroma = if self.is_444() { filmcraft_prores::ChromaFormat::Yuv444 } else { filmcraft_prores::ChromaFormat::Yuv422 };
|
||||
let mut fr = filmcraft_prores::Frame::new(self.w, self.h, chroma, 10, self.alpha);
|
||||
rgba_to_yuv_10(rgba, self.w as usize, self.h as usize, self.is_444(), &mut fr.y, &mut fr.cb, &mut fr.cr);
|
||||
if let Some(a) = fr.alpha.as_mut() {
|
||||
for (d, s) in a.iter_mut().zip(rgba.chunks_exact(4)) {
|
||||
*d = ((s[3] as u32 * 1023 + 127) / 255) as u16;
|
||||
}
|
||||
}
|
||||
let data = self.enc.encode(&fr).map_err(enc)?;
|
||||
Ok(vec![Packet { data, key: true, cto: 0 }])
|
||||
}
|
||||
fn flush(&mut self) -> Result<Vec<Packet>> {
|
||||
Ok(vec![])
|
||||
}
|
||||
}
|
||||
|
||||
/// BT.709 limited-range 10-bit 4:2:2 (horizontal chroma average) or 4:4:4 from RGBA8.
|
||||
fn rgba_to_yuv_10(rgba: &[u8], w: usize, h: usize, full_chroma: bool, y: &mut [u16], cb: &mut [u16], cr: &mut [u16]) {
|
||||
let cw = if full_chroma { w } else { w.div_ceil(2) };
|
||||
debug_assert!(y.len() >= w * h && rgba.len() >= w * h * 4);
|
||||
y.par_chunks_mut(w).zip(cb.par_chunks_mut(cw).zip(cr.par_chunks_mut(cw))).enumerate().for_each(|(row, (yr, (cbr, crr)))| {
|
||||
let src = &rgba[row * w * 4..(row + 1) * w * 4];
|
||||
let mut us = vec![0f32; w];
|
||||
let mut vs = vec![0f32; w];
|
||||
for x in 0..w {
|
||||
let (r, g, b) = (src[x * 4] as f32 / 255.0, src[x * 4 + 1] as f32 / 255.0, src[x * 4 + 2] as f32 / 255.0);
|
||||
let l = 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
yr[x] = (64.0 + 876.0 * l).round().clamp(4.0, 1019.0) as u16;
|
||||
us[x] = (b - l) / 1.8556;
|
||||
vs[x] = (r - l) / 1.5748;
|
||||
}
|
||||
for cx in 0..cw {
|
||||
let (u, v) = if full_chroma {
|
||||
(us[cx], vs[cx])
|
||||
} else {
|
||||
let (a, b2) = (cx * 2, (cx * 2 + 1).min(w - 1));
|
||||
((us[a] + us[b2]) * 0.5, (vs[a] + vs[b2]) * 0.5)
|
||||
};
|
||||
cbr[cx] = (512.0 + 896.0 * u).round().clamp(4.0, 1019.0) as u16;
|
||||
crr[cx] = (512.0 + 896.0 * v).round().clamp(4.0, 1019.0) as u16;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- audio
|
||||
|
||||
enum Audio {
|
||||
Aac(Box<filmcraft_aac::Encoder>),
|
||||
Pcm,
|
||||
}
|
||||
|
||||
const AUDIO_CHANNELS: u32 = 2;
|
||||
|
||||
fn deinterleave(buf: &[f32]) -> [Vec<f32>; 2] {
|
||||
let l = buf.chunks_exact(2).map(|p| p[0]).collect();
|
||||
let r = buf.chunks_exact(2).map(|p| p[1]).collect();
|
||||
[l, r]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- the movie
|
||||
|
||||
type Writer = Mp4Writer<BufWriter<std::fs::File>>;
|
||||
|
||||
pub(crate) fn movie(job: &Job, comp: &Comp, w: u32, h: u32, st: &mut State) -> Result<Report> {
|
||||
let rate = job.settings.rate(comp);
|
||||
let fmt = job.output.format;
|
||||
let channels = if fmt == OutputFormat::ProRes { job.output.channels } else { Channels::Rgb };
|
||||
let mut venc: Box<dyn VideoEncoder> = match fmt {
|
||||
OutputFormat::H264 => Box::new(H264::new(w, h, rate, job.output.bitrate_kbps)?),
|
||||
_ => Box::new(ProRes::new(w, h, job.output.prores_profile, channels)),
|
||||
};
|
||||
let brand = if fmt == OutputFormat::H264 { Brand::Mp4 } else { Brand::Mov };
|
||||
let batch = batch_size();
|
||||
let render = |k: u64| -> Vec<u8> { rgba8(&render_frame(job, comp, k), comp, channels, w, h) };
|
||||
|
||||
// Encode the first batch before creating tracks: encoders finalise their config on frame 1.
|
||||
let first_end = batch.min(st.total);
|
||||
let first: Vec<Vec<u8>> = (0..first_end).into_par_iter().map(render).collect();
|
||||
let mut pending = Vec::new();
|
||||
for (k, px) in first.iter().enumerate() {
|
||||
pending.extend(venc.encode(px, k as u64)?);
|
||||
}
|
||||
drop(first);
|
||||
st.advance(first_end)?;
|
||||
|
||||
let file = std::fs::File::create(job.path).map_err(mux_err)?;
|
||||
let opts = WriterOptions::new(brand);
|
||||
let movie_ts = opts.movie_timescale.max(1) as i128;
|
||||
let mut mux: Writer = Mp4Writer::new(BufWriter::new(file), opts).map_err(mux_err)?;
|
||||
let mut vcfg = TrackConfig::new(venc.sample_entry(), rate.num as u32);
|
||||
if let Some(start) = venc.media_start() {
|
||||
// An explicit edit: the convenience `media_start` edit would subtract the B-frame delay
|
||||
// from the media duration and drop the last frame.
|
||||
let frames_ts = st.total as i128 * rate.den as i128;
|
||||
let seg = (frames_ts * movie_ts + rate.num as i128 / 2) / rate.num as i128;
|
||||
vcfg.edits = vec![filmcraft_isobmff::Edit { segment_duration: seg as u64, media_time: start, media_rate: 0x10000 }];
|
||||
}
|
||||
let vt = mux.add_track(vcfg).map_err(mux_err)?;
|
||||
let sr = job.output.audio_sample_rate.clamp(8_000, 192_000);
|
||||
let with_audio = wants_audio(job);
|
||||
let mut audio = None;
|
||||
if with_audio {
|
||||
let (a, entry, start) = if brand == Brand::Mp4 {
|
||||
let e = filmcraft_aac::Encoder::new(filmcraft_aac::EncoderConfig::cbr(sr, AUDIO_CHANNELS as usize, 320_000)).map_err(enc)?;
|
||||
let entry = SampleEntry::aac(e.audio_specific_config(), AUDIO_CHANNELS, sr);
|
||||
let priming = e.priming_samples() as i64;
|
||||
(Audio::Aac(Box::new(e)), entry, Some(priming))
|
||||
} else {
|
||||
let pcm = PcmConfig { bits: 16, float: false, big_endian: false, signed: true, channels: AUDIO_CHANNELS, sample_rate: sr as f64 };
|
||||
(Audio::Pcm, SampleEntry::pcm(pcm), None)
|
||||
};
|
||||
let mut c = TrackConfig::new(entry, sr);
|
||||
c.media_start = start;
|
||||
let at = mux.add_track(c).map_err(mux_err)?;
|
||||
audio = Some((a, at));
|
||||
}
|
||||
let (span_start, span_end) = job.settings.span(comp);
|
||||
let mut cursor = span_start.to_units_floor(sr as i64);
|
||||
|
||||
let write_video = |mux: &mut Writer, ps: Vec<Packet>| -> Result<()> {
|
||||
for p in ps {
|
||||
mux.write_sample(vt, WriteSample { data: &p.data, duration: rate.den as u32, composition_offset: p.cto, is_sync: p.key }).map_err(mux_err)?;
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
let mut write_audio_until = |mux: &mut Writer, until: Tick, cursor: &mut i64| -> Result<()> {
|
||||
let Some((a, at)) = audio.as_mut() else { return Ok(()) };
|
||||
let end = until.to_units_floor(sr as i64);
|
||||
if end <= *cursor {
|
||||
return Ok(());
|
||||
}
|
||||
let n = (end - *cursor) as usize;
|
||||
let start = Tick(((*cursor as i128 * TICKS_PER_SECOND as i128) / sr as i128) as i64);
|
||||
let buf = effectcraft_render::audio::mix_comp(job.project, job.footage, job.expr, job.comp, start, n, sr);
|
||||
*cursor = end;
|
||||
match a {
|
||||
Audio::Aac(e) => {
|
||||
let planar = deinterleave(&buf);
|
||||
let refs: Vec<&[f32]> = planar.iter().map(Vec::as_slice).collect();
|
||||
for au in e.encode(&refs) {
|
||||
mux.write_sample(*at, WriteSample { data: &au, duration: 1024, composition_offset: 0, is_sync: true }).map_err(mux_err)?;
|
||||
}
|
||||
}
|
||||
Audio::Pcm => {
|
||||
let mut pcm = Vec::with_capacity(n * 4);
|
||||
for s in buf {
|
||||
pcm.extend_from_slice(&((s.clamp(-1.0, 1.0) * 32767.0).round() as i16).to_le_bytes());
|
||||
}
|
||||
mux.write_sample(*at, WriteSample { data: &pcm, duration: n as u32, composition_offset: 0, is_sync: true }).map_err(mux_err)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
let total = st.total;
|
||||
let frame_end_time = |k: u64| -> Tick { if k >= total { span_end } else { job.settings.frame_time(comp, k) } };
|
||||
write_video(&mut mux, pending)?;
|
||||
write_audio_until(&mut mux, frame_end_time(first_end), &mut cursor)?;
|
||||
let mut i = first_end;
|
||||
while i < total {
|
||||
let end = (i + batch).min(total);
|
||||
let frames: Vec<Vec<u8>> = (i..end).into_par_iter().map(render).collect();
|
||||
for (k, px) in frames.iter().enumerate() {
|
||||
let ps = venc.encode(px, i + k as u64)?;
|
||||
write_video(&mut mux, ps)?;
|
||||
}
|
||||
write_audio_until(&mut mux, frame_end_time(end), &mut cursor)?;
|
||||
st.advance(end - i)?;
|
||||
i = end;
|
||||
}
|
||||
write_video(&mut mux, venc.flush()?)?;
|
||||
write_audio_until(&mut mux, span_end, &mut cursor)?;
|
||||
if let Some((Audio::Aac(e), at)) = audio.as_mut() {
|
||||
for au in e.flush() {
|
||||
mux.write_sample(*at, WriteSample { data: &au, duration: 1024, composition_offset: 0, is_sync: true }).map_err(mux_err)?;
|
||||
}
|
||||
}
|
||||
let mut out = mux.finish().map_err(mux_err)?;
|
||||
out.flush().map_err(mux_err)?;
|
||||
drop(out);
|
||||
let bytes = std::fs::metadata(job.path).map(|m| m.len()).unwrap_or(0);
|
||||
Ok(Report { path: job.path.to_string(), frames: 0, width: w, height: h, seconds: 0.0, bytes, audio: with_audio })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn yuv_of_primaries() {
|
||||
let rgba = [255u8, 255, 255, 255, 0, 0, 0, 255, 255, 0, 0, 255, 0, 255, 0, 255];
|
||||
let (mut y, mut u, mut v) = (vec![], vec![], vec![]);
|
||||
rgba_to_yuv420(&rgba, 2, 2, &mut y, &mut u, &mut v);
|
||||
assert_eq!(y[0], 235);
|
||||
assert_eq!(y[1], 16);
|
||||
assert_eq!((u.len(), v.len()), (1, 1));
|
||||
let (mut y10, mut cb, mut cr) = (vec![0u16; 4], vec![0u16; 4], vec![0u16; 4]);
|
||||
rgba_to_yuv_10(&rgba, 2, 2, true, &mut y10, &mut cb, &mut cr);
|
||||
assert_eq!(y10[0], 940);
|
||||
assert_eq!(y10[1], 64);
|
||||
assert_eq!(cb[0], 512);
|
||||
assert!(cr[2] > 900, "red has high Cr: {}", cr[2]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,350 @@
|
|||
//! Render Queue jobs: render a composition's time span and write it through an output module.
|
||||
//!
|
||||
//! | Format | Container / files | Video | Alpha | Audio |
|
||||
//! |---|---|---|---|---|
|
||||
//! | H.264 | MP4 | FilmCraft H.264 High, 8-bit 4:2:0 BT.709 limited, VBR | no | AAC-LC (FilmCraft), stereo |
|
||||
//! | ProRes | QuickTime MOV | FilmCraft ProRes 422 Proxy/LT/422/HQ, 10-bit; 4444/4444 XQ 4:4:4 | 4444 (16-bit alpha coding) | 16-bit PCM, stereo |
|
||||
//! | PNG / JPEG / TIFF sequence | one file per frame | 8-bit | PNG, TIFF | — |
|
||||
//! | OpenEXR sequence | one file per frame | 32-bit float, linear light, premultiplied | yes | — |
|
||||
//! | Animated GIF | GIF89a | 256-colour palette per frame (NeuQuant) | 1-bit | — |
|
||||
//!
|
||||
//! Frames are rendered in parallel batches (rayon; one batch ≈ one frame per core) and handed to
|
||||
//! the encoder in order. Progress is reported after each batch through a callback that can cancel
|
||||
//! the job by returning `false`. Codecs and the MP4/MOV muxer are FilmCraft's pure-Rust crates
|
||||
//! (plan ADR 0001); nothing outside this crate touches them.
|
||||
//!
|
||||
//! H.264 requires even dimensions: odd output sizes are cropped by one pixel (padded when the
|
||||
//! output is a single pixel wide/high).
|
||||
|
||||
mod encode;
|
||||
|
||||
use std::path::Path;
|
||||
use std::time::Instant;
|
||||
|
||||
use effectcraft_project::render_queue::{AudioOutput, Channels, OutputFormat, OutputModule, RenderQuality, RenderSettings, sequence_path};
|
||||
use effectcraft_project::{Comp, ItemId, Project};
|
||||
use effectcraft_raster::Image;
|
||||
use effectcraft_render::{ExprHost, FootageSource, RenderOpts, Renderer};
|
||||
use effectcraft_time::Tick;
|
||||
use rayon::prelude::*;
|
||||
|
||||
pub use effectcraft_project::render_queue;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ExportError {
|
||||
#[error("composition not found")]
|
||||
NoComp,
|
||||
#[error("cancelled")]
|
||||
Cancelled,
|
||||
#[error("i/o: {0}")]
|
||||
Io(String),
|
||||
#[error("encode: {0}")]
|
||||
Encode(String),
|
||||
#[error("{0}")]
|
||||
Unsupported(String),
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, ExportError>;
|
||||
|
||||
fn io(e: impl std::fmt::Display) -> ExportError {
|
||||
ExportError::Io(e.to_string())
|
||||
}
|
||||
|
||||
/// One export: a comp, its Render Settings and Output Module, and the resolved output path.
|
||||
pub struct Job<'a> {
|
||||
pub project: &'a Project,
|
||||
pub footage: &'a dyn FootageSource,
|
||||
pub expr: Option<&'a dyn ExprHost>,
|
||||
pub comp: ItemId,
|
||||
pub settings: &'a RenderSettings,
|
||||
pub output: &'a OutputModule,
|
||||
/// Output file. For image sequences a run of `#` (or `[#####]`) becomes the frame number; a
|
||||
/// path without one gets `_NNNNN` before the extension.
|
||||
pub path: &'a str,
|
||||
}
|
||||
|
||||
/// Progress after a batch of frames.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct Progress {
|
||||
pub done: u64,
|
||||
pub total: u64,
|
||||
pub elapsed: f64,
|
||||
}
|
||||
|
||||
impl Progress {
|
||||
pub fn fraction(&self) -> f64 {
|
||||
if self.total == 0 { 0.0 } else { self.done as f64 / self.total as f64 }
|
||||
}
|
||||
/// Estimated seconds remaining.
|
||||
pub fn remaining(&self) -> Option<f64> {
|
||||
(self.done > 0).then(|| self.elapsed / self.done as f64 * self.total.saturating_sub(self.done) as f64)
|
||||
}
|
||||
}
|
||||
|
||||
/// What an export produced.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Report {
|
||||
/// The movie file, or the first file of a sequence.
|
||||
pub path: String,
|
||||
pub frames: u64,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub seconds: f64,
|
||||
pub bytes: u64,
|
||||
pub audio: bool,
|
||||
}
|
||||
|
||||
/// Formats this build can write (all of them; kept as the hook for optional encoders).
|
||||
pub fn available_formats() -> Vec<OutputFormat> {
|
||||
OutputFormat::ALL.to_vec()
|
||||
}
|
||||
|
||||
/// The size of the written frames for a job (H.264 rounds to even).
|
||||
pub fn output_size(comp: &Comp, settings: &RenderSettings, output: &OutputModule) -> (u32, u32) {
|
||||
let (w, h) = settings.output_size(comp);
|
||||
output.format.coded_size(w, h)
|
||||
}
|
||||
|
||||
/// Whether the job writes an audio track.
|
||||
pub fn wants_audio(job: &Job) -> bool {
|
||||
job.output.format.supports_audio()
|
||||
&& match job.output.audio {
|
||||
AudioOutput::On => true,
|
||||
AudioOutput::Off => false,
|
||||
AudioOutput::Auto => effectcraft_render::audio::comp_has_audio(job.project, job.comp),
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the export (blocking). `progress` is called after every batch; returning `false` cancels.
|
||||
pub fn export(job: &Job, progress: &mut dyn FnMut(&Progress) -> bool) -> Result<Report> {
|
||||
let comp = job.project.comp(job.comp).ok_or(ExportError::NoComp)?;
|
||||
let t0 = Instant::now();
|
||||
let total = job.settings.frame_count(comp);
|
||||
let mut st = State { t0, total, done: 0, progress };
|
||||
st.advance(0)?;
|
||||
if let Some(dir) = Path::new(job.path).parent().filter(|d| !d.as_os_str().is_empty()) {
|
||||
std::fs::create_dir_all(dir).map_err(io)?;
|
||||
}
|
||||
let (w, h) = output_size(comp, job.settings, job.output);
|
||||
let mut report = match job.output.format {
|
||||
OutputFormat::H264 | OutputFormat::ProRes => encode::movie(job, comp, w, h, &mut st)?,
|
||||
OutputFormat::Gif => gif_export(job, comp, w, h, &mut st)?,
|
||||
f if f.is_sequence() => sequence(job, comp, w, h, &mut st)?,
|
||||
f => return Err(ExportError::Unsupported(f.label().into())),
|
||||
};
|
||||
report.seconds = t0.elapsed().as_secs_f64();
|
||||
report.frames = total;
|
||||
(report.width, report.height) = (w, h);
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
pub(crate) struct State<'p> {
|
||||
t0: Instant,
|
||||
total: u64,
|
||||
done: u64,
|
||||
progress: &'p mut dyn FnMut(&Progress) -> bool,
|
||||
}
|
||||
|
||||
impl State<'_> {
|
||||
fn snapshot(&self) -> Progress {
|
||||
Progress { done: self.done, total: self.total, elapsed: self.t0.elapsed().as_secs_f64() }
|
||||
}
|
||||
/// Count `n` more frames; `Err(Cancelled)` when the callback says stop.
|
||||
pub(crate) fn advance(&mut self, n: u64) -> Result<()> {
|
||||
self.done += n;
|
||||
let p = self.snapshot();
|
||||
if (self.progress)(&p) { Ok(()) } else { Err(ExportError::Cancelled) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Frames per parallel batch.
|
||||
pub(crate) fn batch_size() -> u64 {
|
||||
rayon::current_num_threads().clamp(2, 16) as u64
|
||||
}
|
||||
|
||||
/// Render output frame `i` of the job at the settings' resolution.
|
||||
pub(crate) fn render_frame(job: &Job, comp: &Comp, i: u64) -> Image {
|
||||
let t: Tick = job.settings.frame_time(comp, i);
|
||||
let opts = RenderOpts {
|
||||
scale: job.settings.resolution.clamp(0.01, 4.0),
|
||||
motion_blur: job.settings.motion_blur,
|
||||
guides: false,
|
||||
draft: job.settings.quality == RenderQuality::Draft,
|
||||
};
|
||||
let mut r = Renderer::new(job.project, job.footage, opts);
|
||||
r.expr = job.expr;
|
||||
r.comp_frame(job.comp, t)
|
||||
}
|
||||
|
||||
/// 8-bit RGBA of the requested channels (RGB: over the comp background, opaque; RGBA: straight),
|
||||
/// cropped/padded to `w`×`h`.
|
||||
pub(crate) fn rgba8(img: &Image, comp: &Comp, channels: Channels, w: u32, h: u32) -> Vec<u8> {
|
||||
let px = match channels {
|
||||
Channels::Rgb => img.to_rgba8_over(comp.background),
|
||||
Channels::Rgba => img.to_rgba8(),
|
||||
};
|
||||
fit(px, img.width, img.height, w, h)
|
||||
}
|
||||
|
||||
fn fit(px: Vec<u8>, iw: u32, ih: u32, w: u32, h: u32) -> Vec<u8> {
|
||||
if iw == w && ih == h {
|
||||
return px;
|
||||
}
|
||||
let mut out = vec![0u8; (w * h * 4) as usize];
|
||||
for y in 0..h.min(ih) as usize {
|
||||
let n = w.min(iw) as usize * 4;
|
||||
out[y * w as usize * 4..y * w as usize * 4 + n].copy_from_slice(&px[y * iw as usize * 4..y * iw as usize * 4 + n]);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The comp frame number of output frame 0 (sequence numbering follows comp frames, like AE).
|
||||
fn first_frame_number(job: &Job, comp: &Comp) -> i64 {
|
||||
job.settings.first_frame(comp)
|
||||
}
|
||||
|
||||
fn sequence(job: &Job, comp: &Comp, w: u32, h: u32, st: &mut State) -> Result<Report> {
|
||||
let f0 = first_frame_number(job, comp);
|
||||
let fmt = job.output.format;
|
||||
let channels = if fmt.supports_alpha() { job.output.channels } else { Channels::Rgb };
|
||||
let batch = batch_size();
|
||||
let mut bytes = 0u64;
|
||||
let mut i = 0;
|
||||
while i < st.total {
|
||||
let end = (i + batch).min(st.total);
|
||||
let written: Vec<Result<u64>> = (i..end)
|
||||
.into_par_iter()
|
||||
.map(|k| {
|
||||
let path = sequence_path(job.path, f0 + k as i64);
|
||||
if job.settings.skip_existing && Path::new(&path).exists() {
|
||||
return Ok(0);
|
||||
}
|
||||
let img = render_frame(job, comp, k);
|
||||
write_still(&path, fmt, &img, comp, channels, w, h, job.output.quality)?;
|
||||
Ok(std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0))
|
||||
})
|
||||
.collect();
|
||||
for r in written {
|
||||
bytes += r?;
|
||||
}
|
||||
st.advance(end - i)?;
|
||||
i = end;
|
||||
}
|
||||
Ok(Report { path: sequence_path(job.path, f0), frames: 0, width: w, height: h, seconds: 0.0, bytes, audio: false })
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn write_still(path: &str, fmt: OutputFormat, img: &Image, comp: &Comp, channels: Channels, w: u32, h: u32, quality: u8) -> Result<()> {
|
||||
use image::{ExtendedColorType, ImageEncoder, ImageFormat};
|
||||
let file = || std::fs::File::create(path).map(std::io::BufWriter::new).map_err(io);
|
||||
match fmt {
|
||||
OutputFormat::ExrSequence => {
|
||||
// Linear light, premultiplied (the EXR convention); RGB keeps alpha = 1 over the background.
|
||||
let lin = |v: f32| if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) };
|
||||
let mut data = Vec::with_capacity((w * h * 4) as usize);
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let p = if x < img.width && y < img.height { img.data[img.idx(x, y)] } else { [0.0; 4] };
|
||||
let (c, a) = match channels {
|
||||
Channels::Rgba => {
|
||||
let a = p[3].clamp(0.0, 1.0);
|
||||
let s = |c: f32| if a > 0.0 { lin((c / a).clamp(0.0, 1.0)) * a } else { 0.0 };
|
||||
([s(p[0]), s(p[1]), s(p[2])], a)
|
||||
}
|
||||
Channels::Rgb => {
|
||||
let k = 1.0 - p[3].clamp(0.0, 1.0);
|
||||
let c = |i: usize| lin((p[i] + comp.background[i] * k).clamp(0.0, 1.0));
|
||||
([c(0), c(1), c(2)], 1.0)
|
||||
}
|
||||
};
|
||||
data.extend_from_slice(&[c[0], c[1], c[2], a]);
|
||||
}
|
||||
}
|
||||
let buf = image::Rgba32FImage::from_raw(w, h, data).ok_or_else(|| ExportError::Encode("exr buffer".into()))?;
|
||||
image::DynamicImage::ImageRgba32F(buf).save_with_format(path, ImageFormat::OpenExr).map_err(|e| ExportError::Encode(e.to_string()))
|
||||
}
|
||||
OutputFormat::JpegSequence => {
|
||||
let px = rgba8(img, comp, Channels::Rgb, w, h);
|
||||
let rgb: Vec<u8> = px.chunks_exact(4).flat_map(|p| [p[0], p[1], p[2]]).collect();
|
||||
image::codecs::jpeg::JpegEncoder::new_with_quality(file()?, quality.clamp(1, 100))
|
||||
.write_image(&rgb, w, h, ExtendedColorType::Rgb8)
|
||||
.map_err(|e| ExportError::Encode(e.to_string()))
|
||||
}
|
||||
OutputFormat::PngSequence | OutputFormat::TiffSequence => {
|
||||
let px = rgba8(img, comp, channels, w, h);
|
||||
let (data, ct) = match channels {
|
||||
Channels::Rgba => (px, ExtendedColorType::Rgba8),
|
||||
Channels::Rgb => (px.chunks_exact(4).flat_map(|p| [p[0], p[1], p[2]]).collect(), ExtendedColorType::Rgb8),
|
||||
};
|
||||
if fmt == OutputFormat::PngSequence {
|
||||
image::codecs::png::PngEncoder::new(file()?).write_image(&data, w, h, ct).map_err(|e| ExportError::Encode(e.to_string()))
|
||||
} else {
|
||||
// The TIFF encoder needs Seek.
|
||||
let f = std::fs::File::create(path).map_err(io)?;
|
||||
image::codecs::tiff::TiffEncoder::new(std::io::BufWriter::new(f)).write_image(&data, w, h, ct).map_err(|e| ExportError::Encode(e.to_string()))
|
||||
}
|
||||
}
|
||||
f => Err(ExportError::Unsupported(format!("{} is not a still format", f.label()))),
|
||||
}
|
||||
}
|
||||
|
||||
fn gif_export(job: &Job, comp: &Comp, w: u32, h: u32, st: &mut State) -> Result<Report> {
|
||||
if w > u16::MAX as u32 || h > u16::MAX as u32 {
|
||||
return Err(ExportError::Unsupported("GIF frames are limited to 65535 px".into()));
|
||||
}
|
||||
let file = std::fs::File::create(job.path).map_err(io)?;
|
||||
let mut enc = gif::Encoder::new(std::io::BufWriter::new(file), w as u16, h as u16, &[]).map_err(|e| ExportError::Encode(e.to_string()))?;
|
||||
enc.set_repeat(if job.output.gif_loop { gif::Repeat::Infinite } else { gif::Repeat::Finite(0) }).map_err(|e| ExportError::Encode(e.to_string()))?;
|
||||
let rate = job.settings.rate(comp);
|
||||
// GIF delays are in 1/100 s: carry the rounding error so long animations keep their timing.
|
||||
let cs_per_frame = 100.0 * rate.den as f64 / rate.num as f64;
|
||||
let mut clock = 0.0f64;
|
||||
let mut emitted = 0u64;
|
||||
let batch = batch_size();
|
||||
let mut i = 0;
|
||||
while i < st.total {
|
||||
let end = (i + batch).min(st.total);
|
||||
let frames: Vec<gif::Frame<'static>> = (i..end)
|
||||
.into_par_iter()
|
||||
.map(|k| {
|
||||
let img = render_frame(job, comp, k);
|
||||
let mut px = rgba8(&img, comp, job.output.channels, w, h);
|
||||
gif::Frame::from_rgba_speed(w as u16, h as u16, &mut px, 10)
|
||||
})
|
||||
.collect();
|
||||
for mut f in frames {
|
||||
clock += cs_per_frame;
|
||||
let d = (clock.round() as u64).saturating_sub(emitted).max(1);
|
||||
emitted += d;
|
||||
f.delay = d.min(u16::MAX as u64) as u16;
|
||||
if job.output.channels == Channels::Rgba {
|
||||
f.dispose = gif::DisposalMethod::Background;
|
||||
}
|
||||
enc.write_frame(&f).map_err(|e| ExportError::Encode(e.to_string()))?;
|
||||
}
|
||||
st.advance(end - i)?;
|
||||
i = end;
|
||||
}
|
||||
drop(enc);
|
||||
let bytes = std::fs::metadata(job.path).map(|m| m.len()).unwrap_or(0);
|
||||
Ok(Report { path: job.path.to_string(), frames: 0, width: w, height: h, seconds: 0.0, bytes, audio: false })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn fit_crops_and_pads() {
|
||||
let px: Vec<u8> = (0..3 * 3 * 4).map(|v| v as u8).collect();
|
||||
let c = fit(px.clone(), 3, 3, 2, 2);
|
||||
assert_eq!(c.len(), 16);
|
||||
assert_eq!(&c[0..8], &px[0..8]);
|
||||
assert_eq!(&c[8..16], &px[12..20]);
|
||||
let p = fit(px, 3, 3, 4, 4);
|
||||
assert_eq!(p.len(), 64);
|
||||
assert_eq!(&p[12..16], &[0, 0, 0, 0]);
|
||||
assert_eq!(OutputFormat::H264.coded_size(1, 101), (2, 100));
|
||||
assert_eq!(OutputFormat::Gif.coded_size(1, 101), (1, 101));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,365 @@
|
|||
//! Encode a short comp to every output format and decode it back: through EffectCraft's own
|
||||
//! media layer (FilmCraft decoders) for MP4/MOV, the `image`/`gif` crates for stills and GIF,
|
||||
//! and — when installed — ffprobe as an external oracle (skipped otherwise).
|
||||
//!
|
||||
//! The comp: 64×48 @ 10 fps, 1.2 s (12 frames), dark-blue background, a red solid of 32×48
|
||||
//! centred, starting at frame 5. So frame < 5 is background everywhere; from frame 5 the centre
|
||||
//! is red and the corners stay background.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use effectcraft_color::Label;
|
||||
use effectcraft_export::render_queue::{AudioOutput, Channels, OutputFormat, OutputModule, ProResProfile, RenderSettings, TimeSpan, sequence_path};
|
||||
use effectcraft_export::{Job, Progress, export};
|
||||
use effectcraft_media::MediaPool;
|
||||
use effectcraft_project::{Comp, ItemId, ItemKind, LayerSource, Project, Solid, build};
|
||||
use effectcraft_raster::Image;
|
||||
use effectcraft_render::{FootageSource, NoFootage};
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
|
||||
const W: u32 = 64;
|
||||
const H: u32 = 48;
|
||||
const BG: [f32; 3] = [0.1, 0.1, 0.4];
|
||||
const FRAMES: u64 = 12;
|
||||
|
||||
fn out_dir(name: &str) -> PathBuf {
|
||||
let d = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/test-out/export").join(name);
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).expect("mkdir");
|
||||
d
|
||||
}
|
||||
|
||||
fn project(audio: Option<&Path>) -> (Project, ItemId) {
|
||||
let mut p = Project::default();
|
||||
let mut comp = Comp::new(W, H, FrameRate::new(10, 1), Tick::from_seconds_f64(1.2));
|
||||
comp.background = BG;
|
||||
let sid = p.add_item("Red", Label::Red, None, ItemKind::Solid(Solid { color: [1.0, 0.0, 0.0], width: 32, height: H, pixel_aspect: 1.0 }));
|
||||
let mut l = build::layer(&mut p, &comp, "Red", LayerSource::Solid { item: sid }, (32, H), None);
|
||||
l.in_point = comp.frame_rate.tick_of(5);
|
||||
comp.layers.push(l);
|
||||
if let Some(path) = audio {
|
||||
let f = effectcraft_media::probe(path).expect("probe wav");
|
||||
let fid = p.add_item("tone.wav", Label::SeaFoam, None, ItemKind::Footage(f));
|
||||
let l = build::layer(&mut p, &comp, "tone", LayerSource::Footage { item: fid }, (0, 0), None);
|
||||
comp.layers.push(l);
|
||||
}
|
||||
let cid = p.add_item("Comp 1", Label::Sandstone, None, ItemKind::Comp(comp.into()));
|
||||
(p, cid)
|
||||
}
|
||||
|
||||
fn settings() -> RenderSettings {
|
||||
RenderSettings { time_span: TimeSpan::LengthOfComp, ..Default::default() }
|
||||
}
|
||||
|
||||
fn run(p: &Project, cid: ItemId, footage: &dyn FootageSource, om: &OutputModule, path: &Path) -> effectcraft_export::Report {
|
||||
let s = settings();
|
||||
let path = path.to_string_lossy().to_string();
|
||||
let job = Job { project: p, footage, expr: None, comp: cid, settings: &s, output: om, path: &path };
|
||||
let mut last = Progress::default();
|
||||
let mut calls = 0;
|
||||
let r = export(&job, &mut |pr| {
|
||||
assert!(pr.done >= last.done, "progress is monotonic");
|
||||
last = *pr;
|
||||
calls += 1;
|
||||
true
|
||||
})
|
||||
.expect("export");
|
||||
assert_eq!(last.done, FRAMES);
|
||||
assert_eq!(last.total, FRAMES);
|
||||
assert!(calls >= 2);
|
||||
assert_eq!(r.frames, FRAMES);
|
||||
r
|
||||
}
|
||||
|
||||
fn px(img: &Image, x: u32, y: u32) -> [f32; 4] {
|
||||
img.data[img.idx(x, y)]
|
||||
}
|
||||
|
||||
fn near(a: [f32; 3], b: [f32; 3], tol: f32) -> bool {
|
||||
a.iter().zip(b).all(|(x, y)| (x - y).abs() <= tol)
|
||||
}
|
||||
|
||||
/// Check a decoded (premultiplied, opaque) frame `k`.
|
||||
fn check_frame(img: &Image, k: u64, tol: f32) {
|
||||
let corner = px(img, 2, 2);
|
||||
assert!(near([corner[0], corner[1], corner[2]], BG, tol), "frame {k} corner {corner:?}");
|
||||
let c = px(img, img.width / 2, img.height / 2);
|
||||
let want = if k >= 5 { [1.0, 0.0, 0.0] } else { BG };
|
||||
assert!(near([c[0], c[1], c[2]], want, tol), "frame {k} centre {c:?} want {want:?}");
|
||||
}
|
||||
|
||||
fn decode_movie(path: &Path, frames: u64, tol: f32) -> effectcraft_project::Footage {
|
||||
let f = effectcraft_media::probe(path).expect("probe our output");
|
||||
assert_eq!((f.width, f.height), (W, H));
|
||||
assert!(f.has_video);
|
||||
let n = f.frame_rate.frame_at(f.duration - Tick(1)) + 1;
|
||||
assert_eq!(n as u64, frames, "frame count from duration {:?} @ {:?}", f.duration, f.frame_rate);
|
||||
let pool = MediaPool::new();
|
||||
for k in 0..frames {
|
||||
let img = pool.frame_at(&f, f.frame_rate.tick_of(k as i64)).expect("decode");
|
||||
check_frame(&img, k, tol);
|
||||
}
|
||||
f
|
||||
}
|
||||
|
||||
fn ffprobe(path: &Path) -> Option<String> {
|
||||
let out = Command::new("ffprobe")
|
||||
.args(["-v", "error", "-count_frames", "-show_entries", "stream=codec_name,width,height,nb_read_frames,pix_fmt", "-of", "compact=p=0:nk=1"])
|
||||
.arg(path)
|
||||
.output()
|
||||
.ok()?;
|
||||
if !out.status.success() {
|
||||
panic!("ffprobe rejected {}: {}", path.display(), String::from_utf8_lossy(&out.stderr));
|
||||
}
|
||||
Some(String::from_utf8_lossy(&out.stdout).to_string())
|
||||
}
|
||||
|
||||
/// Frame `k` decoded by ffmpeg (external oracle) as RGBA, or `None` without ffmpeg.
|
||||
fn ffmpeg_frame(path: &Path, k: u64) -> Option<Image> {
|
||||
let png = path.with_extension(format!("ffmpeg-{k}.png"));
|
||||
let st = Command::new("ffmpeg")
|
||||
.args(["-hide_banner", "-loglevel", "error", "-y", "-i"])
|
||||
.arg(path)
|
||||
.args(["-vf", &format!("select=eq(n\\,{k})"), "-frames:v", "1", "-pix_fmt", "rgba"])
|
||||
.arg(&png)
|
||||
.status()
|
||||
.ok()?;
|
||||
assert!(st.success(), "ffmpeg failed to decode {}", path.display());
|
||||
let img = image::open(&png).expect("ffmpeg png").to_rgba8();
|
||||
Some(Image::from_rgba8(img.width(), img.height(), img.as_raw()))
|
||||
}
|
||||
|
||||
fn ffmpeg_check(path: &Path, tol: f32) {
|
||||
for k in [0, 4, 5, FRAMES - 1] {
|
||||
match ffmpeg_frame(path, k) {
|
||||
Some(img) => check_frame(&img, k, tol),
|
||||
None => {
|
||||
eprintln!("ffmpeg not found: skipping oracle decode");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn h264_mp4_roundtrip() {
|
||||
let d = out_dir("h264");
|
||||
let (p, cid) = project(None);
|
||||
let om = OutputModule { bitrate_kbps: 4000, ..OutputModule::for_format(OutputFormat::H264) };
|
||||
let path = d.join("comp.mp4");
|
||||
let r = run(&p, cid, &NoFootage, &om, &path);
|
||||
assert!(r.bytes > 100 && !r.audio);
|
||||
decode_movie(&path, FRAMES, 0.08);
|
||||
ffmpeg_check(&path, 0.08);
|
||||
if let Some(s) = ffprobe(&path) {
|
||||
assert!(s.contains("h264|64|48|yuv420p"), "{s}");
|
||||
assert!(s.lines().next().unwrap_or("").ends_with("|12"), "{s}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn h264_odd_size_is_cropped_even() {
|
||||
let d = out_dir("h264-odd");
|
||||
let (p, cid) = project(None);
|
||||
let om = OutputModule::for_format(OutputFormat::H264);
|
||||
let s = RenderSettings { resolution: 31.0 / 64.0, ..settings() };
|
||||
let comp = p.comp(cid).expect("comp");
|
||||
assert_eq!(s.output_size(comp).0 % 2, 1);
|
||||
let path = d.join("odd.mp4").to_string_lossy().to_string();
|
||||
let job = Job { project: &p, footage: &NoFootage, expr: None, comp: cid, settings: &s, output: &om, path: &path };
|
||||
let r = export(&job, &mut |_| true).expect("export");
|
||||
assert_eq!((r.width % 2, r.height % 2), (0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prores_mov_roundtrip() {
|
||||
let d = out_dir("prores");
|
||||
let (p, cid) = project(None);
|
||||
let om = OutputModule::for_format(OutputFormat::ProRes);
|
||||
let path = d.join("comp.mov");
|
||||
run(&p, cid, &NoFootage, &om, &path);
|
||||
decode_movie(&path, FRAMES, 0.03);
|
||||
ffmpeg_check(&path, 0.03);
|
||||
if let Some(s) = ffprobe(&path) {
|
||||
assert!(s.starts_with("prores|64|48"), "{s}");
|
||||
assert!(s.lines().next().unwrap_or("").ends_with("|12"), "{s}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prores_4444_with_alpha() {
|
||||
let d = out_dir("prores4444");
|
||||
let (p, cid) = project(None);
|
||||
let om = OutputModule { channels: Channels::Rgba, prores_profile: ProResProfile::Hq, ..OutputModule::for_format(OutputFormat::ProRes) };
|
||||
let path = d.join("alpha.mov");
|
||||
run(&p, cid, &NoFootage, &om, &path);
|
||||
let f = effectcraft_media::probe(&path).expect("probe");
|
||||
let img = MediaPool::new().frame_at(&f, f.frame_rate.tick_of(7)).expect("decode");
|
||||
assert!(px(&img, 2, 2)[3] < 0.02, "corner transparent: {:?}", px(&img, 2, 2));
|
||||
let c = px(&img, W / 2, H / 2);
|
||||
assert!(c[3] > 0.98 && c[0] > 0.95 && c[1] < 0.05, "centre opaque red: {c:?}");
|
||||
if let Some(s) = ffprobe(&path) {
|
||||
assert!(s.starts_with("prores|64|48|yuva444"), "{s}");
|
||||
}
|
||||
}
|
||||
|
||||
fn check_still_dir(dir: &Path, ext: &str, tol: f32, alpha: bool) {
|
||||
for k in 0..FRAMES {
|
||||
let path = sequence_path(&dir.join(format!("f_[#####].{ext}")).to_string_lossy(), k as i64);
|
||||
let img = image::open(&path).unwrap_or_else(|e| panic!("{path}: {e}")).to_rgba8();
|
||||
assert_eq!(img.dimensions(), (W, H));
|
||||
let img = Image::from_rgba8(W, H, img.as_raw());
|
||||
if alpha {
|
||||
let corner = px(&img, 2, 2);
|
||||
assert!(corner[3] < 0.01, "{path}: corner {corner:?}");
|
||||
let c = px(&img, W / 2, H / 2);
|
||||
assert_eq!(c[3] > 0.99, k >= 5, "{path}: centre {c:?}");
|
||||
} else {
|
||||
check_frame(&img, k, tol);
|
||||
}
|
||||
}
|
||||
assert!(!dir.join(format!("f_{:05}.{ext}", FRAMES)).exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_sequences_roundtrip() {
|
||||
let (p, cid) = project(None);
|
||||
for (fmt, ext, tol, ch) in [
|
||||
(OutputFormat::PngSequence, "png", 0.01, Channels::Rgb),
|
||||
(OutputFormat::PngSequence, "png", 0.01, Channels::Rgba),
|
||||
(OutputFormat::JpegSequence, "jpg", 0.06, Channels::Rgb),
|
||||
(OutputFormat::TiffSequence, "tif", 0.01, Channels::Rgb),
|
||||
(OutputFormat::TiffSequence, "tif", 0.01, Channels::Rgba),
|
||||
] {
|
||||
let d = out_dir(&format!("seq-{ext}-{ch:?}"));
|
||||
let om = OutputModule { channels: ch, ..OutputModule::for_format(fmt) };
|
||||
let r = run(&p, cid, &NoFootage, &om, &d.join(format!("f_[#####].{ext}")));
|
||||
assert!(r.path.ends_with(&format!("f_00000.{ext}")), "{}", r.path);
|
||||
check_still_dir(&d, ext, tol, ch == Channels::Rgba);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exr_sequence_is_linear() {
|
||||
let (p, cid) = project(None);
|
||||
let d = out_dir("seq-exr");
|
||||
let om = OutputModule::for_format(OutputFormat::ExrSequence);
|
||||
run(&p, cid, &NoFootage, &om, &d.join("f_[#####].exr"));
|
||||
let img = image::open(d.join("f_00000.exr")).expect("exr").to_rgba32f();
|
||||
assert_eq!(img.dimensions(), (W, H));
|
||||
let c = img.get_pixel(2, 2).0;
|
||||
// sRGB 0.4 → linear ≈ 0.133
|
||||
assert!((c[2] - 0.1329).abs() < 0.01 && (c[3] - 1.0).abs() < 1e-6, "{c:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gif_roundtrip() {
|
||||
let (p, cid) = project(None);
|
||||
let d = out_dir("gif");
|
||||
let om = OutputModule::for_format(OutputFormat::Gif);
|
||||
let path = d.join("anim.gif");
|
||||
run(&p, cid, &NoFootage, &om, &path);
|
||||
let mut opts = gif::DecodeOptions::new();
|
||||
opts.set_color_output(gif::ColorOutput::RGBA);
|
||||
let mut dec = opts.read_info(std::fs::File::open(&path).expect("open")).expect("gif header");
|
||||
assert_eq!((dec.width() as u32, dec.height() as u32), (W, H));
|
||||
let mut k = 0;
|
||||
let mut total_delay = 0u32;
|
||||
while let Some(f) = dec.read_next_frame().expect("frame") {
|
||||
assert_eq!((f.width as u32, f.height as u32), (W, H));
|
||||
total_delay += f.delay as u32;
|
||||
let img = Image::from_rgba8(W, H, &f.buffer);
|
||||
check_frame(&img, k, 0.06);
|
||||
k += 1;
|
||||
}
|
||||
assert_eq!(k, FRAMES);
|
||||
assert_eq!(total_delay, 120, "1.2 s in centiseconds");
|
||||
if let Some(s) = ffprobe(&path) {
|
||||
assert!(s.starts_with("gif|64|48"), "{s}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_stops_early() {
|
||||
let (p, cid) = project(None);
|
||||
let d = out_dir("cancel");
|
||||
let om = OutputModule::for_format(OutputFormat::PngSequence);
|
||||
let s = settings();
|
||||
let path = d.join("c_[#####].png").to_string_lossy().to_string();
|
||||
let job = Job { project: &p, footage: &NoFootage, expr: None, comp: cid, settings: &s, output: &om, path: &path };
|
||||
let r = export(&job, &mut |_| false);
|
||||
assert!(matches!(r, Err(effectcraft_export::ExportError::Cancelled)));
|
||||
let written = std::fs::read_dir(&d).map(|r| r.count()).unwrap_or(0);
|
||||
assert_eq!(written, 0, "cancelled before the first batch");
|
||||
}
|
||||
|
||||
/// 16-bit stereo WAV with a 440 Hz sine at amplitude 0.5 (left) and silence (right).
|
||||
fn write_tone(path: &Path, secs: f64) {
|
||||
let sr = 48_000u32;
|
||||
let n = (secs * sr as f64) as usize;
|
||||
let mut data = Vec::with_capacity(n * 4);
|
||||
for i in 0..n {
|
||||
let s = (0.5 * (2.0 * std::f64::consts::PI * 440.0 * i as f64 / sr as f64).sin() * 32767.0).round() as i16;
|
||||
data.extend_from_slice(&s.to_le_bytes());
|
||||
data.extend_from_slice(&0i16.to_le_bytes());
|
||||
}
|
||||
let mut w = Vec::new();
|
||||
w.extend_from_slice(b"RIFF");
|
||||
w.extend_from_slice(&(36 + data.len() as u32).to_le_bytes());
|
||||
w.extend_from_slice(b"WAVEfmt ");
|
||||
w.extend_from_slice(&16u32.to_le_bytes());
|
||||
w.extend_from_slice(&1u16.to_le_bytes());
|
||||
w.extend_from_slice(&2u16.to_le_bytes());
|
||||
w.extend_from_slice(&sr.to_le_bytes());
|
||||
w.extend_from_slice(&(sr * 4).to_le_bytes());
|
||||
w.extend_from_slice(&4u16.to_le_bytes());
|
||||
w.extend_from_slice(&16u16.to_le_bytes());
|
||||
w.extend_from_slice(b"data");
|
||||
w.extend_from_slice(&(data.len() as u32).to_le_bytes());
|
||||
w.extend_from_slice(&data);
|
||||
std::fs::write(path, w).expect("write wav");
|
||||
}
|
||||
|
||||
fn rms(v: impl Iterator<Item = f32>) -> f32 {
|
||||
let (s, n) = v.fold((0.0f64, 0usize), |(s, n), x| (s + (x as f64) * (x as f64), n + 1));
|
||||
(s / n.max(1) as f64).sqrt() as f32
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn movies_carry_audio() {
|
||||
let d = out_dir("audio");
|
||||
let wav = d.join("tone.wav");
|
||||
write_tone(&wav, 2.0);
|
||||
let (p, cid) = project(Some(&wav));
|
||||
let pool = MediaPool::new();
|
||||
for (fmt, name) in [(OutputFormat::H264, "a.mp4"), (OutputFormat::ProRes, "a.mov")] {
|
||||
let om = OutputModule { audio: AudioOutput::Auto, ..OutputModule::for_format(fmt) };
|
||||
let path = d.join(name);
|
||||
let r = run(&p, cid, &pool, &om, &path);
|
||||
assert!(r.audio, "{name}: auto audio is on for a comp with a sound layer");
|
||||
let f = effectcraft_media::probe(&path).expect("probe");
|
||||
assert!(f.has_audio, "{name}");
|
||||
let dec = MediaPool::new();
|
||||
let s = dec.audio_samples(&f, Tick::from_seconds_f64(0.3), 24_000, 48_000);
|
||||
let (l, r) = (rms(s.iter().step_by(2).copied()), rms(s.iter().skip(1).step_by(2).copied()));
|
||||
// sine of amplitude 0.5 → RMS ≈ 0.354
|
||||
assert!((l - 0.3536).abs() < 0.03, "{name}: left RMS {l}");
|
||||
assert!(r < 0.01, "{name}: right RMS {r}");
|
||||
if Command::new("ffprobe").arg("-version").output().is_ok() {
|
||||
let out = Command::new("ffprobe")
|
||||
.args(["-v", "error", "-select_streams", "a:0", "-show_entries", "stream=codec_name,channels,sample_rate", "-of", "compact=p=0:nk=1"])
|
||||
.arg(&path)
|
||||
.output()
|
||||
.expect("ffprobe");
|
||||
let s = String::from_utf8_lossy(&out.stdout);
|
||||
let want = if fmt == OutputFormat::H264 { "aac|48000|2" } else { "pcm_s16le|48000|2" };
|
||||
assert!(s.trim() == want, "{name}: {s}");
|
||||
}
|
||||
// Audio off.
|
||||
let om = OutputModule { audio: AudioOutput::Off, ..om };
|
||||
let path = d.join(format!("silent-{name}"));
|
||||
assert!(!run(&p, cid, &pool, &om, &path).audio);
|
||||
assert!(!effectcraft_media::probe(&path).expect("probe").has_audio);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
//! A fully wired [`Session`]: footage decoding through `effectcraft-media` (FilmCraft's codecs),
|
||||
//! the media importer and the expression engine. Frontends (desktop, CLI, MCP, web) start here.
|
||||
//! the media importer, the expression engine and Render Queue export through
|
||||
//! `effectcraft-export` (FilmCraft's encoders). Frontends (desktop, CLI, MCP, web) start here.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use effectcraft_engine::{Importer, Session};
|
||||
use effectcraft_engine::project::render_queue::OutputFormat;
|
||||
use effectcraft_engine::{ExportJob, ExportResult, Exporter, Importer, Session};
|
||||
use effectcraft_project::Footage;
|
||||
|
||||
struct MediaImporter;
|
||||
|
|
@ -14,13 +16,40 @@ impl Importer for MediaImporter {
|
|||
}
|
||||
}
|
||||
|
||||
/// A new session with media, import and expressions enabled.
|
||||
/// Render Queue export via `effectcraft-export`.
|
||||
pub struct FileExporter;
|
||||
|
||||
impl Exporter for FileExporter {
|
||||
fn formats(&self) -> Vec<OutputFormat> {
|
||||
effectcraft_export::available_formats()
|
||||
}
|
||||
fn export(&self, job: &ExportJob, progress: &mut dyn FnMut(u64, u64) -> bool) -> Result<ExportResult, String> {
|
||||
let j = effectcraft_export::Job {
|
||||
project: job.project,
|
||||
footage: job.footage,
|
||||
expr: job.expr,
|
||||
comp: job.item.comp,
|
||||
settings: &job.item.settings,
|
||||
output: &job.item.output,
|
||||
path: job.path,
|
||||
};
|
||||
match effectcraft_export::export(&j, &mut |p| progress(p.done, p.total)) {
|
||||
Ok(r) => Ok(ExportResult { path: r.path, frames: r.frames, width: r.width, height: r.height, bytes: r.bytes, seconds: r.seconds, audio: r.audio }),
|
||||
Err(effectcraft_export::ExportError::Cancelled) => Err(effectcraft_engine::render_queue::CANCELLED.into()),
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A new session with media, import, expressions and export enabled.
|
||||
pub fn session() -> Session {
|
||||
let mut s = Session::default();
|
||||
s.footage = Arc::new(effectcraft_media::MediaPool::new());
|
||||
s.importer = Some(Arc::new(MediaImporter));
|
||||
s.expr = Some(Arc::new(effectcraft_expr::Expressions));
|
||||
s
|
||||
Session {
|
||||
exporter: Some(Arc::new(FileExporter)),
|
||||
footage: Arc::new(effectcraft_media::MediaPool::new()),
|
||||
importer: Some(Arc::new(MediaImporter)),
|
||||
expr: Some(Arc::new(effectcraft_expr::Expressions)),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -37,4 +66,44 @@ mod tests {
|
|||
let img = s.render(cid, s.time(), effectcraft_engine::render::RenderOpts { scale: 0.25, ..Default::default() });
|
||||
assert!(img.data.iter().any(|p| p[3] > 0.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_queue_exports_demo_comp() {
|
||||
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/test-out/host-rq");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let mut s = super::session();
|
||||
s.execute("file.openDemoProject", json!({})).unwrap();
|
||||
let out = dir.join("[compName]_[width]x[height].[fileExtension]");
|
||||
let r = s
|
||||
.execute(
|
||||
"renderQueue.add",
|
||||
json!({"format": "h264", "output": out.to_string_lossy(), "resolution": 0.125, "timeSpan": "custom", "start": 0.0, "end": 0.4}),
|
||||
)
|
||||
.unwrap();
|
||||
let path = r["outputPath"].as_str().unwrap().to_string();
|
||||
assert!(path.ends_with("_240x134.mp4"), "{path}");
|
||||
// A second item: PNG sequence, rendered in the background.
|
||||
s.execute("renderQueue.add", json!({"format": "png", "output": dir.join("seq_[###].png").to_string_lossy(), "resolution": 0.0625, "timeSpan": "custom", "start": 0.0, "end": 0.2})).unwrap();
|
||||
let r = s.execute("renderQueue.render", json!({"wait": false})).unwrap();
|
||||
assert_eq!(r["items"].as_array().unwrap().len(), 2);
|
||||
let t0 = std::time::Instant::now();
|
||||
while s.is_rendering() {
|
||||
assert!(t0.elapsed().as_secs() < 300, "render timed out");
|
||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||
s.poll_render();
|
||||
}
|
||||
s.poll_render();
|
||||
let list = s.execute("renderQueue.list", json!({})).unwrap();
|
||||
for it in list["items"].as_array().unwrap() {
|
||||
assert_eq!(it["statusLabel"], "Done", "{it}");
|
||||
assert!(it["render_time"].as_f64().is_some());
|
||||
assert!(std::path::Path::new(it["last_output"].as_str().unwrap()).exists(), "{it}");
|
||||
}
|
||||
assert!(std::path::Path::new(&path).metadata().unwrap().len() > 500);
|
||||
assert!(dir.join("seq_000.png").exists() && dir.join("seq_005.png").exists() && !dir.join("seq_006.png").exists());
|
||||
// Rendering again needs a re-queue.
|
||||
assert!(s.execute("renderQueue.render", json!({})).is_err());
|
||||
s.execute("renderQueue.setRender", json!({"index": 1, "render": true})).unwrap();
|
||||
assert_eq!(s.project.render_queue[0].status, effectcraft_engine::project::render_queue::RenderStatus::Queued);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -454,6 +454,9 @@ impl FootageSource for MediaPool {
|
|||
}
|
||||
}
|
||||
}
|
||||
fn audio(&self, _item: ItemId, footage: &Footage, start: Tick, frames: usize, rate: u32) -> Vec<f32> {
|
||||
self.audio_samples(footage, start, frames, rate)
|
||||
}
|
||||
}
|
||||
|
||||
fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
|
||||
pub mod build;
|
||||
pub mod props;
|
||||
pub mod render_queue;
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
|
@ -586,11 +587,14 @@ pub struct Project {
|
|||
pub settings: ProjectSettings,
|
||||
pub items: BTreeMap<ItemId, Item>,
|
||||
pub next_id: u64,
|
||||
/// The Render Queue (Composition ▸ Add to Render Queue).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub render_queue: Vec<render_queue::RenderQueueItem>,
|
||||
}
|
||||
|
||||
impl Default for Project {
|
||||
fn default() -> Self {
|
||||
Project { schema: SCHEMA_VERSION, settings: ProjectSettings::default(), items: BTreeMap::new(), next_id: 1 }
|
||||
Project { schema: SCHEMA_VERSION, settings: ProjectSettings::default(), items: BTreeMap::new(), next_id: 1, render_queue: Vec::new() }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,525 @@
|
|||
//! The Render Queue: compositions queued for export, each with Render Settings (what to render:
|
||||
//! quality, resolution, time span, frame rate) and an Output Module (how to write it: format,
|
||||
//! channels, codec options, output path). Saved with the project, like After Effects.
|
||||
//!
|
||||
//! Output paths are templates: `[compName].[fileExtension]` (the default), with the tokens
|
||||
//! `[compName]`, `[projectName]`, `[fileExtension]`, `[width]`, `[height]`, `[frameRate]`,
|
||||
//! `[startFrame]`, `[endFrame]`, `[outputModuleName]` and, for image sequences, a run of `#`
|
||||
//! (`[#####]`) that becomes the zero-padded frame number. A relative result is resolved against the
|
||||
//! project's folder (or the working directory for unsaved projects).
|
||||
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{Comp, ItemId};
|
||||
|
||||
/// The default output path template.
|
||||
pub const DEFAULT_TEMPLATE: &str = "[compName].[fileExtension]";
|
||||
/// The default template for image sequences.
|
||||
pub const DEFAULT_SEQUENCE_TEMPLATE: &str = "[compName]_[#####].[fileExtension]";
|
||||
|
||||
/// Render Settings ▸ Quality.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum RenderQuality {
|
||||
#[default]
|
||||
Best,
|
||||
Draft,
|
||||
}
|
||||
|
||||
/// Render Settings ▸ Time Span.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind")]
|
||||
pub enum TimeSpan {
|
||||
/// The composition's work area.
|
||||
#[default]
|
||||
WorkArea,
|
||||
/// The whole composition.
|
||||
LengthOfComp,
|
||||
/// An explicit span `[start, end)` in comp time.
|
||||
Custom { start: Tick, end: Tick },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct RenderSettings {
|
||||
pub quality: RenderQuality,
|
||||
/// Output scale (1 = Full, 0.5 = Half, 1/3 = Third, 0.25 = Quarter).
|
||||
pub resolution: f64,
|
||||
pub time_span: TimeSpan,
|
||||
/// `None` = use the comp's frame rate.
|
||||
pub frame_rate: Option<FrameRate>,
|
||||
/// Motion blur for layers with the switch on (and the comp's Enable Motion Blur).
|
||||
pub motion_blur: bool,
|
||||
/// Image sequences: skip frames whose file already exists.
|
||||
pub skip_existing: bool,
|
||||
}
|
||||
|
||||
impl Default for RenderSettings {
|
||||
fn default() -> Self {
|
||||
RenderSettings {
|
||||
quality: RenderQuality::Best,
|
||||
resolution: 1.0,
|
||||
time_span: TimeSpan::WorkArea,
|
||||
frame_rate: None,
|
||||
motion_blur: true,
|
||||
skip_existing: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderSettings {
|
||||
/// The comp-time span `[start, end)` to render.
|
||||
pub fn span(&self, comp: &Comp) -> (Tick, Tick) {
|
||||
let (a, b) = match self.time_span {
|
||||
TimeSpan::WorkArea => comp.work_area,
|
||||
TimeSpan::LengthOfComp => (Tick::ZERO, comp.duration),
|
||||
TimeSpan::Custom { start, end } => (start, end),
|
||||
};
|
||||
let a = a.clamp(Tick::ZERO, comp.duration);
|
||||
let b = b.clamp(a, comp.duration);
|
||||
(a, b)
|
||||
}
|
||||
pub fn rate(&self, comp: &Comp) -> FrameRate {
|
||||
self.frame_rate.unwrap_or(comp.frame_rate)
|
||||
}
|
||||
/// First output-rate frame starting at or after `t`.
|
||||
fn ceil_frame(r: FrameRate, t: Tick) -> i64 {
|
||||
let f = r.frame_at(t);
|
||||
if r.tick_of(f) < t { f + 1 } else { f }
|
||||
}
|
||||
/// Output-rate frame number of output frame 0 (the first frame starting inside the span).
|
||||
pub fn first_frame(&self, comp: &Comp) -> i64 {
|
||||
Self::ceil_frame(self.rate(comp), self.span(comp).0)
|
||||
}
|
||||
/// Number of output frames: those starting in `[start, end)` (at least 1).
|
||||
pub fn frame_count(&self, comp: &Comp) -> u64 {
|
||||
let (_, b) = self.span(comp);
|
||||
let n = Self::ceil_frame(self.rate(comp), b) - self.first_frame(comp);
|
||||
n.max(1) as u64
|
||||
}
|
||||
/// Comp time of output frame `i`.
|
||||
pub fn frame_time(&self, comp: &Comp, i: u64) -> Tick {
|
||||
self.rate(comp).tick_of(self.first_frame(comp) + i as i64)
|
||||
}
|
||||
pub fn output_size(&self, comp: &Comp) -> (u32, u32) {
|
||||
let s = self.resolution.clamp(0.01, 4.0);
|
||||
(((comp.width as f64 * s).round() as u32).max(1), ((comp.height as f64 * s).round() as u32).max(1))
|
||||
}
|
||||
pub fn resolution_label(&self) -> &'static str {
|
||||
match self.resolution {
|
||||
r if (r - 1.0).abs() < 1e-6 => "Full",
|
||||
r if (r - 0.5).abs() < 1e-6 => "Half",
|
||||
r if (r - 1.0 / 3.0).abs() < 1e-3 => "Third",
|
||||
r if (r - 0.25).abs() < 1e-6 => "Quarter",
|
||||
_ => "Custom",
|
||||
}
|
||||
}
|
||||
/// One-line summary shown next to "Render Settings:" (AE shows the template name).
|
||||
pub fn summary(&self) -> String {
|
||||
let q = if self.quality == RenderQuality::Best { "Best Settings" } else { "Draft Settings" };
|
||||
format!("{q} · {}", self.resolution_label())
|
||||
}
|
||||
}
|
||||
|
||||
/// Output Module ▸ Format.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum OutputFormat {
|
||||
/// H.264 video (+ AAC audio) in MP4.
|
||||
#[default]
|
||||
H264,
|
||||
/// Apple ProRes in QuickTime (+ PCM audio). 4444 when channels include alpha.
|
||||
ProRes,
|
||||
PngSequence,
|
||||
JpegSequence,
|
||||
TiffSequence,
|
||||
/// OpenEXR sequence (32-bit float, linear light).
|
||||
ExrSequence,
|
||||
/// Animated GIF.
|
||||
Gif,
|
||||
}
|
||||
|
||||
impl OutputFormat {
|
||||
pub const ALL: [OutputFormat; 7] = [
|
||||
OutputFormat::H264,
|
||||
OutputFormat::ProRes,
|
||||
OutputFormat::PngSequence,
|
||||
OutputFormat::JpegSequence,
|
||||
OutputFormat::TiffSequence,
|
||||
OutputFormat::ExrSequence,
|
||||
OutputFormat::Gif,
|
||||
];
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
OutputFormat::H264 => "H.264",
|
||||
OutputFormat::ProRes => "QuickTime (ProRes)",
|
||||
OutputFormat::PngSequence => "PNG Sequence",
|
||||
OutputFormat::JpegSequence => "JPEG Sequence",
|
||||
OutputFormat::TiffSequence => "TIFF Sequence",
|
||||
OutputFormat::ExrSequence => "OpenEXR Sequence",
|
||||
OutputFormat::Gif => "Animated GIF",
|
||||
}
|
||||
}
|
||||
pub fn extension(self) -> &'static str {
|
||||
match self {
|
||||
OutputFormat::H264 => "mp4",
|
||||
OutputFormat::ProRes => "mov",
|
||||
OutputFormat::PngSequence => "png",
|
||||
OutputFormat::JpegSequence => "jpg",
|
||||
OutputFormat::TiffSequence => "tif",
|
||||
OutputFormat::ExrSequence => "exr",
|
||||
OutputFormat::Gif => "gif",
|
||||
}
|
||||
}
|
||||
pub fn is_sequence(self) -> bool {
|
||||
matches!(self, OutputFormat::PngSequence | OutputFormat::JpegSequence | OutputFormat::TiffSequence | OutputFormat::ExrSequence)
|
||||
}
|
||||
pub fn is_movie(self) -> bool {
|
||||
matches!(self, OutputFormat::H264 | OutputFormat::ProRes)
|
||||
}
|
||||
pub fn supports_alpha(self) -> bool {
|
||||
!matches!(self, OutputFormat::H264 | OutputFormat::JpegSequence)
|
||||
}
|
||||
pub fn supports_audio(self) -> bool {
|
||||
self.is_movie()
|
||||
}
|
||||
/// The written frame size for a rendered size: H.264 needs even dimensions (rounded down,
|
||||
/// at least 2).
|
||||
pub fn coded_size(self, w: u32, h: u32) -> (u32, u32) {
|
||||
let even = |v: u32| if v < 2 { 2 } else { v & !1 };
|
||||
if self == OutputFormat::H264 { (even(w), even(h)) } else { (w, h) }
|
||||
}
|
||||
/// Parse a format name: `h264`, `mp4`, `prores`, `mov`, `png`, `jpeg`/`jpg`, `tiff`/`tif`,
|
||||
/// `exr`, `gif`, or a label.
|
||||
pub fn from_name(s: &str) -> Option<OutputFormat> {
|
||||
let n = s.trim().to_ascii_lowercase().replace([' ', '.', '-', '_'], "");
|
||||
Some(match n.as_str() {
|
||||
"h264" | "mp4" | "avc" => OutputFormat::H264,
|
||||
"prores" | "mov" | "quicktime" | "quicktimeprores" => OutputFormat::ProRes,
|
||||
"png" | "pngsequence" => OutputFormat::PngSequence,
|
||||
"jpg" | "jpeg" | "jpegsequence" => OutputFormat::JpegSequence,
|
||||
"tif" | "tiff" | "tiffsequence" => OutputFormat::TiffSequence,
|
||||
"exr" | "openexr" | "openexrsequence" => OutputFormat::ExrSequence,
|
||||
"gif" | "animatedgif" => OutputFormat::Gif,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
/// Guess from a file name's extension.
|
||||
pub fn from_path(path: &str) -> Option<OutputFormat> {
|
||||
let ext = std::path::Path::new(path).extension()?.to_string_lossy().to_ascii_lowercase();
|
||||
OutputFormat::from_name(&ext)
|
||||
}
|
||||
}
|
||||
|
||||
/// Output Module ▸ Channels.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Channels {
|
||||
/// Opaque, composited over the comp background colour.
|
||||
#[default]
|
||||
Rgb,
|
||||
/// With straight (unmatted) alpha.
|
||||
Rgba,
|
||||
}
|
||||
|
||||
/// Output Module ▸ Audio Output.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum AudioOutput {
|
||||
/// On when the comp has audible layers.
|
||||
#[default]
|
||||
Auto,
|
||||
On,
|
||||
Off,
|
||||
}
|
||||
|
||||
/// ProRes flavour.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ProResProfile {
|
||||
Proxy,
|
||||
Lt,
|
||||
Standard,
|
||||
#[default]
|
||||
Hq,
|
||||
P4444,
|
||||
P4444Xq,
|
||||
}
|
||||
|
||||
impl ProResProfile {
|
||||
pub const ALL: [ProResProfile; 6] =
|
||||
[ProResProfile::Proxy, ProResProfile::Lt, ProResProfile::Standard, ProResProfile::Hq, ProResProfile::P4444, ProResProfile::P4444Xq];
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
ProResProfile::Proxy => "Apple ProRes 422 Proxy",
|
||||
ProResProfile::Lt => "Apple ProRes 422 LT",
|
||||
ProResProfile::Standard => "Apple ProRes 422",
|
||||
ProResProfile::Hq => "Apple ProRes 422 HQ",
|
||||
ProResProfile::P4444 => "Apple ProRes 4444",
|
||||
ProResProfile::P4444Xq => "Apple ProRes 4444 XQ",
|
||||
}
|
||||
}
|
||||
pub fn is_4444(self) -> bool {
|
||||
matches!(self, ProResProfile::P4444 | ProResProfile::P4444Xq)
|
||||
}
|
||||
pub fn from_name(s: &str) -> Option<ProResProfile> {
|
||||
let n = s.to_ascii_lowercase().replace([' ', '-', '_'], "");
|
||||
ProResProfile::ALL.into_iter().find(|p| {
|
||||
let l = p.label().to_ascii_lowercase().replace(' ', "");
|
||||
l == n || l.trim_start_matches("appleprores") == n.trim_start_matches("appleprores") || format!("{p:?}").to_ascii_lowercase() == n
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct OutputModule {
|
||||
pub format: OutputFormat,
|
||||
pub channels: Channels,
|
||||
/// Output path or template (see the module docs).
|
||||
pub output: String,
|
||||
/// JPEG quality 1–100.
|
||||
pub quality: u8,
|
||||
/// H.264 target bitrate.
|
||||
pub bitrate_kbps: u32,
|
||||
pub prores_profile: ProResProfile,
|
||||
pub audio: AudioOutput,
|
||||
pub audio_sample_rate: u32,
|
||||
/// GIF: loop forever.
|
||||
pub gif_loop: bool,
|
||||
}
|
||||
|
||||
impl Default for OutputModule {
|
||||
fn default() -> Self {
|
||||
OutputModule {
|
||||
format: OutputFormat::H264,
|
||||
channels: Channels::Rgb,
|
||||
output: DEFAULT_TEMPLATE.into(),
|
||||
quality: 90,
|
||||
bitrate_kbps: 10_000,
|
||||
prores_profile: ProResProfile::Hq,
|
||||
audio: AudioOutput::Auto,
|
||||
audio_sample_rate: 48_000,
|
||||
gif_loop: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl OutputModule {
|
||||
pub fn for_format(format: OutputFormat) -> OutputModule {
|
||||
let mut m = OutputModule { format, ..Default::default() };
|
||||
if format.is_sequence() {
|
||||
m.output = DEFAULT_SEQUENCE_TEMPLATE.into();
|
||||
}
|
||||
m
|
||||
}
|
||||
/// Change the format, keeping the output name but fixing its extension / frame-number token.
|
||||
pub fn set_format(&mut self, format: OutputFormat) {
|
||||
let old = self.format;
|
||||
self.format = format;
|
||||
if !format.supports_alpha() {
|
||||
self.channels = Channels::Rgb;
|
||||
}
|
||||
if self.output.contains("[fileExtension]") {
|
||||
if format.is_sequence() && !self.output.contains('#') {
|
||||
self.output = self.output.replace(".[fileExtension]", "_[#####].[fileExtension]");
|
||||
} else if !format.is_sequence() && old.is_sequence() {
|
||||
self.output = self.output.replace("_[#####]", "").replace("[#####]", "");
|
||||
}
|
||||
return;
|
||||
}
|
||||
let p = std::path::Path::new(&self.output);
|
||||
let mut stem = p.with_extension("").to_string_lossy().to_string();
|
||||
if format.is_sequence() && !stem.contains('#') {
|
||||
stem.push_str("_[#####]");
|
||||
} else if !format.is_sequence() {
|
||||
stem = stem.replace("_[#####]", "").replace("[#####]", "");
|
||||
}
|
||||
self.output = format!("{stem}.{}", format.extension());
|
||||
}
|
||||
/// One-line summary shown next to "Output Module:".
|
||||
pub fn summary(&self) -> String {
|
||||
let ch = match self.channels {
|
||||
Channels::Rgb => "RGB",
|
||||
Channels::Rgba => "RGB + Alpha",
|
||||
};
|
||||
match self.format {
|
||||
OutputFormat::H264 => format!("H.264 · {} kbps", self.bitrate_kbps),
|
||||
OutputFormat::ProRes => format!(
|
||||
"{} · {ch}",
|
||||
if self.channels == Channels::Rgba && !self.prores_profile.is_4444() { "Apple ProRes 4444" } else { self.prores_profile.label() }
|
||||
),
|
||||
f => format!("{} · {ch}", f.label()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Render Queue item status (the Status column).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub enum RenderStatus {
|
||||
/// Render checkbox off.
|
||||
Unqueued,
|
||||
#[default]
|
||||
Queued,
|
||||
NeedsOutput,
|
||||
Rendering,
|
||||
Done,
|
||||
UserStopped,
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
impl RenderStatus {
|
||||
pub fn label(&self) -> &str {
|
||||
match self {
|
||||
RenderStatus::Unqueued => "Unqueued",
|
||||
RenderStatus::Queued => "Queued",
|
||||
RenderStatus::NeedsOutput => "Needs Output",
|
||||
RenderStatus::Rendering => "Rendering",
|
||||
RenderStatus::Done => "Done",
|
||||
RenderStatus::UserStopped => "User Stopped",
|
||||
RenderStatus::Failed(_) => "Failed",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RenderQueueItem {
|
||||
/// Stable id (unique within the queue).
|
||||
pub id: u64,
|
||||
pub comp: ItemId,
|
||||
/// The Render checkbox.
|
||||
pub render: bool,
|
||||
#[serde(default)]
|
||||
pub status: RenderStatus,
|
||||
#[serde(default)]
|
||||
pub settings: RenderSettings,
|
||||
#[serde(default)]
|
||||
pub output: OutputModule,
|
||||
/// Unix seconds when the last render of this item started.
|
||||
#[serde(default)]
|
||||
pub started: Option<u64>,
|
||||
/// Seconds the last render took.
|
||||
#[serde(default)]
|
||||
pub render_time: Option<f64>,
|
||||
/// Resolved path of the last render (first file for sequences).
|
||||
#[serde(default)]
|
||||
pub last_output: Option<String>,
|
||||
}
|
||||
|
||||
impl RenderQueueItem {
|
||||
pub fn new(id: u64, comp: ItemId) -> RenderQueueItem {
|
||||
RenderQueueItem {
|
||||
id,
|
||||
comp,
|
||||
render: true,
|
||||
status: RenderStatus::Queued,
|
||||
settings: RenderSettings::default(),
|
||||
output: OutputModule::default(),
|
||||
started: None,
|
||||
render_time: None,
|
||||
last_output: None,
|
||||
}
|
||||
}
|
||||
/// Will be rendered by the next Render.
|
||||
pub fn is_queued(&self) -> bool {
|
||||
self.render && matches!(self.status, RenderStatus::Queued)
|
||||
}
|
||||
}
|
||||
|
||||
/// Values for template tokens.
|
||||
pub struct TemplateVars<'a> {
|
||||
pub comp_name: &'a str,
|
||||
pub project_name: &'a str,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub frame_rate: f64,
|
||||
pub start_frame: i64,
|
||||
pub end_frame: i64,
|
||||
}
|
||||
|
||||
/// Expand template tokens (all but the `#` frame-number run).
|
||||
pub fn expand_template(template: &str, format: OutputFormat, v: &TemplateVars) -> String {
|
||||
let fps = if (v.frame_rate - v.frame_rate.round()).abs() < 1e-6 { format!("{}", v.frame_rate.round() as i64) } else { format!("{:.2}", v.frame_rate) };
|
||||
template
|
||||
.replace("[compName]", &sanitize(v.comp_name))
|
||||
.replace("[projectName]", &sanitize(v.project_name))
|
||||
.replace("[fileExtension]", format.extension())
|
||||
.replace("[width]", &v.width.to_string())
|
||||
.replace("[height]", &v.height.to_string())
|
||||
.replace("[frameRate]", &fps)
|
||||
.replace("[startFrame]", &v.start_frame.to_string())
|
||||
.replace("[endFrame]", &v.end_frame.to_string())
|
||||
.replace("[outputModuleName]", format.label())
|
||||
}
|
||||
|
||||
/// Replace the first run of `#` (optionally in brackets, `[####]`) with the zero-padded frame
|
||||
/// number. Paths without a run get `_NNNNN` before the extension.
|
||||
pub fn sequence_path(path: &str, frame: i64) -> String {
|
||||
if let Some(start) = path.find('#') {
|
||||
let len = path[start..].chars().take_while(|c| *c == '#').count();
|
||||
let (mut a, mut b) = (start, start + len);
|
||||
if a > 0 && path.as_bytes()[a - 1] == b'[' && path.as_bytes().get(b) == Some(&b']') {
|
||||
a -= 1;
|
||||
b += 1;
|
||||
}
|
||||
return format!("{}{:0width$}{}", &path[..a], frame, &path[b..], width = len);
|
||||
}
|
||||
let p = std::path::Path::new(path);
|
||||
match p.extension() {
|
||||
Some(ext) => format!("{}_{frame:05}.{}", p.with_extension("").to_string_lossy(), ext.to_string_lossy()),
|
||||
None => format!("{path}_{frame:05}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Make a name safe as a file name component.
|
||||
pub fn sanitize(name: &str) -> String {
|
||||
name.chars().map(|c| if matches!(c, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|') { '_' } else { c }).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn templates_and_sequences() {
|
||||
let v = TemplateVars { comp_name: "Main/Title", project_name: "P", width: 1920, height: 1080, frame_rate: 29.97, start_frame: 0, end_frame: 9 };
|
||||
assert_eq!(expand_template(DEFAULT_TEMPLATE, OutputFormat::H264, &v), "Main_Title.mp4");
|
||||
assert_eq!(expand_template("[compName]_[width]x[height]@[frameRate].[fileExtension]", OutputFormat::Gif, &v), "Main_Title_1920x1080@29.97.gif");
|
||||
assert_eq!(sequence_path("out/a_[#####].png", 42), "out/a_00042.png");
|
||||
assert_eq!(sequence_path("a###.tif", 7), "a007.tif");
|
||||
assert_eq!(sequence_path("a.png", 3), "a_00003.png");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_format_fixes_names() {
|
||||
let mut m = OutputModule::default();
|
||||
m.set_format(OutputFormat::PngSequence);
|
||||
assert_eq!(m.output, "[compName]_[#####].[fileExtension]");
|
||||
m.set_format(OutputFormat::ProRes);
|
||||
assert_eq!(m.output, DEFAULT_TEMPLATE);
|
||||
m.output = "/tmp/x.mov".into();
|
||||
m.set_format(OutputFormat::JpegSequence);
|
||||
assert_eq!(m.output, "/tmp/x_[#####].jpg");
|
||||
assert_eq!(OutputFormat::from_name("TIFF"), Some(OutputFormat::TiffSequence));
|
||||
assert_eq!(ProResProfile::from_name("4444"), Some(ProResProfile::P4444));
|
||||
assert_eq!(ProResProfile::from_name("hq"), Some(ProResProfile::Hq));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spans() {
|
||||
let mut c = Comp::new(100, 50, FrameRate::new(25, 1), Tick::from_seconds_f64(4.0));
|
||||
c.work_area = (Tick::from_seconds_f64(1.0), Tick::from_seconds_f64(2.0));
|
||||
let mut s = RenderSettings::default();
|
||||
assert_eq!(s.frame_count(&c), 25);
|
||||
assert_eq!(s.frame_time(&c, 0), Tick::from_seconds_f64(1.0));
|
||||
s.time_span = TimeSpan::LengthOfComp;
|
||||
assert_eq!(s.frame_count(&c), 100);
|
||||
s.frame_rate = Some(FrameRate::new(10, 1));
|
||||
assert_eq!(s.frame_count(&c), 40);
|
||||
s.resolution = 0.5;
|
||||
assert_eq!(s.output_size(&c), (50, 25));
|
||||
// NTSC: 1 s – 2 s holds the 30 frames that start inside it.
|
||||
let c = Comp::new(100, 50, FrameRate::new(30000, 1001), Tick::from_seconds_f64(4.0));
|
||||
let s = RenderSettings { time_span: TimeSpan::Custom { start: Tick::from_seconds_f64(1.0), end: Tick::from_seconds_f64(2.0) }, ..Default::default() };
|
||||
assert_eq!(s.frame_count(&c), 30);
|
||||
assert_eq!(s.first_frame(&c), 30);
|
||||
assert!(s.frame_time(&c, 0) >= Tick::from_seconds_f64(1.0));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,203 @@
|
|||
//! Audio mixdown of a composition: every audible footage layer (and, recursively, precomp layer)
|
||||
//! summed with its Audio Levels (dB, per channel), respecting in/out points, the Audio switch and
|
||||
//! solo. Levels are evaluated once per call (callers mix in short blocks, e.g. one video frame).
|
||||
//! Time-stretched layers are resampled nearest-sample; reversed (negative stretch) layers are
|
||||
//! silent for now.
|
||||
|
||||
use effectcraft_project::{Comp, ItemId, ItemKind, Layer, LayerSource, Project};
|
||||
use effectcraft_time::{TICKS_PER_SECOND, Tick};
|
||||
|
||||
use crate::{EvalCtx, ExprHost, FootageSource};
|
||||
|
||||
const MAX_DEPTH: usize = 16;
|
||||
|
||||
/// `frames` stereo sample frames of `comp`'s mix from comp time `start` at `rate` Hz,
|
||||
/// interleaved (L R L R …).
|
||||
pub fn mix_comp(project: &Project, footage: &dyn FootageSource, expr: Option<&dyn ExprHost>, comp: ItemId, start: Tick, frames: usize, rate: u32) -> Vec<f32> {
|
||||
let mut out = vec![0.0f32; frames * 2];
|
||||
mix_into(project, footage, expr, comp, start, rate, &mut out, 1.0, 1.0, 0);
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether the comp has anything audible (footage with audio, or a precomp that has).
|
||||
pub fn comp_has_audio(project: &Project, comp: ItemId) -> bool {
|
||||
fn walk(project: &Project, comp: ItemId, depth: usize) -> bool {
|
||||
let Some(c) = project.comp(comp) else { return false };
|
||||
depth < MAX_DEPTH
|
||||
&& c.layers.iter().any(|l| {
|
||||
l.switches.audio
|
||||
&& match &l.source {
|
||||
LayerSource::Footage { item } => {
|
||||
matches!(project.item(*item).map(|i| &i.kind), Some(ItemKind::Footage(f)) if f.has_audio && !f.missing)
|
||||
}
|
||||
LayerSource::Comp { item } => walk(project, *item, depth + 1),
|
||||
_ => false,
|
||||
}
|
||||
})
|
||||
}
|
||||
walk(project, comp, 0)
|
||||
}
|
||||
|
||||
fn audible(project: &Project, l: &Layer) -> bool {
|
||||
l.switches.audio
|
||||
&& match &l.source {
|
||||
LayerSource::Footage { item } => matches!(project.item(*item).map(|i| &i.kind), Some(ItemKind::Footage(f)) if f.has_audio),
|
||||
LayerSource::Comp { .. } => true,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn db_to_gain(db: f64) -> f32 {
|
||||
if db <= -96.0 { 0.0 } else { 10f64.powf(db / 20.0) as f32 }
|
||||
}
|
||||
|
||||
fn units_ceil(t: Tick, rate: u32) -> i64 {
|
||||
let n = t.0 as i128 * rate as i128;
|
||||
let d = TICKS_PER_SECOND as i128;
|
||||
(n.div_euclid(d) + i128::from(n.rem_euclid(d) != 0)) as i64
|
||||
}
|
||||
|
||||
fn sample_tick(i: i64, rate: u32) -> Tick {
|
||||
Tick(((i as i128 * TICKS_PER_SECOND as i128) / rate as i128) as i64)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn mix_into(
|
||||
project: &Project,
|
||||
footage: &dyn FootageSource,
|
||||
expr: Option<&dyn ExprHost>,
|
||||
comp_id: ItemId,
|
||||
start: Tick,
|
||||
rate: u32,
|
||||
out: &mut [f32],
|
||||
gl: f32,
|
||||
gr: f32,
|
||||
depth: usize,
|
||||
) {
|
||||
let Some(comp) = project.comp(comp_id) else { return };
|
||||
if depth >= MAX_DEPTH || rate == 0 {
|
||||
return;
|
||||
}
|
||||
let frames = out.len() / 2;
|
||||
let any_solo = comp.layers.iter().any(|l| l.switches.solo && audible(project, l));
|
||||
let s0 = start.to_units_floor(rate as i64);
|
||||
for l in &comp.layers {
|
||||
if !audible(project, l) || (any_solo && !l.switches.solo) || l.stretch <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
// Sample range of the block that falls inside the layer's [in, out).
|
||||
let a = (units_ceil(l.in_point, rate) - s0).clamp(0, frames as i64) as usize;
|
||||
let b = (units_ceil(l.out_point, rate) - s0).clamp(0, frames as i64) as usize;
|
||||
if a >= b {
|
||||
continue;
|
||||
}
|
||||
let t_a = sample_tick(s0 + a as i64, rate);
|
||||
let (ll, lr) = levels(project, comp_id, comp, l, t_a, expr);
|
||||
let (ll, lr) = (ll * gl, lr * gr);
|
||||
if ll == 0.0 && lr == 0.0 {
|
||||
continue;
|
||||
}
|
||||
let n = b - a;
|
||||
let speed = 100.0 / l.stretch;
|
||||
let src_n = ((n as f64 * speed).ceil() as usize).max(1);
|
||||
let lt = l.layer_time(t_a);
|
||||
let buf = match &l.source {
|
||||
LayerSource::Footage { item } => match project.item(*item).map(|i| &i.kind) {
|
||||
Some(ItemKind::Footage(f)) => footage.audio(*item, f, lt, src_n, rate),
|
||||
_ => continue,
|
||||
},
|
||||
LayerSource::Comp { item } => {
|
||||
if project.comp_contains(*item, comp_id) {
|
||||
continue;
|
||||
}
|
||||
let mut sub = vec![0.0f32; src_n * 2];
|
||||
mix_into(project, footage, expr, *item, lt, rate, &mut sub, 1.0, 1.0, depth + 1);
|
||||
sub
|
||||
}
|
||||
_ => continue,
|
||||
};
|
||||
let same = (speed - 1.0).abs() < 1e-9;
|
||||
for i in 0..n {
|
||||
let j = if same { i } else { ((i as f64 * speed) as usize).min(src_n - 1) };
|
||||
if j * 2 + 1 >= buf.len() {
|
||||
break;
|
||||
}
|
||||
out[(a + i) * 2] += buf[j * 2] * ll;
|
||||
out[(a + i) * 2 + 1] += buf[j * 2 + 1] * lr;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn levels(project: &Project, comp_id: ItemId, comp: &Comp, l: &Layer, t: Tick, expr: Option<&dyn ExprHost>) -> (f32, f32) {
|
||||
let Some(g) = l.props.sub("audio") else { return (1.0, 1.0) };
|
||||
let mut ctx = EvalCtx::new(project, comp_id, comp, t);
|
||||
ctx.expr = expr;
|
||||
let [a, b] = ctx.v2(l, g, "levels", [0.0, 0.0]);
|
||||
(db_to_gain(a), db_to_gain(b))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use effectcraft_project::{Footage, FootageKind, build};
|
||||
use effectcraft_time::FrameRate;
|
||||
|
||||
use super::*;
|
||||
use crate::Image;
|
||||
|
||||
/// A 1 kHz-ish constant "tone": every sample is 0.25 (left) / -0.25 (right).
|
||||
struct Dc;
|
||||
impl FootageSource for Dc {
|
||||
fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option<Arc<Image>> {
|
||||
None
|
||||
}
|
||||
fn audio(&self, _: ItemId, _: &Footage, start: Tick, frames: usize, _: u32) -> Vec<f32> {
|
||||
(0..frames).flat_map(|_| if start >= Tick::ZERO { [0.25, -0.25] } else { [0.0, 0.0] }).collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixes_layers_with_levels_and_in_out() {
|
||||
let mut p = Project::default();
|
||||
let f = Footage {
|
||||
path: "x.wav".into(),
|
||||
kind: FootageKind::Audio,
|
||||
width: 0,
|
||||
height: 0,
|
||||
pixel_aspect: 1.0,
|
||||
frame_rate: FrameRate::new(25, 1),
|
||||
native_rate: None,
|
||||
duration: Tick::from_seconds_f64(10.0),
|
||||
has_video: false,
|
||||
has_audio: true,
|
||||
alpha: Default::default(),
|
||||
premul_color: [0.0; 3],
|
||||
loop_count: 1,
|
||||
codec: String::new(),
|
||||
missing: false,
|
||||
sequence: vec![],
|
||||
};
|
||||
let fid = p.add_item("x.wav", effectcraft_color::Label::SeaFoam, None, ItemKind::Footage(f));
|
||||
let comp = Comp::new(64, 64, FrameRate::new(25, 1), Tick::from_seconds_f64(2.0));
|
||||
let mut l1 = build::layer(&mut p, &comp, "a", LayerSource::Footage { item: fid }, (0, 0), None);
|
||||
l1.in_point = Tick::from_seconds_f64(1.0);
|
||||
let mut l2 = build::layer(&mut p, &comp, "b", LayerSource::Footage { item: fid }, (0, 0), None);
|
||||
if let Some(g) = l2.props.sub_mut("audio")
|
||||
&& let Some(pr) = g.get_mut("levels")
|
||||
{
|
||||
pr.value = effectcraft_project::Value::Vec2([-6.0206, -96.0]);
|
||||
}
|
||||
let mut comp = comp;
|
||||
comp.layers = vec![l1, l2];
|
||||
let cid = p.add_item("C", effectcraft_color::Label::Sandstone, None, ItemKind::Comp(comp.into()));
|
||||
assert!(comp_has_audio(&p, cid));
|
||||
let rate = 1000;
|
||||
let m = mix_comp(&p, &Dc, None, cid, Tick::from_seconds_f64(0.5), 1000, rate);
|
||||
// first half: only layer b (half gain left, muted right)
|
||||
assert!((m[0] - 0.125).abs() < 1e-3 && m[1].abs() < 1e-6, "{} {}", m[0], m[1]);
|
||||
// second half (≥ 1 s): a + b
|
||||
let i = 700 * 2;
|
||||
assert!((m[i] - 0.375).abs() < 1e-3 && (m[i + 1] + 0.25).abs() < 1e-3, "{} {}", m[i], m[i + 1]);
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@
|
|||
//! with motion blur sub-samples) → **track matte** → **blend** with the layer's mode and opacity.
|
||||
//! Adjustment layers run their effects on everything below, limited by their own bounds/masks.
|
||||
|
||||
pub mod audio;
|
||||
pub mod eval;
|
||||
pub mod masks;
|
||||
pub mod shapes;
|
||||
|
|
@ -27,6 +28,11 @@ use rayon::prelude::*;
|
|||
pub trait FootageSource: Send + Sync {
|
||||
/// The frame of `item` at source time `t`, straight from the file (any size).
|
||||
fn frame(&self, item: ItemId, footage: &Footage, t: Tick) -> Option<Arc<Image>>;
|
||||
/// `frames` stereo sample frames of `item`'s audio from source time `start` at `rate` Hz,
|
||||
/// interleaved L R L R… (silence where there is none). Default: no audio.
|
||||
fn audio(&self, _item: ItemId, _footage: &Footage, _start: Tick, frames: usize, _rate: u32) -> Vec<f32> {
|
||||
vec![0.0; frames * 2]
|
||||
}
|
||||
}
|
||||
|
||||
/// No footage available (renders footage layers as transparent).
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,504 @@
|
|||
//! Render Queue panel, laid out like After Effects': a Current Render strip (progress bar,
|
||||
//! elapsed / estimated remaining, Stop and Render buttons), the queue list (twirl, Render
|
||||
//! checkbox, label, #, Comp Name, Status, Started, Render Time) with expandable Render Settings,
|
||||
//! Output Module and Output To rows, and a status footer.
|
||||
//!
|
||||
//! Every control dispatches an engine `renderQueue.*` command (so agents can do the same) and
|
||||
//! registers an automation id: `renderQueue.render`, `renderQueue.stop`,
|
||||
//! `renderQueue.item.<n>.{row,twirl,render,renderSettings,outputModule,outputTo,outputPath}`.
|
||||
|
||||
use effectcraft_engine::project::render_queue::{AudioOutput, Channels, OutputFormat, ProResProfile, RenderQuality, RenderQueueItem, RenderStatus, TimeSpan};
|
||||
use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::theme::Tokens;
|
||||
use crate::{EffectcraftApp, widgets};
|
||||
|
||||
const TOP_H: f32 = 74.0;
|
||||
const HEAD_H: f32 = 22.0;
|
||||
const ROW_H: f32 = 24.0;
|
||||
const FOOT_H: f32 = 24.0;
|
||||
|
||||
/// `h:mm:ss` for a duration in seconds.
|
||||
pub fn hms(secs: f64) -> String {
|
||||
let s = secs.max(0.0).round() as u64;
|
||||
format!("{}:{:02}:{:02}", s / 3600, s / 60 % 60, s % 60)
|
||||
}
|
||||
|
||||
/// Unix seconds → `YYYY-MM-DD hh:mm:ss` (UTC).
|
||||
pub fn utc_stamp(unix: u64) -> String {
|
||||
let days = (unix / 86_400) as i64;
|
||||
let rem = unix % 86_400;
|
||||
// Civil-from-days (proleptic Gregorian).
|
||||
let z = days + 719_468;
|
||||
let era = z.div_euclid(146_097);
|
||||
let doe = z - era * 146_097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = yoe + era * 400 + i64::from(m <= 2);
|
||||
format!("{y:04}-{m:02}-{d:02} {:02}:{:02}:{:02}", rem / 3600, rem / 60 % 60, rem % 60)
|
||||
}
|
||||
|
||||
fn status_color(t: &Tokens, s: &RenderStatus) -> Color32 {
|
||||
match s {
|
||||
RenderStatus::Done => Color32::from_rgb(0x6c, 0xc0, 0x6c),
|
||||
RenderStatus::Rendering => t.accent_hover,
|
||||
RenderStatus::Failed(_) | RenderStatus::NeedsOutput => t.danger,
|
||||
RenderStatus::UserStopped => t.warning,
|
||||
RenderStatus::Unqueued => t.text_faint,
|
||||
RenderStatus::Queued => t.text,
|
||||
}
|
||||
}
|
||||
|
||||
/// Render Settings menu: (label, params).
|
||||
fn settings_menu(it: &RenderQueueItem) -> Vec<(String, Value)> {
|
||||
let s = &it.settings;
|
||||
let mark = |on: bool, l: &str| if on { format!("✓ {l}") } else { format!(" {l}") };
|
||||
let mut v = vec![
|
||||
(mark(s.quality == RenderQuality::Best, "Best Settings"), json!({"quality": "best"})),
|
||||
(mark(s.quality == RenderQuality::Draft, "Draft Settings"), json!({"quality": "draft"})),
|
||||
("-".into(), Value::Null),
|
||||
];
|
||||
for (l, k) in [("Full", "full"), ("Half", "half"), ("Third", "third"), ("Quarter", "quarter")] {
|
||||
v.push((mark(s.resolution_label() == l, &format!("Resolution: {l}")), json!({"resolution": k})));
|
||||
}
|
||||
v.push(("-".into(), Value::Null));
|
||||
v.push((mark(s.time_span == TimeSpan::WorkArea, "Time Span: Work Area Only"), json!({"timeSpan": "workArea"})));
|
||||
v.push((mark(s.time_span == TimeSpan::LengthOfComp, "Time Span: Length of Comp"), json!({"timeSpan": "comp"})));
|
||||
v.push(("-".into(), Value::Null));
|
||||
v.push((mark(s.frame_rate.is_none(), "Frame Rate: Use comp's frame rate"), json!({"frameRate": null})));
|
||||
for fps in [12.0, 15.0, 23.976, 24.0, 25.0, 29.97, 30.0, 50.0, 60.0] {
|
||||
let on = s.frame_rate.is_some_and(|r| (r.as_f64() - fps).abs() < 0.01);
|
||||
v.push((mark(on, &format!("Frame Rate: {fps}")), json!({"frameRate": fps})));
|
||||
}
|
||||
v.push(("-".into(), Value::Null));
|
||||
v.push((mark(s.motion_blur, "Motion Blur: On for Checked Layers"), json!({"motionBlur": !s.motion_blur})));
|
||||
v.push((mark(s.skip_existing, "Skip Existing Files"), json!({"skipExisting": !s.skip_existing})));
|
||||
v
|
||||
}
|
||||
|
||||
/// Output Module menu: (label, params).
|
||||
fn output_menu(it: &RenderQueueItem, available: &[OutputFormat]) -> Vec<(String, Value)> {
|
||||
let o = &it.output;
|
||||
let mark = |on: bool, l: &str| if on { format!("✓ {l}") } else { format!(" {l}") };
|
||||
let mut v = Vec::new();
|
||||
for f in OutputFormat::ALL {
|
||||
if available.contains(&f) {
|
||||
v.push((mark(o.format == f, &format!("Format: {}", f.label())), json!({"format": format!("{f:?}")})));
|
||||
}
|
||||
}
|
||||
v.push(("-".into(), Value::Null));
|
||||
v.push((mark(o.channels == Channels::Rgb, "Channels: RGB"), json!({"channels": "rgb"})));
|
||||
if o.format.supports_alpha() {
|
||||
v.push((mark(o.channels == Channels::Rgba, "Channels: RGB + Alpha"), json!({"channels": "rgba"})));
|
||||
}
|
||||
match o.format {
|
||||
OutputFormat::ProRes => {
|
||||
v.push(("-".into(), Value::Null));
|
||||
for p in ProResProfile::ALL {
|
||||
v.push((mark(o.prores_profile == p, p.label()), json!({"proresProfile": format!("{p:?}")})));
|
||||
}
|
||||
}
|
||||
OutputFormat::H264 => {
|
||||
v.push(("-".into(), Value::Null));
|
||||
for kbps in [2_000, 5_000, 10_000, 20_000, 40_000] {
|
||||
v.push((mark(o.bitrate_kbps == kbps, &format!("Bitrate: {} Mbps", kbps / 1000)), json!({"bitrate": kbps})));
|
||||
}
|
||||
}
|
||||
OutputFormat::JpegSequence => {
|
||||
v.push(("-".into(), Value::Null));
|
||||
for q in [60u8, 80, 90, 100] {
|
||||
v.push((mark(o.quality == q, &format!("Quality: {q}")), json!({"quality": q})));
|
||||
}
|
||||
}
|
||||
OutputFormat::Gif => {
|
||||
v.push(("-".into(), Value::Null));
|
||||
v.push((mark(o.gif_loop, "Loop Forever"), json!({"loop": !o.gif_loop})));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if o.format.supports_audio() {
|
||||
v.push(("-".into(), Value::Null));
|
||||
for (a, l) in [(AudioOutput::Auto, "auto"), (AudioOutput::On, "on"), (AudioOutput::Off, "off")] {
|
||||
let label = match a {
|
||||
AudioOutput::Auto => "Audio Output: Auto",
|
||||
AudioOutput::On => "Audio Output: On (48 kHz stereo)",
|
||||
AudioOutput::Off => "Audio Output: Off",
|
||||
};
|
||||
v.push((mark(o.audio == a, label), json!({"audio": l})));
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
const OUTPUT_TEMPLATES: [&str; 4] = [
|
||||
"[compName].[fileExtension]",
|
||||
"[compName]_[width]x[height].[fileExtension]",
|
||||
"[projectName]_[compName].[fileExtension]",
|
||||
"[compName]/[compName].[fileExtension]",
|
||||
];
|
||||
|
||||
fn output_to_menu(it: &RenderQueueItem) -> Vec<(String, Value)> {
|
||||
let mut v: Vec<(String, Value)> = OUTPUT_TEMPLATES
|
||||
.iter()
|
||||
.map(|t| {
|
||||
let t = if it.output.format.is_sequence() { t.replace(".[fileExtension]", "_[#####].[fileExtension]") } else { t.to_string() };
|
||||
(t.clone(), json!({"path": t}))
|
||||
})
|
||||
.collect();
|
||||
v.push(("-".into(), Value::Null));
|
||||
v.push(("Choose File…".into(), json!({"choose": true})));
|
||||
v
|
||||
}
|
||||
|
||||
pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let t = app.tokens;
|
||||
let p = ui.painter().with_clip_rect(rect);
|
||||
let queue: Vec<RenderQueueItem> = app.session.project.render_queue.clone();
|
||||
let progress = app.session.render_progress();
|
||||
let rendering = app.session.is_rendering();
|
||||
let available = app.session.exporter.as_ref().map(|e| e.formats()).unwrap_or_default();
|
||||
let mut actions: Vec<(&'static str, Value)> = Vec::new();
|
||||
let state_id = egui::Id::new("rq-ui");
|
||||
let (mut open, mut selected): (Vec<u64>, Option<u64>) = ui.data(|d| d.get_temp(state_id).unwrap_or_default());
|
||||
|
||||
// ---------------------------------------------------------- Current Render strip
|
||||
let top = Rect::from_min_size(rect.min, vec2(rect.width(), TOP_H));
|
||||
p.rect_filled(top, 0.0, t.tl_ruler_bg);
|
||||
let x0 = rect.min.x + 12.0;
|
||||
p.text(pos2(x0, top.min.y + 16.0), Align2::LEFT_CENTER, "Current Render", Tokens::semibold(12.5), t.text);
|
||||
let cur_item = progress.as_ref().and_then(|p| p.current).and_then(|id| queue.iter().find(|i| i.id == id));
|
||||
let cur_name = cur_item.and_then(|i| app.session.project.item(i.comp)).map(|i| i.name.clone());
|
||||
if let Some(pr) = &progress {
|
||||
let rem = pr.remaining.map(hms).unwrap_or_else(|| "–".into());
|
||||
p.text(
|
||||
pos2(top.max.x - 12.0, top.min.y + 16.0),
|
||||
Align2::RIGHT_CENTER,
|
||||
format!("Elapsed: {} Est. Remain: {rem}", hms(pr.item_elapsed)),
|
||||
Tokens::ui(11.5),
|
||||
t.text_dim,
|
||||
);
|
||||
}
|
||||
let bw = 76.0;
|
||||
let render_b = Rect::from_min_size(pos2(top.max.x - 12.0 - bw, top.min.y + 34.0), vec2(bw, 26.0));
|
||||
let stop_b = Rect::from_min_size(pos2(render_b.min.x - 8.0 - bw, render_b.min.y), vec2(bw, 26.0));
|
||||
let can_render = !rendering && queue.iter().any(RenderQueueItem::is_queued) && !available.is_empty();
|
||||
if widgets::text_button(ui, render_b, "Render", can_render, &t, egui::Id::new("rq-render")).clicked() && can_render {
|
||||
actions.push(("renderQueue.render", json!({"wait": false})));
|
||||
}
|
||||
app.auto.add("renderQueue.render", render_b, "Render");
|
||||
if rendering {
|
||||
if widgets::text_button(ui, stop_b, "Stop", false, &t, egui::Id::new("rq-stop")).clicked() {
|
||||
actions.push(("renderQueue.stop", json!({})));
|
||||
}
|
||||
app.auto.add("renderQueue.stop", stop_b, "Stop");
|
||||
}
|
||||
// Progress bar.
|
||||
let bar =
|
||||
Rect::from_min_max(pos2(x0, render_b.min.y + 4.0), pos2(if rendering { stop_b.min.x - 16.0 } else { render_b.min.x - 16.0 }, render_b.max.y - 4.0));
|
||||
p.rect_filled(bar, 3.0, t.field_bg);
|
||||
p.rect_stroke(bar, 3.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside);
|
||||
let frac = progress.as_ref().filter(|p| p.total > 0).map(|p| (p.done as f32 / p.total as f32).clamp(0.0, 1.0)).unwrap_or(0.0);
|
||||
if frac > 0.0 {
|
||||
let fill = Rect::from_min_size(bar.min, vec2(bar.width() * frac, bar.height()));
|
||||
p.rect_filled(fill, 3.0, t.accent);
|
||||
}
|
||||
let bar_text = match (&progress, &cur_name) {
|
||||
(Some(pr), Some(n)) if rendering => format!("Rendering “{n}” {} / {} frames", pr.done, pr.total),
|
||||
_ => {
|
||||
let n = queue.iter().filter(|i| i.is_queued()).count();
|
||||
if available.is_empty() {
|
||||
"Export is not available in this build".to_string()
|
||||
} else if n == 0 {
|
||||
"Composition ▸ Add to Render Queue (Ctrl/⌘ M) to queue a render".to_string()
|
||||
} else {
|
||||
format!("{n} item(s) queued")
|
||||
}
|
||||
}
|
||||
};
|
||||
p.text(pos2(bar.min.x + 8.0, bar.center().y), Align2::LEFT_CENTER, bar_text, Tokens::ui(11.5), t.text);
|
||||
app.auto.add("renderQueue.progress", bar, &format!("{:.0}%", frac * 100.0));
|
||||
|
||||
// ---------------------------------------------------------- column header
|
||||
let head = Rect::from_min_size(pos2(rect.min.x, top.max.y), vec2(rect.width(), HEAD_H));
|
||||
p.rect_filled(head, 0.0, t.panel_bg);
|
||||
p.line_segment([head.left_bottom(), head.right_bottom()], Stroke::new(1.0, t.separator));
|
||||
// Columns: twirl | Render | label | # | Comp Name | Status | Started | Render Time
|
||||
let cx_twirl = rect.min.x + 4.0;
|
||||
let cx_render = cx_twirl + 20.0;
|
||||
let cx_label = cx_render + 48.0;
|
||||
let cx_num = cx_label + 20.0;
|
||||
let cx_name = cx_num + 28.0;
|
||||
let w_time = 92.0;
|
||||
let w_started = 156.0;
|
||||
let w_status = 112.0;
|
||||
let cx_time = (rect.max.x - w_time).max(cx_name + 120.0);
|
||||
let cx_started = cx_time - w_started;
|
||||
let cx_status = cx_started - w_status;
|
||||
let hy = head.center().y;
|
||||
for (x, l) in [(cx_render, "Render"), (cx_num, "#"), (cx_name, "Comp Name"), (cx_status, "Status"), (cx_started, "Started"), (cx_time, "Render Time")] {
|
||||
p.text(pos2(x + 2.0, hy), Align2::LEFT_CENTER, l, Tokens::ui(11.0), t.text_dim);
|
||||
}
|
||||
for x in [cx_render, cx_label, cx_num, cx_name, cx_status, cx_started, cx_time] {
|
||||
p.line_segment([pos2(x - 3.0, head.min.y + 4.0), pos2(x - 3.0, head.max.y - 4.0)], Stroke::new(1.0, t.separator));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------- items
|
||||
let list = Rect::from_min_max(pos2(rect.min.x, head.max.y), pos2(rect.max.x, rect.max.y - FOOT_H));
|
||||
let lp = p.with_clip_rect(list);
|
||||
let mut y = list.min.y;
|
||||
if queue.is_empty() {
|
||||
lp.text(pos2(list.center().x, list.min.y + 30.0), Align2::CENTER_CENTER, "The render queue is empty.", Tokens::ui(12.0), t.text_faint);
|
||||
}
|
||||
let editing_id = egui::Id::new("rq-edit-output");
|
||||
let mut editing: Option<(u64, String)> = ui.data(|d| d.get_temp(editing_id));
|
||||
for (k, it) in queue.iter().enumerate() {
|
||||
let n = k + 1;
|
||||
let aid = |s: &str| format!("renderQueue.item.{n}.{s}");
|
||||
let row = Rect::from_min_size(pos2(list.min.x, y), vec2(list.width(), ROW_H));
|
||||
y += ROW_H;
|
||||
let is_sel = selected == Some(it.id);
|
||||
lp.rect_filled(
|
||||
row,
|
||||
0.0,
|
||||
if is_sel {
|
||||
t.row_selected
|
||||
} else if k % 2 == 0 {
|
||||
t.row
|
||||
} else {
|
||||
t.row_alt
|
||||
},
|
||||
);
|
||||
let row_resp = ui.interact(row, egui::Id::new(("rq-row", it.id)), Sense::click());
|
||||
if row_resp.clicked() {
|
||||
selected = Some(it.id);
|
||||
app.ui.focused = crate::dock::PanelKind::RenderQueue;
|
||||
}
|
||||
let item_id = it.id;
|
||||
row_resp.context_menu(|ui| {
|
||||
if ui.button("Duplicate").clicked() {
|
||||
actions.push(("renderQueue.duplicate", json!({"item": item_id})));
|
||||
ui.close();
|
||||
}
|
||||
if ui.button("Remove").clicked() {
|
||||
actions.push(("renderQueue.remove", json!({"item": item_id})));
|
||||
ui.close();
|
||||
}
|
||||
if k > 0 && ui.button("Move Up").clicked() {
|
||||
actions.push(("renderQueue.move", json!({"item": item_id, "to": n - 1})));
|
||||
ui.close();
|
||||
}
|
||||
if n < queue.len() && ui.button("Move Down").clicked() {
|
||||
actions.push(("renderQueue.move", json!({"item": item_id, "to": n + 1})));
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
app.auto.add(&aid("row"), row, &format!("#{n}"));
|
||||
let is_open = open.contains(&it.id);
|
||||
let tw = Rect::from_center_size(pos2(cx_twirl + 8.0, row.center().y), vec2(14.0, 14.0));
|
||||
if widgets::twirl(ui, tw, is_open, egui::Id::new(("rq-twirl", it.id)), &t).clicked() {
|
||||
if is_open {
|
||||
open.retain(|i| *i != it.id);
|
||||
} else {
|
||||
open.push(it.id);
|
||||
}
|
||||
}
|
||||
app.auto.add(&aid("twirl"), tw, "Twirl");
|
||||
let cb = Rect::from_center_size(pos2(cx_render + 20.0, row.center().y), vec2(18.0, 18.0));
|
||||
if widgets::checkbox(ui, cb, it.render, &t, egui::Id::new(("rq-cb", it.id))).clicked() && !rendering {
|
||||
actions.push(("renderQueue.setRender", json!({"item": it.id, "render": !it.render})));
|
||||
}
|
||||
app.auto.add(&aid("render"), cb, if it.render { "Render: on" } else { "Render: off" });
|
||||
let comp_item = app.session.project.item(it.comp);
|
||||
let label_col = comp_item.map(|i| Tokens::label(i.label)).unwrap_or(t.text_faint);
|
||||
lp.rect_filled(Rect::from_center_size(pos2(cx_label + 6.0, row.center().y), vec2(10.0, 10.0)), 2.0, label_col);
|
||||
lp.text(pos2(cx_num + 2.0, row.center().y), Align2::LEFT_CENTER, n.to_string(), Tokens::ui(12.0), t.text_dim);
|
||||
let name = comp_item.map(|i| i.name.clone()).unwrap_or_else(|| "(missing composition)".into());
|
||||
lp.text(pos2(cx_name + 2.0, row.center().y), Align2::LEFT_CENTER, &name, Tokens::ui(12.0), t.text);
|
||||
let status = if rendering && progress.as_ref().and_then(|p| p.current) == Some(it.id) {
|
||||
let pr = progress.as_ref().expect("progress");
|
||||
format!("Rendering {:.0}%", if pr.total > 0 { pr.done as f64 * 100.0 / pr.total as f64 } else { 0.0 })
|
||||
} else {
|
||||
it.status.label().to_string()
|
||||
};
|
||||
let st_rect = Rect::from_min_size(pos2(cx_status, row.min.y), vec2(w_status, ROW_H));
|
||||
lp.text(pos2(cx_status + 2.0, row.center().y), Align2::LEFT_CENTER, &status, Tokens::ui(12.0), status_color(&t, &it.status));
|
||||
if let RenderStatus::Failed(e) = &it.status {
|
||||
ui.interact(st_rect, egui::Id::new(("rq-st", it.id)), Sense::hover()).on_hover_text(e);
|
||||
}
|
||||
app.auto.add(&aid("status"), st_rect, &status);
|
||||
if let Some(s) = it.started {
|
||||
lp.text(pos2(cx_started + 2.0, row.center().y), Align2::LEFT_CENTER, utc_stamp(s), Tokens::ui(11.5), t.text_dim);
|
||||
} else {
|
||||
lp.text(pos2(cx_started + 2.0, row.center().y), Align2::LEFT_CENTER, "-", Tokens::ui(11.5), t.text_faint);
|
||||
}
|
||||
let rt = if progress.as_ref().and_then(|p| p.current) == Some(it.id) && rendering {
|
||||
progress.as_ref().map(|p| hms(p.item_elapsed)).unwrap_or_default()
|
||||
} else {
|
||||
it.render_time.map(|s| if s < 60.0 { format!("{s:.1} s") } else { hms(s) }).unwrap_or_else(|| "-".into())
|
||||
};
|
||||
lp.text(pos2(cx_time + 2.0, row.center().y), Align2::LEFT_CENTER, rt, Tokens::ui(11.5), t.text_dim);
|
||||
|
||||
if !is_open {
|
||||
continue;
|
||||
}
|
||||
// Expanded: Render Settings / Output Module + Output To.
|
||||
let ix = cx_name;
|
||||
for sub in 0..2 {
|
||||
let r = Rect::from_min_size(pos2(list.min.x, y), vec2(list.width(), ROW_H));
|
||||
y += ROW_H;
|
||||
lp.rect_filled(r, 0.0, t.panel_bg);
|
||||
let (label, summary, key, menu): (&str, String, &str, Vec<(String, Value)>) = if sub == 0 {
|
||||
("Render Settings:", it.settings.summary(), "renderSettings", settings_menu(it))
|
||||
} else {
|
||||
("Output Module:", it.output.summary(), "outputModule", output_menu(it, &available))
|
||||
};
|
||||
lp.text(pos2(cx_num - 6.0, r.center().y), Align2::LEFT_CENTER, label, Tokens::ui(11.5), t.text_dim);
|
||||
let dd = Rect::from_min_size(pos2(ix + 70.0, r.min.y + 3.0), vec2(220.0, ROW_H - 6.0));
|
||||
let did = egui::Id::new(("rq-dd", it.id, sub));
|
||||
if widgets::dropdown(ui, dd, &summary, &t, did).clicked() && !rendering {
|
||||
widgets::open_popup(ui, did);
|
||||
}
|
||||
let labels: Vec<String> = menu.iter().map(|(l, _)| l.clone()).collect();
|
||||
if let Some(i) = widgets::popup_menu(ui, did, dd.left_bottom(), &labels, None) {
|
||||
let mut params = menu[i].1.clone();
|
||||
params["item"] = json!(it.id);
|
||||
actions.push((if sub == 0 { "renderQueue.setRenderSettings" } else { "renderQueue.setOutputModule" }, params));
|
||||
}
|
||||
app.auto.add(&aid(key), dd, &summary);
|
||||
if sub == 0 {
|
||||
let comp = app.session.project.comp(it.comp);
|
||||
if let Some(c) = comp {
|
||||
let (w, h) = it.settings.output_size(c);
|
||||
let (a, b) = it.settings.span(c);
|
||||
let info = format!(
|
||||
"{w}×{h} · {:.3} fps · {:.2}s – {:.2}s · {} frames",
|
||||
it.settings.rate(c).as_f64(),
|
||||
a.seconds(),
|
||||
b.seconds(),
|
||||
it.settings.frame_count(c)
|
||||
);
|
||||
lp.text(pos2(dd.max.x + 14.0, r.center().y), Align2::LEFT_CENTER, info, Tokens::ui(11.0), t.text_faint);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Output To.
|
||||
let ox = dd.max.x + 14.0;
|
||||
lp.text(pos2(ox, r.center().y), Align2::LEFT_CENTER, "Output To:", Tokens::ui(11.5), t.text_dim);
|
||||
let odd = Rect::from_min_size(pos2(ox + 62.0, r.min.y + 3.0), vec2(22.0, ROW_H - 6.0));
|
||||
let oid = egui::Id::new(("rq-out", it.id));
|
||||
if widgets::dropdown(ui, odd, "", &t, oid).clicked() && !rendering {
|
||||
widgets::open_popup(ui, oid);
|
||||
}
|
||||
app.auto.add(&aid("outputTo"), odd, "Output To");
|
||||
let omenu = output_to_menu(it);
|
||||
let olabels: Vec<String> = omenu.iter().map(|(l, _)| l.clone()).collect();
|
||||
if let Some(i) = widgets::popup_menu(ui, oid, odd.left_bottom(), &olabels, None) {
|
||||
if omenu[i].1.get("choose").is_some() {
|
||||
let default = app.session.resolve_output(it).unwrap_or_default();
|
||||
let picked = app
|
||||
.hooks
|
||||
.pick_save
|
||||
.as_ref()
|
||||
.and_then(|f| f(std::path::Path::new(&default).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default().as_str()));
|
||||
match picked {
|
||||
Some(path) => actions.push(("renderQueue.setOutput", json!({"item": it.id, "path": path}))),
|
||||
None if app.hooks.pick_save.is_none() => editing = Some((it.id, it.output.output.clone())),
|
||||
None => {}
|
||||
}
|
||||
} else {
|
||||
let mut params = omenu[i].1.clone();
|
||||
params["item"] = json!(it.id);
|
||||
actions.push(("renderQueue.setOutput", params));
|
||||
}
|
||||
}
|
||||
let path_rect = Rect::from_min_max(pos2(odd.max.x + 6.0, r.min.y + 2.0), pos2(r.max.x - 8.0, r.max.y - 2.0));
|
||||
if editing.as_ref().is_some_and(|(id, _)| *id == it.id) {
|
||||
let mut buf = editing.as_ref().map(|(_, b)| b.clone()).unwrap_or_default();
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(path_rect));
|
||||
let resp = child.add(egui::TextEdit::singleline(&mut buf).desired_width(path_rect.width()).font(Tokens::ui(11.5)));
|
||||
if resp.lost_focus() {
|
||||
if ui.input(|i| i.key_pressed(egui::Key::Enter)) && !buf.trim().is_empty() {
|
||||
actions.push(("renderQueue.setOutput", json!({"item": it.id, "path": buf.trim()})));
|
||||
}
|
||||
editing = None;
|
||||
} else {
|
||||
resp.request_focus();
|
||||
editing = Some((it.id, buf));
|
||||
}
|
||||
} else {
|
||||
let shown = app.session.resolve_output(it).unwrap_or_else(|| "Not yet specified".into());
|
||||
let resp = ui.interact(path_rect, egui::Id::new(("rq-path", it.id)), Sense::click());
|
||||
let col = if resp.hovered() { t.accent_hover } else { t.hot_text };
|
||||
lp.with_clip_rect(path_rect.intersect(list)).text(pos2(path_rect.min.x, r.center().y), Align2::LEFT_CENTER, &shown, Tokens::ui(11.5), col);
|
||||
if resp.clicked() && !rendering {
|
||||
editing = Some((it.id, it.output.output.clone()));
|
||||
}
|
||||
resp.on_hover_text(format!("Template: {}\nClick to edit", it.output.output));
|
||||
app.auto.add(&aid("outputPath"), path_rect, &shown);
|
||||
}
|
||||
}
|
||||
}
|
||||
ui.data_mut(|d| match &editing {
|
||||
Some(e) => {
|
||||
d.insert_temp(editing_id, e.clone());
|
||||
}
|
||||
None => d.remove::<(u64, String)>(editing_id),
|
||||
});
|
||||
|
||||
// Delete removes the selected item when the panel has focus.
|
||||
if app.ui.focused == crate::dock::PanelKind::RenderQueue
|
||||
&& editing.is_none()
|
||||
&& !rendering
|
||||
&& let Some(id) = selected
|
||||
&& ui.input(|i| i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace))
|
||||
&& !ui.ctx().egui_wants_keyboard_input()
|
||||
{
|
||||
actions.push(("renderQueue.remove", json!({"item": id})));
|
||||
selected = None;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------- footer
|
||||
let foot = Rect::from_min_max(pos2(rect.min.x, rect.max.y - FOOT_H), rect.max);
|
||||
p.rect_filled(foot, 0.0, t.tl_ruler_bg);
|
||||
p.line_segment([foot.left_top(), foot.right_top()], Stroke::new(1.0, t.separator));
|
||||
let msg = match &progress {
|
||||
Some(pr) if rendering => format!("Message: Rendering {} of {}", (pr.items_done + 1).min(pr.items_total), pr.items_total),
|
||||
_ => {
|
||||
let done = queue.iter().filter(|i| i.status == RenderStatus::Done).count();
|
||||
format!("Message: {done} of {} done", queue.len())
|
||||
}
|
||||
};
|
||||
p.text(pos2(foot.min.x + 12.0, foot.center().y), Align2::LEFT_CENTER, msg, Tokens::ui(11.0), t.text_dim);
|
||||
let started = queue.iter().filter(|i| i.started.is_some()).count();
|
||||
let total: f64 = queue.iter().filter_map(|i| i.render_time).sum();
|
||||
p.text(
|
||||
pos2(foot.max.x - 12.0, foot.center().y),
|
||||
Align2::RIGHT_CENTER,
|
||||
format!("Renders Started: {started} Total Time Elapsed: {}", hms(progress.as_ref().filter(|_| rendering).map(|p| p.elapsed).unwrap_or(total))),
|
||||
Tokens::ui(11.0),
|
||||
t.text_dim,
|
||||
);
|
||||
|
||||
open.retain(|id| queue.iter().any(|i| i.id == *id));
|
||||
ui.data_mut(|d| d.insert_temp(state_id, (open, selected)));
|
||||
let ctx = ui.ctx().clone();
|
||||
for (id, params) in actions {
|
||||
if let Err(e) = crate::menus::invoke(app, &ctx, id, params) {
|
||||
app.ui.status = e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn stamps() {
|
||||
assert_eq!(super::utc_stamp(0), "1970-01-01 00:00:00");
|
||||
assert_eq!(super::utc_stamp(1_700_000_000), "2023-11-14 22:13:20");
|
||||
assert_eq!(super::hms(3725.4), "1:02:05");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
//! Headless look at the Render Queue panel (wgpu offscreen). Ignored by default (needs a GPU
|
||||
//! adapter); run with
|
||||
//! `RQ_SNAPSHOT=/abs/out.png cargo test -p effectcraft-ui-egui --test render_queue_snapshot -- --ignored`.
|
||||
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_engine::project::render_queue::RenderStatus;
|
||||
use effectcraft_ui_egui::EffectcraftApp;
|
||||
use egui_kittest::Harness;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn render_queue_panel_snapshot() {
|
||||
let mut s = Session::default();
|
||||
s.execute("file.openDemoProject", json!({})).unwrap();
|
||||
s.execute("renderQueue.add", json!({"output": "/renders/[compName].[fileExtension]", "resolution": "half", "timeSpan": "comp"})).unwrap();
|
||||
s.execute("renderQueue.add", json!({"format": "prores", "channels": "rgba", "output": "/renders/[compName]_alpha.mov"})).unwrap();
|
||||
s.execute("renderQueue.add", json!({"format": "png", "output": "/renders/frames/[compName]_[#####].png", "timeSpan": "workArea"})).unwrap();
|
||||
s.execute("renderQueue.add", json!({"format": "gif", "resolution": "quarter"})).unwrap();
|
||||
{
|
||||
let p = std::sync::Arc::make_mut(&mut s.project);
|
||||
p.render_queue[0].status = RenderStatus::Done;
|
||||
p.render_queue[0].started = Some(1_790_000_000);
|
||||
p.render_queue[0].render_time = Some(12.4);
|
||||
p.render_queue[1].status = RenderStatus::Failed("disk full".into());
|
||||
p.render_queue[1].started = Some(1_790_000_013);
|
||||
p.render_queue[1].render_time = Some(0.8);
|
||||
p.render_queue[3].render = false;
|
||||
p.render_queue[3].status = RenderStatus::Unqueued;
|
||||
}
|
||||
let ids: Vec<u64> = s.project.render_queue.iter().take(3).map(|i| i.id).collect();
|
||||
let mut app = Some(EffectcraftApp::new(s));
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| app.take().expect("app"));
|
||||
h.ctx.data_mut(|d| d.insert_temp(egui::Id::new("rq-ui"), (ids, None::<u64>)));
|
||||
h.state_mut().show_panel(effectcraft_ui_egui::dock::PanelKind::RenderQueue);
|
||||
h.run_steps(4);
|
||||
let img = h.render().expect("render");
|
||||
let out = std::env::var("RQ_SNAPSHOT").unwrap_or_else(|_| concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/test-out/render_queue.png").into());
|
||||
if let Some(d) = std::path::Path::new(&out).parent() {
|
||||
std::fs::create_dir_all(d).unwrap();
|
||||
}
|
||||
img.save(&out).unwrap();
|
||||
}
|
||||
Loading…
Reference in New Issue