mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-29 04:26:55 -06:00
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:
co-authored by
Claude Opus 5.5
parent
dee16181fe
commit
7f9c3a85c0
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user