Merge branch 'worktree-agent-a773c6d272696f48a' (M12.2 GPU compositor)

Auto backend keeps comps with 3D layers on the CPU compositor (3D runs are
still CPU-composited; the GPU round-trip made them slower).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-02 11:16:42 -04:00
co-authored by Claude Opus 5.5
39 changed files with 3903 additions and 97 deletions
+1 -1
View File
@@ -89,7 +89,7 @@ fn save_frame(s: &mut Session, p: &Value) -> Result<Value> {
let t = f_p(p, "time").map(Tick::from_seconds_f64).unwrap_or(s.time());
let scale = f_p(p, "scale").unwrap_or(1.0).clamp(0.01, 4.0);
let bg = comp.background;
let img = s.render(cid, t, effectcraft_render::RenderOpts { scale, ..Default::default() });
let img = s.render(cid, t, effectcraft_render::RenderOpts { scale, backend: effectcraft_render::Backend::Auto, ..Default::default() });
let rgba = img.to_rgba8_over(bg);
let png = encode_png(&rgba, img.width, img.height).map_err(|e| EngineError::Other(format!("PNG encoding failed: {e}")))?;
s.services.write_file(&path, &png).map_err(|e| EngineError::Other(format!("cannot write {path}: {e}")))?;
+48 -1
View File
@@ -152,6 +152,17 @@ fn project_settings(s: &mut Session, p: &Value) -> Result<Value> {
),
};
}
if let Some(r) = p.get("renderer").or_else(|| p.get("gpuAcceleration")) {
proj.settings.gpu_acceleration = match r {
Value::Bool(b) => *b,
Value::String(s) => match effectcraft_render::Backend::parse(s) {
Some(effectcraft_render::Backend::Cpu) => false,
Some(_) => true,
None => return Err(bad("file.projectSettings", format!("renderer: gpu|software, not `{s}`"))),
},
_ => return Err(bad("file.projectSettings", "renderer: gpu|software")),
};
}
if let Some(t) = str_p(p, "timeDisplay") {
proj.settings.time_display =
if t.eq_ignore_ascii_case("frames") { effectcraft_project::TimeDisplayStyle::Frames } else { effectcraft_project::TimeDisplayStyle::Timecode };
@@ -161,6 +172,41 @@ fn project_settings(s: &mut Session, p: &Value) -> Result<Value> {
Ok(serde_json::to_value(&s.project.settings).unwrap_or_default())
}
/// Video Rendering and Effects: report or set the renderer (Mercury GPU Acceleration when a GPU
/// adapter exists, else Mercury Software Only).
fn render_backend(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(b) = p.get("backend").or_else(|| p.get("renderer")) {
let gpu = match b {
Value::Bool(b) => *b,
Value::String(v) => match effectcraft_render::Backend::parse(v) {
Some(effectcraft_render::Backend::Cpu) => false,
Some(_) => true,
None => return Err(bad("render.backend", format!("backend: gpu|cpu, not `{v}`"))),
},
_ => return Err(bad("render.backend", "backend: gpu|cpu")),
};
if gpu != s.project.settings.gpu_acceleration {
s.edit("Project Settings", None, |proj, _| {
proj.settings.gpu_acceleration = gpu;
Ok(())
})?;
}
}
Ok(backend_status(s))
}
/// The renderer setting and what renders with it.
pub fn backend_status(s: &Session) -> Value {
let adapter = s.accel.as_ref().map(|a| a.name());
let gpu = s.project.settings.gpu_acceleration;
json!({
"renderer": if gpu { "Mercury GPU Acceleration" } else { "Mercury Software Only" },
"gpuAcceleration": gpu,
"adapter": adapter,
"active": if gpu && adapter.is_some() { "gpu" } else { "cpu" },
})
}
fn cycle_depth(s: &mut Session, _: &Value) -> Result<Value> {
s.edit("Project Bit Depth", None, |proj, _| {
proj.settings.bit_depth = proj.settings.bit_depth.next();
@@ -184,10 +230,11 @@ pub fn specs() -> Vec<CommandSpec> {
"Project Settings...",
["File"],
Some("Cmd+Alt+Shift+K"),
"{bitDepth?: 8|16|32, workingSpace?: none|srgb|rec709|rec2020|p3, linearize?, blendLinear?, timeDisplay?: timecode|frames}",
"{bitDepth?: 8|16|32, workingSpace?: none|srgb|rec709|rec2020|p3, linearize?, blendLinear?, renderer?: gpu|software, timeDisplay?: timecode|frames}",
always,
project_settings
),
cmd!("file.cycleBitDepth", "Cycle Project Bit Depth", [], None, "{}", always, cycle_depth),
cmd!("render.backend", "Video Rendering and Effects", [], None, "{backend?: gpu|cpu}", always, render_backend),
]
}
+1
View File
@@ -47,6 +47,7 @@ fn summary(s: &mut Session, _: &Value) -> Result<Value> {
"path": s.path,
"dirty": s.is_dirty(),
"bitDepth": s.project.settings.bit_depth.label(),
"renderer": super::file::backend_status(s),
"items": items,
"activeComp": s.state.active_comp.map(|c| c.0),
"time": s.time().seconds(),
+2 -1
View File
@@ -495,7 +495,8 @@ fn save_current_preview(s: &mut Session, p: &Value) -> Result<Value> {
let mut item = RenderQueueItem::new(0, cid);
item.output = OutputModule::for_format(format);
item.output.output = path.clone();
let job = crate::ExportJob { project: &s.project, footage: s.footage.as_ref(), expr: s.expr.as_deref(), item: &item, path: &path };
let job =
crate::ExportJob { project: &s.project, footage: s.footage.as_ref(), expr: s.expr.as_deref(), accel: s.accel.as_deref(), item: &item, path: &path };
let r = exporter.export(&job, &mut |_, _| true).map_err(EngineError::Other)?;
s.toast(format!("Saved preview to {}", r.path));
Ok(serde_json::to_value(&r).unwrap_or_default())
+7 -1
View File
@@ -236,6 +236,10 @@ pub struct Session {
pub services: Arc<dyn Services>,
pub footage: Arc<dyn FootageSource>,
pub expr: Option<Arc<dyn ExprHost>>,
/// GPU compositor (Mercury GPU Acceleration), when the frontend has one: used by renders
/// that ask for [`effectcraft_render::Backend::Gpu`]/`Auto`, and by Render Queue exports
/// when the project's renderer is the GPU.
pub accel: Option<Arc<dyn effectcraft_render::Accelerator>>,
/// Expression syntax checker (set by the host that links the expression engine).
pub expr_check: Option<fn(&str) -> std::result::Result<(), String>>,
pub importer: Option<Arc<dyn Importer>>,
@@ -281,6 +285,7 @@ impl Default for Session {
services: Arc::new(FsServices),
footage: Arc::new(NoFootage),
expr: None,
accel: None,
expr_check: None,
importer: None,
exporter: None,
@@ -484,6 +489,7 @@ impl Session {
let mut r = Renderer::new(&self.project, self.footage.as_ref(), opts);
r.expr = self.expr.as_deref();
r.cache = Some(&self.layer_cache);
r.accel = self.accel.as_deref();
r.comp_frame(comp, t)
}
@@ -502,7 +508,7 @@ impl Session {
let c = self.project.comp(comp).ok_or(EngineError::NoComp)?;
let long = c.width.max(c.height).max(1) as f64;
let scale = if max_side == 0 { 1.0 } else { (max_side as f64 / long).min(1.0) };
let img = self.render(comp, t, RenderOpts { scale, ..Default::default() });
let img = self.render(comp, t, RenderOpts { scale, backend: effectcraft_render::Backend::Auto, ..Default::default() });
Ok((img.width, img.height, img.to_rgba8_over(c.background)))
}
+5 -2
View File
@@ -23,6 +23,8 @@ pub struct ExportJob<'a> {
pub footage: &'a dyn FootageSource,
pub expr: Option<&'a dyn ExprHost>,
pub item: &'a RenderQueueItem,
/// GPU compositor to render with when the project's renderer is Mercury GPU Acceleration.
pub accel: Option<&'a dyn effectcraft_render::Accelerator>,
/// Resolved output path (templates expanded except the `#` frame-number run).
pub path: &'a str,
}
@@ -120,6 +122,7 @@ struct Work {
project: Arc<Project>,
footage: Arc<dyn FootageSource>,
expr: Option<Arc<dyn ExprHost>>,
accel: Option<Arc<dyn effectcraft_render::Accelerator>>,
exporter: Arc<dyn Exporter>,
/// Per queue item: one (item with that output module, resolved path) per output module.
items: Vec<Vec<(RenderQueueItem, String)>>,
@@ -155,7 +158,7 @@ fn run_work(w: Work, shared: &JobShared) {
// first module's file is the item's output.
let mut r: Result<ExportResult, String> = Err("no output module".into());
for (mi, (mitem, path)) in modules.iter().enumerate() {
let job = ExportJob { project: &w.project, footage: w.footage.as_ref(), expr: w.expr.as_deref(), item: mitem, path };
let job = ExportJob { project: &w.project, footage: w.footage.as_ref(), expr: w.expr.as_deref(), accel: w.accel.as_deref(), item: mitem, path };
let rr = w.exporter.export(&job, &mut |done, total| {
let mut s = lock(&shared.state);
s.done = done;
@@ -288,7 +291,7 @@ impl Session {
return Err("nothing is queued: add a composition (Composition ▸ Add to Render Queue) and tick Render".into());
}
let ids: Vec<u64> = items.iter().filter_map(|m| m.first().map(|(i, _)| i.id)).collect();
let work = Work { project: self.project.clone(), footage: self.footage.clone(), expr: self.expr.clone(), exporter, items };
let work = Work { project: self.project.clone(), footage: self.footage.clone(), expr: self.expr.clone(), accel: self.accel.clone(), exporter, items };
let shared = Arc::new(JobShared::default());
if wait {
run_work(work, &shared);