Merge branch 'worktree-agent-a12b9f39d35fe5457'

# Conflicts:
#	docs/parity.md
This commit is contained in:
Brandon Thomas
2026-10-03 05:25:26 -04:00
39 changed files with 5600 additions and 958 deletions
+39 -1
View File
@@ -82,7 +82,37 @@ pub(crate) fn encode_png(rgba: &[u8], w: u32, h: u32) -> std::result::Result<Vec
Ok(out)
}
/// Save Frame As ▸ File with `queue`: a Render Queue item for the current frame, from the Frame
/// Default templates (Edit ▸ Templates).
fn queue_frame(s: &mut Session, p: &Value) -> Result<Value> {
use effectcraft_project::render_queue::{RenderQueueItem, TimeSpan};
use effectcraft_project::render_templates::TemplateSlot;
let cid = comp_id(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let t = f_p(p, "time").map(Tick::from_seconds_f64).unwrap_or(s.time());
let mut it = RenderQueueItem::new(0, cid);
it.settings = s.project.render_templates.default_render_settings(TemplateSlot::Still);
it.settings.time_span = TimeSpan::Custom { start: t, end: t + comp.frame_duration() };
it.output = s.project.render_templates.default_output_module(TemplateSlot::Still);
if let Some(path) = str_p(p, "path") {
it.output.output = path.to_string();
} else if !it.output.format.is_sequence() {
it.output.output = "[compName]_frame.[fileExtension]".into();
}
let id = s.edit("Save Frame As", 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 path = s.project.render_queue.last().and_then(|i| s.resolve_output(i));
Ok(json!({"item": id, "queued": true, "output": path, "time": t.seconds()}))
}
fn save_frame(s: &mut Session, p: &Value) -> Result<Value> {
if p.get("queue").and_then(Value::as_bool) == Some(true) {
return queue_frame(s, p);
}
let path = str_p(p, "path").ok_or_else(|| bad("comp.saveFrameAs", "missing `path` (.png)"))?.to_string();
let cid = comp_id(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
@@ -124,7 +154,15 @@ pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("comp.cropToRegionOfInterest", "Crop Comp to Region of Interest", ["Composition"], None, "{comp?}", has_roi, crop_to_roi),
cmd!("comp.cropToLayerBounds", "Crop Comp to Selected Layer(s) Bounds", ["Composition"], None, "{layers?}", has_layers, crop_to_layers),
cmd!("comp.saveFrameAs", "File...", ["Composition", "Save Frame As"], Some("Cmd+Alt+S"), "{path (.png), time?, scale?}", has_comp, save_frame),
cmd!(
"comp.saveFrameAs",
"File...",
["Composition", "Save Frame As"],
Some("Cmd+Alt+S"),
"{path (.png; with queue: an output path or template), time?, scale?, queue?: bool (add a Render Queue item from the Frame Default templates instead of writing now)}",
has_comp,
save_frame
),
cmd!("comp.responsiveTime", "Responsive Design — Time", [], None, "{op: intro|outro|workArea}", has_comp, responsive_time),
]
}
+15 -4
View File
@@ -4,6 +4,7 @@
//! item's switch).
use effectcraft_project::render_queue::{Channels, OutputFormat, OutputModule, PostRenderAction, ProResProfile, RenderQuality, RenderQueueItem, TimeSpan};
use effectcraft_project::render_templates::TemplateSlot;
use effectcraft_project::{Footage, ItemId, ItemKind, Proxy};
use effectcraft_time::Tick;
use serde_json::{Value, json};
@@ -159,18 +160,28 @@ fn create_proxy(s: &mut Session, p: &Value) -> Result<Value> {
k => return Err(bad(C, format!("kind: still|movie, not `{k}`"))),
};
let mut it = RenderQueueItem::new(0, cid);
// The Movie / Still Proxy Default templates, at draft quality and the proxy resolution.
let templates = &s.project.render_templates;
let slot = if still { TemplateSlot::StillProxy } else { TemplateSlot::MovieProxy };
it.settings = templates.default_render_settings(slot);
it.settings.quality = RenderQuality::Draft;
it.settings.resolution = p.get("resolution").and_then(Value::as_f64).unwrap_or(0.5).clamp(0.05, 1.0);
if still {
// One frame at the current time (the comp's poster frame when not the active comp).
let t = if s.active_comp_id() == Some(cid) { s.time() } else { comp.poster_time };
it.settings.time_span = TimeSpan::Custom { start: t, end: t + comp.frame_duration() };
it.output = OutputModule::for_format(OutputFormat::PngSequence);
it.output.output = "[compName]_proxy_[#####].png".into();
it.output = templates.default_output_module(slot);
if !it.output.format.is_sequence() {
it.output = OutputModule::for_format(OutputFormat::PngSequence);
}
it.output.output = "[compName]_proxy_[#####].[fileExtension]".into();
} else {
it.settings.time_span = TimeSpan::LengthOfComp;
it.output = OutputModule::for_format(OutputFormat::ProRes);
it.output.prores_profile = ProResProfile::P4444;
it.output = templates.default_output_module(slot);
if !it.output.format.is_movie() {
it.output = OutputModule::for_format(OutputFormat::ProRes);
it.output.prores_profile = ProResProfile::P4444;
}
it.output.output = "[compName]_proxy.[fileExtension]".into();
}
it.output.channels = Channels::Rgba;
+466 -44
View File
@@ -1,16 +1,20 @@
//! Render Queue: Composition ▸ Add to Render Queue, the queue's Render Settings / Output Module /
//! Output To, Render and Stop.
//! Output To / Log, Render and Stop, Notify, storage overflow folders, and the Render Settings /
//! Output Module templates (Edit ▸ Templates).
//!
//! 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, PostRenderAction, ProResProfile, RenderQuality, RenderQueueItem, RenderSettings, RenderStatus, TimeSpan,
AlphaMode, AudioFormat, AudioOutput, Channels, ColorDepth, CurrentOrOff, DEFAULT_SEQUENCE_TEMPLATE, DEFAULT_TEMPLATE, EffectsMode, FieldRender,
OutputFormat, OutputModule, PostRenderAction, ProResProfile, Pulldown, RESIZE_PRESETS, RenderLog, RenderQuality, RenderQueueItem, RenderSettings,
RenderStatus, ResizeQuality, SwitchOverride, TimeSpan, post_render_parse,
};
use effectcraft_project::render_templates::{RenderTemplates, TemplateKind, TemplateSlot};
use effectcraft_time::{FrameRate, Tick};
use serde_json::{Value, json};
use super::{CommandSpec, b_p, bad, comp_id, f_p, has_comp, str_p};
use super::{CommandSpec, always, b_p, bad, comp_id, f_p, has_comp, str_p};
use crate::{EngineError, Result, Session, cmd, query};
fn can_add(s: &Session) -> std::result::Result<(), String> {
@@ -54,16 +58,32 @@ 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> {
/// Parse an enum parameter.
fn enum_p<T>(p: &Value, k: &str, cmd: &str, parse: fn(&str) -> Option<T>, help: &str) -> Result<Option<T>> {
match p.get(k) {
None | Some(Value::Null) => Ok(None),
Some(Value::String(v)) => parse(v).map(Some).ok_or_else(|| bad(cmd, format!("{k}: {help}"))),
Some(Value::Bool(b)) => parse(if *b { "on" } else { "off" }).map(Some).ok_or_else(|| bad(cmd, format!("{k}: {help}"))),
Some(Value::Number(n)) => parse(&n.to_string()).map(Some).ok_or_else(|| bad(cmd, format!("{k}: {help}"))),
Some(_) => Err(bad(cmd, format!("{k}: {help}"))),
}
}
/// Apply Render Settings parameters (`template` first, then the individual settings). Any
/// individual change makes the settings "Custom".
fn apply_settings(rs: &mut RenderSettings, templates: &RenderTemplates, p: &Value, cmd: &str) -> Result<bool> {
let mut any = false;
if let Some(t) = str_p(p, "template") {
*rs = templates.render_settings(t).ok_or_else(|| bad(cmd, format!("no Render Settings template “{t}”")))?;
any = true;
}
let before = rs.clone();
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)) => {
@@ -74,15 +94,21 @@ fn apply_settings(rs: &mut RenderSettings, p: &Value, cmd: &str) -> Result<bool>
"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"));
let (start, mut end) = (time_p(p, "start"), time_p(p, "end"));
if let Some(d) = time_p(p, "duration") {
// Time Span ▸ Custom ▸ Duration: from the given (or current custom) start.
let a = start.unwrap_or(match rs.time_span {
TimeSpan::Custom { start, .. } => start,
_ => Tick::ZERO,
});
end = Some(a + d);
}
if let Some(ts) = str_p(p, "timeSpan") {
rs.time_span = match ts.to_ascii_lowercase().replace([' ', '_', '-'], "").as_str() {
"workarea" | "workareaonly" => TimeSpan::WorkArea,
@@ -90,14 +116,12 @@ fn apply_settings(rs: &mut RenderSettings, p: &Value, cmd: &str) -> Result<bool>
"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
@@ -106,61 +130,193 @@ fn apply_settings(rs: &mut RenderSettings, p: &Value, cmd: &str) -> Result<bool>
}
if let Some(u) = str_p(p, "proxyUse") {
rs.proxy_use = effectcraft_project::render_queue::ProxyUse::parse(u).ok_or_else(|| bad(cmd, "proxyUse: current|all|comp|none"))?;
any = true;
}
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;
match p.get("motionBlur") {
Some(Value::Bool(b)) => {
rs.motion_blur = *b;
if *b && rs.motion_blur_mode == SwitchOverride::OffForAll {
rs.motion_blur_mode = SwitchOverride::Current;
}
}
Some(_) => {
let m = enum_p(p, "motionBlur", cmd, SwitchOverride::parse, "current|onForChecked|offForAll")?.unwrap_or_default();
rs.motion_blur = m != SwitchOverride::OffForAll;
rs.motion_blur_mode = m;
}
None => {}
}
if let Some(m) = enum_p(p, "frameBlending", cmd, SwitchOverride::parse, "current|onForChecked|offForAll")? {
rs.frame_blending = m;
}
if let Some(f) = enum_p(p, "fieldRender", cmd, FieldRender::parse, "off|upper|lower")? {
rs.field_render = f;
}
if let Some(f) = enum_p(p, "pulldown", cmd, Pulldown::parse, "off|WSSWW|SSWWW|SWWWS|WWWSS|WWSSW")? {
rs.pulldown = f;
}
if let Some(e) = enum_p(p, "effects", cmd, EffectsMode::parse, "current|allOn|allOff")? {
rs.effects = e;
}
if let Some(e) = enum_p(p, "solo", cmd, CurrentOrOff::parse, "current|allOff")? {
rs.solo = e;
}
if let Some(e) = enum_p(p, "guideLayers", cmd, CurrentOrOff::parse, "current|allOff")? {
rs.guide_layers = e;
}
if let Some(e) = enum_p(p, "colorDepth", cmd, ColorDepth::parse, "current|8|16|32")? {
rs.color_depth = e;
}
if let Some(b) = b_p(p, "skipExisting") {
rs.skip_existing = b;
}
if let Some(b) = b_p(p, "storageOverflow") {
rs.storage_overflow = b;
}
if *rs != before {
rs.name = "Custom".into();
any = true;
}
Ok(any)
}
/// Apply Output Module parameters.
fn apply_output(om: &mut OutputModule, p: &Value, cmd: &str) -> Result<bool> {
/// Apply a template to a module, keeping its output path (with the extension fixed).
fn apply_om_template(om: &mut OutputModule, t: &OutputModule) {
let keep = om.output.clone();
let old = om.format;
*om = t.clone();
om.output = keep;
let f = om.format;
om.format = old;
om.set_format(f);
om.channels = t.channels;
om.name = t.name.clone();
}
/// Apply Output Module parameters (`template` first). `roi`: the viewer's region of interest
/// (captured by `crop.useRoi`).
fn apply_output(om: &mut OutputModule, templates: &RenderTemplates, roi: Option<[f64; 4]>, p: &Value, cmd: &str) -> Result<bool> {
let mut any = false;
if let Some(t) = str_p(p, "template") {
let t = templates.output_module(t).ok_or_else(|| bad(cmd, format!("no Output Module template “{t}”")))?;
apply_om_template(om, &t);
any = true;
}
let before = om.clone();
if let Some(f) = str_p(p, "format") {
let f = OutputFormat::from_name(f).ok_or_else(|| bad(cmd, "format: h264|prores|webm|png|jpeg|tiff|exr|gif|wav|aiff"))?;
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")),
"alpha" | "alphaonly" => Channels::Alpha,
_ => return Err(bad(cmd, "channels: rgb|rgba|alpha")),
};
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(c) = str_p(p, "color").or(str_p(p, "alphaMode")) {
om.alpha_mode = match c.to_ascii_lowercase().replace([' ', '(', ')'], "").as_str() {
"straight" | "straightunmatted" => AlphaMode::Straight,
"premultiplied" | "premultipliedmatted" | "premul" => AlphaMode::Premultiplied,
_ => return Err(bad(cmd, "color: straight|premultiplied")),
};
}
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;
om.bitrate_kbps = b.clamp(50.0, 500_000.0) as u32;
}
if let Some(b) = b_p(p, "webmBitrate") {
om.webm_bitrate = b;
}
if let Some(k) = p.get("keyframeInterval").and_then(Value::as_u64) {
om.keyframe_interval = k.min(100_000) as u32;
}
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;
}
match p.get("crop") {
Some(Value::Bool(b)) => om.crop.enabled = *b,
Some(Value::Object(c)) => {
om.crop.enabled = c.get("enabled").and_then(Value::as_bool).unwrap_or(true);
for (k, v) in [("top", &mut om.crop.top), ("left", &mut om.crop.left), ("bottom", &mut om.crop.bottom), ("right", &mut om.crop.right)] {
if let Some(n) = c.get(k).and_then(Value::as_i64) {
*v = n.clamp(-20_000, 20_000) as i32;
}
}
if let Some(u) = c.get("useRoi").and_then(Value::as_bool) {
om.crop.use_roi = u;
if u {
om.crop.roi = c.get("roi").and_then(|r| serde_json::from_value::<[f64; 4]>(r.clone()).ok()).or(roi);
if om.crop.roi.is_none() {
return Err(bad(
cmd,
"Use Region of Interest: the composition has no region of interest (View ▸ Region of Interest) — pass crop.roi [x, y, w, h]",
));
}
}
}
}
Some(Value::Null) | None => {}
Some(_) => return Err(bad(cmd, "crop: bool or {enabled?, useRoi?, roi?: [x,y,w,h], top?, left?, bottom?, right?}")),
}
// Flat keys (dialog forms): cropTop/cropLeft/cropBottom/cropRight, resizeWidth/resizeHeight.
for (k, v) in [("cropTop", &mut om.crop.top), ("cropLeft", &mut om.crop.left), ("cropBottom", &mut om.crop.bottom), ("cropRight", &mut om.crop.right)] {
if let Some(n) = p.get(k).and_then(Value::as_f64) {
*v = (n.round() as i64).clamp(-20_000, 20_000) as i32;
om.crop.enabled = true;
om.crop.use_roi = false;
}
}
for (k, v) in [("resizeWidth", &mut om.resize.width), ("resizeHeight", &mut om.resize.height)] {
if let Some(n) = p.get(k).and_then(Value::as_f64) {
*v = (n.round() as u32).clamp(1, 30_000);
om.resize.enabled = true;
}
}
match p.get("resize") {
Some(Value::Bool(b)) => om.resize.enabled = *b,
Some(Value::Object(r)) => {
om.resize.enabled = r.get("enabled").and_then(Value::as_bool).unwrap_or(true);
if let Some(name) = r.get("preset").and_then(Value::as_str) {
let (_, w, h) =
RESIZE_PRESETS.iter().find(|(n, _, _)| n.eq_ignore_ascii_case(name)).ok_or_else(|| bad(cmd, format!("no resize preset “{name}”")))?;
(om.resize.width, om.resize.height) = (*w, *h);
om.resize.lock_aspect = false;
}
if let Some(l) = r.get("lockAspect").and_then(Value::as_bool) {
om.resize.lock_aspect = l;
}
if let Some(w) = r.get("width").and_then(Value::as_u64) {
om.resize.width = (w as u32).clamp(1, 30_000);
}
if let Some(h) = r.get("height").and_then(Value::as_u64) {
om.resize.height = (h as u32).clamp(1, 30_000);
}
if let Some(q) = r.get("quality").and_then(Value::as_str) {
om.resize.quality = match q.to_ascii_lowercase().as_str() {
"low" | "bilinear" => ResizeQuality::Low,
"high" | "bicubic" => ResizeQuality::High,
_ => return Err(bad(cmd, "resize.quality: low|high")),
};
}
}
Some(Value::Null) | None => {}
Some(_) => return Err(bad(cmd, "resize: bool or {enabled?, preset?, width?, height?, lockAspect?, quality?: low|high}")),
}
if let Some(a) = p.get("audio") {
om.audio = match a {
@@ -174,15 +330,29 @@ fn apply_output(om: &mut OutputModule, p: &Value, cmd: &str) -> Result<bool> {
},
_ => 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;
}
match p.get("audioChannels") {
Some(Value::Number(n)) => om.audio_channels = if n.as_u64() == Some(1) { 1 } else { 2 },
Some(Value::String(c)) => {
om.audio_channels = match c.to_ascii_lowercase().as_str() {
"mono" | "1" => 1,
"stereo" | "2" => 2,
_ => return Err(bad(cmd, "audioChannels: mono|stereo")),
}
}
_ => {}
}
if let Some(f) = enum_p(p, "audioFormat", cmd, AudioFormat::parse, "16|24|32 (float)")? {
om.audio_format = f;
}
if let Some(l) = b_p(p, "loop") {
om.gif_loop = l;
any = true;
}
if let Some(l) = b_p(p, "includeProjectLink") {
om.include_project_link = l;
}
if let Some(o) = str_p(p, "output").or(str_p(p, "path")) {
om.output = o.to_string();
@@ -194,6 +364,14 @@ fn apply_output(om: &mut OutputModule, p: &Value, cmd: &str) -> Result<bool> {
om.set_format(f);
om.output = keep;
}
}
if *om != before {
// Output To alone doesn't make the module custom.
let mut o = om.clone();
o.output = before.output.clone();
if o != before {
om.name = "Custom".into();
}
any = true;
}
Ok(any)
@@ -227,7 +405,7 @@ fn requeue(it: &mut RenderQueueItem) {
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 (w, h) = comp.map(|c| it.output.output_size(c, &it.settings)).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() {
@@ -249,6 +427,10 @@ fn item_json(s: &Session, it: &RenderQueueItem, index: usize) -> Value {
o.insert("outputModules".into(), json!(it.extra_outputs.len() + 1));
o.insert("extraOutputs".into(), json!(extra));
o.insert("postRenderAction".into(), json!(it.post_render.label()));
o.insert("logLabel".into(), json!(it.log.label()));
if let Some(p) = s.resolve_output(it) {
o.insert("logPath".into(), json!(effectcraft_project::render_queue::log_path(&p)));
}
o.insert("width".into(), json!(w));
o.insert("height".into(), json!(h));
o.insert("frames".into(), json!(frames));
@@ -259,16 +441,25 @@ fn item_json(s: &Session, it: &RenderQueueItem, index: usize) -> Value {
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);
// New items start from the Movie Default templates (Edit ▸ Templates).
let templates = s.project.render_templates.clone();
it.settings = templates.default_render_settings(TemplateSlot::Movie);
it.output = templates.default_output_module(TemplateSlot::Movie);
if let Some(f) = str_p(p, "format") {
let f = OutputFormat::from_name(f).ok_or_else(|| bad("renderQueue.add", "format: h264|prores|webm|png|jpeg|tiff|exr|gif|wav|aiff"))?;
it.output = OutputModule::for_format(f);
if f != it.output.format {
it.output = OutputModule::for_format(f);
}
}
apply_settings(&mut it.settings, p, "renderQueue.add")?;
apply_settings(&mut it.settings, &templates, 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")?;
apply_output(&mut it.output, &templates, s.state.region_of_interest, &op, "renderQueue.add")?;
if let Some(l) = enum_p(p, "log", "renderQueue.add", RenderLog::parse, "errorsOnly|plusSettings|plusPerFrameInfo")? {
it.log = l;
}
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;
@@ -305,7 +496,7 @@ fn set_render(s: &mut Session, p: &Value) -> Result<Value> {
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")? {
if !apply_settings(&mut rs, &s.project.render_templates, p, "renderQueue.setRenderSettings")? {
return Err(bad("renderQueue.setRenderSettings", "nothing to change"));
}
s.edit("Render Settings", None, |proj, _| {
@@ -321,12 +512,20 @@ fn set_output_module(s: &mut Session, p: &Value) -> Result<Value> {
let i = item_index(s, p, "renderQueue.setOutputModule")?;
let m = module_p(s, i, p, "renderQueue.setOutputModule")?;
let mut om = module_ref(&s.project.render_queue[i], m).clone();
if !apply_output(&mut om, p, "renderQueue.setOutputModule")? {
let changed = apply_output(&mut om, &s.project.render_templates, s.state.region_of_interest, p, "renderQueue.setOutputModule")?;
let post = match str_p(p, "postRenderAction") {
Some(a) => Some(post_render_parse(a).ok_or_else(|| bad("renderQueue.setOutputModule", "postRenderAction: none|import|importAndReplace|setProxy"))?),
None => None,
};
if !changed && post.is_none() {
return Err(bad("renderQueue.setOutputModule", "nothing to change"));
}
s.edit("Output Module Settings", None, |proj, _| {
let it = &mut proj.render_queue[i];
*module_mut(it, m) = om;
if let Some(a) = post {
it.post_render = a;
}
requeue(it);
Ok(())
})?;
@@ -338,7 +537,7 @@ fn set_output(s: &mut Session, p: &Value) -> Result<Value> {
let path = str_p(p, "path").or(str_p(p, "output")).ok_or_else(|| bad("renderQueue.setOutput", "pass `path` (file path or template)"))?;
let m = module_p(s, i, p, "renderQueue.setOutput")?;
let mut om = module_ref(&s.project.render_queue[i], m).clone();
apply_output(&mut om, &json!({"output": path}), "renderQueue.setOutput")?;
apply_output(&mut om, &s.project.render_templates, None, &json!({"output": path}), "renderQueue.setOutput")?;
s.edit("Output To", None, |proj, _| {
let it = &mut proj.render_queue[i];
*module_mut(it, m) = om;
@@ -452,7 +651,7 @@ fn add_output_module(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(o) = op.as_object_mut() {
o.remove("format");
}
apply_output(&mut om, &op, "render.addOutputModule")?;
apply_output(&mut om, &s.project.render_templates, s.state.region_of_interest, &op, "render.addOutputModule")?;
s.edit("Add Output Module", None, |proj, _| {
let it = &mut proj.render_queue[i];
it.extra_outputs.push(om);
@@ -469,11 +668,11 @@ fn add_output_module(s: &mut Session, p: &Value) -> Result<Value> {
fn pre_render(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);
it.output = OutputModule::for_format(OutputFormat::ProRes);
it.output.prores_profile = ProResProfile::P4444;
it.output.channels = Channels::Rgba;
let templates = s.project.render_templates.clone();
it.settings = templates.default_render_settings(TemplateSlot::PreRender);
it.output = templates.default_output_module(TemplateSlot::PreRender);
it.output.output = "[compName]_prerender.[fileExtension]".into();
apply_output(&mut it.output, p, "render.preRender")?;
apply_output(&mut it.output, &templates, s.state.region_of_interest, p, "render.preRender")?;
it.post_render = PostRenderAction::ImportAndReplace;
let id = s.edit("Pre-render", None, |proj, _| {
it.id = proj.render_queue.iter().map(|i| i.id).max().unwrap_or(0) + 1;
@@ -506,6 +705,8 @@ fn save_current_preview(s: &mut Session, p: &Value) -> Result<Value> {
accel: s.accel.as_deref(),
item: &item,
path: &path,
storage: s.storage_quota.as_deref(),
label: "Save Current Preview".into(),
nested_switches: s.prefs.general.switches_affect_nested_comps,
};
let r = exporter.export(&job, &mut |_, _| true).map_err(EngineError::Other)?;
@@ -513,6 +714,187 @@ fn save_current_preview(s: &mut Session, p: &Value) -> Result<Value> {
Ok(serde_json::to_value(&r).unwrap_or_default())
}
// ---------------------------------------------------------------- templates (Edit ▸ Templates)
fn kind_p(p: &Value, cmd: &str) -> Result<TemplateKind> {
str_p(p, "kind").and_then(TemplateKind::parse).ok_or_else(|| bad(cmd, "kind: renderSettings|outputModule"))
}
fn name_p<'a>(p: &'a Value, cmd: &str) -> Result<&'a str> {
str_p(p, "name").map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad(cmd, "pass `name`"))
}
/// Templates as JSON (lists, built-in flags, defaults per slot).
fn templates_json(t: &RenderTemplates) -> Value {
let rs: Vec<Value> = t
.render_settings_list()
.iter()
.map(|r| json!({"name": r.name, "builtin": RenderTemplates::is_builtin(TemplateKind::RenderSettings, &r.name), "settings": r}))
.collect();
let om: Vec<Value> = t
.output_module_list()
.iter()
.map(|m| json!({"name": m.name, "builtin": RenderTemplates::is_builtin(TemplateKind::OutputModule, &m.name), "summary": format!("{} · {}", m.format.label(), m.channels.label()), "module": m}))
.collect();
let slots = |k: TemplateKind| -> Value {
TemplateSlot::ALL.iter().map(|sl| (format!("{sl:?}"), json!(t.default_name(k, *sl)))).collect::<serde_json::Map<String, Value>>().into()
};
json!({
"renderSettings": rs,
"outputModules": om,
"defaults": {"renderSettings": slots(TemplateKind::RenderSettings), "outputModules": slots(TemplateKind::OutputModule)},
"slots": TemplateSlot::ALL.iter().map(|sl| json!({"id": format!("{sl:?}"), "label": sl.label()})).collect::<Vec<_>>(),
"resizePresets": RESIZE_PRESETS.iter().map(|(n, w, h)| json!({"name": n, "width": w, "height": h})).collect::<Vec<_>>(),
})
}
fn templates(s: &mut Session, _: &Value) -> Result<Value> {
Ok(templates_json(&s.project.render_templates))
}
/// Save a template: from an item's settings (`item`/`index`, `module`), or the slot default,
/// adjusted by `params` (the same keys as setRenderSettings / setOutputModule).
fn save_template(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "renderQueue.saveTemplate";
let kind = kind_p(p, C)?;
let name = name_p(p, C)?.to_string();
if name.eq_ignore_ascii_case("custom") {
return Err(bad(C, "“Custom” is reserved"));
}
let item = if p.get("item").is_some() || p.get("index").is_some() { Some(item_index(s, p, C)?) } else { None };
let params = p.get("params").cloned().unwrap_or(Value::Null);
let from = str_p(p, "from");
let mut t = s.project.render_templates.clone();
match kind {
TemplateKind::RenderSettings => {
let mut rs = match (item, from) {
(Some(i), _) => s.project.render_queue[i].settings.clone(),
(None, Some(f)) => t.render_settings(f).ok_or_else(|| bad(C, format!("no Render Settings template “{f}”")))?,
(None, None) => t.default_render_settings(TemplateSlot::Movie),
};
if params.is_object() {
apply_settings(&mut rs, &t, &params, C)?;
}
t.save_render_settings(&name, rs);
}
TemplateKind::OutputModule => {
let mut om = match (item, from) {
(Some(i), _) => module_ref(&s.project.render_queue[i], module_p(s, i, p, C)?).clone(),
(None, Some(f)) => t.output_module(f).ok_or_else(|| bad(C, format!("no Output Module template “{f}”")))?,
(None, None) => t.default_output_module(TemplateSlot::Movie),
};
if params.is_object() {
apply_output(&mut om, &t, s.state.region_of_interest, &params, C)?;
}
// Templates name files after the composition, not a particular path.
om.output = if om.format.is_sequence() { DEFAULT_SEQUENCE_TEMPLATE } else { DEFAULT_TEMPLATE }.into();
t.save_output_module(&name, om);
}
}
s.edit("Save Template", None, |proj, _| {
proj.render_templates = t;
Ok(())
})?;
Ok(json!({"saved": name, "templates": templates_json(&s.project.render_templates)}))
}
fn delete_template(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "renderQueue.deleteTemplate";
let kind = kind_p(p, C)?;
let name = name_p(p, C)?.to_string();
let mut t = s.project.render_templates.clone();
t.delete(kind, &name).map_err(|e| bad(C, e))?;
s.edit("Delete Template", None, |proj, _| {
proj.render_templates = t;
Ok(())
})?;
Ok(json!({"deleted": name}))
}
fn set_template_default(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "renderQueue.setTemplateDefault";
let kind = kind_p(p, C)?;
let slot = str_p(p, "slot").and_then(TemplateSlot::parse).ok_or_else(|| bad(C, "slot: movie|still|preRender|movieProxy|stillProxy"))?;
let name = name_p(p, C)?.to_string();
let mut t = s.project.render_templates.clone();
t.set_default(kind, slot, &name).map_err(|e| bad(C, e))?;
s.edit("Template Default", None, |proj, _| {
proj.render_templates = t;
Ok(())
})?;
Ok(json!({"slot": format!("{slot:?}"), "name": name}))
}
/// Apply templates to an item: `renderSettings` and/or `outputModule` (to `module`).
fn apply_template(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "renderQueue.applyTemplate";
let i = item_index(s, p, C)?;
let m = module_p(s, i, p, C)?;
let t = s.project.render_templates.clone();
let rs = match str_p(p, "renderSettings") {
Some(n) => Some(t.render_settings(n).ok_or_else(|| bad(C, format!("no Render Settings template “{n}”")))?),
None => None,
};
let om = match str_p(p, "outputModule") {
Some(n) => {
let tpl = t.output_module(n).ok_or_else(|| bad(C, format!("no Output Module template “{n}”")))?;
let mut om = module_ref(&s.project.render_queue[i], m).clone();
apply_om_template(&mut om, &tpl);
Some(om)
}
None => None,
};
if rs.is_none() && om.is_none() {
return Err(bad(C, "pass `renderSettings` and/or `outputModule` (template names)"));
}
s.edit("Apply Template", None, |proj, _| {
let it = &mut proj.render_queue[i];
if let Some(rs) = rs {
it.settings = rs;
}
if let Some(om) = om {
*module_mut(it, m) = om;
}
requeue(it);
Ok(())
})?;
Ok(item_json(s, &s.project.render_queue[i], i))
}
fn set_log(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "renderQueue.setLog";
let i = item_index(s, p, C)?;
let log = enum_p(p, "log", C, RenderLog::parse, "errorsOnly|plusSettings|plusPerFrameInfo")?.ok_or_else(|| bad(C, "pass `log`"))?;
s.edit("Render Queue: Log", None, |proj, _| {
proj.render_queue[i].log = log;
Ok(())
})?;
Ok(item_json(s, &s.project.render_queue[i], i))
}
fn set_notify(s: &mut Session, p: &Value) -> Result<Value> {
let on = b_p(p, "notify").or(b_p(p, "value")).unwrap_or(!s.project.render_prefs.notify);
s.edit("Render Queue: Notify", None, |proj, _| {
proj.render_prefs.notify = on;
Ok(())
})?;
Ok(json!({"notify": on}))
}
fn set_overflow(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "renderQueue.setOverflowFolders";
let folders: Vec<String> = match p.get("folders") {
Some(Value::Array(a)) => a.iter().filter_map(Value::as_str).map(str::trim).filter(|f| !f.is_empty()).map(String::from).collect(),
Some(Value::Null) | None => vec![],
_ => return Err(bad(C, "folders: [path, …]")),
};
s.edit("Storage Overflow Folders", None, |proj, _| {
proj.render_prefs.overflow_folders = folders.clone();
Ok(())
})?;
Ok(json!({"folders": s.project.render_prefs.overflow_folders}))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
@@ -520,7 +902,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Add to Render Queue",
["Composition"],
Some("Cmd+M"),
"{comp?: id|name, format?: h264|prores|webm|png|jpeg|tiff|exr|gif|wav|aiff, output?: path|template, quality?: best|draft|1-100 (jpeg, webm), resolution?: full|half|third|quarter|scale, timeSpan?: workArea|comp|custom, start?: s, end?: s, frameRate?: fps|null, motionBlur?: bool, skipExisting?: bool, proxyUse?: current|all|comp|none, channels?: rgb|rgba, bitrate?: kbps, proresProfile?: proxy|lt|standard|hq|4444|4444xq, audio?: auto|on|off, sampleRate?, loop?: bool}",
"{comp?: id|name, template?: Render Settings template, format?: h264|prores|webm|png|jpeg|tiff|exr|gif|wav|aiff, output?: path|template, log?: errorsOnly|plusSettings|plusPerFrameInfo, quality?: best|draft|1-100 (jpeg, webm), resolution?: full|half|third|quarter|scale, proxyUse?, effects?, solo?, guideLayers?, colorDepth?, frameBlending?, fieldRender?, pulldown?, motionBlur?, timeSpan?: workArea|comp|custom, start?: s, end?: s, duration?: s, frameRate?: fps|null, skipExisting?: bool, storageOverflow?: bool, channels?: rgb|rgba|alpha, color?, bitrate?: kbps, webmBitrate?, keyframeInterval?, proresProfile?, crop?, resize?, includeProjectLink?, audio?: auto|on|off, sampleRate?, audioChannels?, audioFormat?, loop?: bool (values as in setRenderSettings / setOutputModule)}",
can_add,
add
),
@@ -531,7 +913,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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, proxyUse?: current|all|comp|none}",
"{item?|index?, template?: name, quality?: best|draft, resolution?: full|half|third|quarter|scale, proxyUse?: current|all|comp|none, effects?: current|allOn|allOff, solo?: current|allOff, guideLayers?: current|allOff, colorDepth?: current|8|16|32, frameBlending?: current|onForChecked|offForAll, fieldRender?: off|upper|lower, pulldown?: off|WSSWW|SSWWW|SWWWS|WWWSS|WWSSW, motionBlur?: bool|current|onForChecked|offForAll, timeSpan?: workArea|comp|custom, start?: s, end?: s, duration?: s, frameRate?: fps|null, skipExisting?: bool, storageOverflow?: bool}",
has_items,
set_render_settings
),
@@ -540,7 +922,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Output Module Settings...",
[],
None,
"{item?|index?, module?: n (1 = first), format?, channels?: rgb|rgba, quality?: 1-100, bitrate?: kbps, proresProfile?: proxy|lt|standard|hq|4444|4444xq, audio?: auto|on|off, sampleRate?, loop?: bool, output?}",
"{item?|index?, module?: n (1 = first), template?: name, format?, channels?: rgb|rgba|alpha, color?: straight|premultiplied, quality?: 1-100, bitrate?: kbps, webmBitrate?: bool, keyframeInterval?: frames, proresProfile?: proxy|lt|standard|hq|4444|4444xq, crop?: bool|{useRoi?, roi?, top?, left?, bottom?, right?}, cropTop?, cropLeft?, cropBottom?, cropRight?, resize?: bool|{preset?, width?, height?, lockAspect?, quality?: low|high}, resizeWidth?, resizeHeight?, postRenderAction?: none|import|importAndReplace|setProxy, includeProjectLink?: bool, audio?: auto|on|off, sampleRate?, audioChannels?: mono|stereo, audioFormat?: 16|24|32, loop?: bool, output?}",
has_items,
set_output_module
),
@@ -569,6 +951,46 @@ pub fn specs() -> Vec<CommandSpec> {
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.templates", "Render Templates", "{}", templates),
cmd!(
"renderQueue.saveTemplate",
"Save Template...",
[],
None,
"{kind: renderSettings|outputModule, name, item?|index? (save from this item), from?: template to copy, module?, params?: {setRenderSettings / setOutputModule keys}}",
always,
save_template
),
cmd!("renderQueue.deleteTemplate", "Delete Template", [], None, "{kind: renderSettings|outputModule, name}", always, delete_template),
cmd!(
"renderQueue.setTemplateDefault",
"Set Template Default",
[],
None,
"{kind: renderSettings|outputModule, slot: movie|still|preRender|movieProxy|stillProxy, name}",
always,
set_template_default
),
cmd!(
"renderQueue.applyTemplate",
"Apply Template",
[],
None,
"{item?|index?, renderSettings?: template name, outputModule?: template name, module?: n}",
has_items,
apply_template
),
cmd!("renderQueue.setLog", "Render Queue: Log", [], None, "{item?|index?, log: errorsOnly|plusSettings|plusPerFrameInfo}", has_items, set_log),
cmd!("renderQueue.setNotify", "Notify When Done", [], None, "{notify?: bool (toggles)}", always, set_notify),
cmd!(
"renderQueue.setOverflowFolders",
"Storage Overflow Folders",
[],
None,
"{folders: [path, …] (used in order when the output volume is full)}",
always,
set_overflow
),
query!("renderQueue.formats", "Output Formats", "{}", formats),
]
}
+3
View File
@@ -285,6 +285,8 @@ pub struct Session {
pub importer: Option<Arc<dyn Importer>>,
/// Render Queue encoder (the export layer); `None` = export unavailable.
pub exporter: Option<Arc<dyn Exporter>>,
/// Free-space hook for Render Settings ▸ Use Storage Overflow (`None`: never full).
pub storage_quota: Option<Arc<dyn effectcraft_project::render_queue::StorageQuota>>,
/// The running (or finished, not yet polled) render.
pub render_job: Option<render_queue::RenderJob>,
/// The running (or finished, not yet polled) track analysis.
@@ -386,6 +388,7 @@ impl Default for Session {
expr_check: None,
importer: None,
exporter: None,
storage_quota: None,
render_job: None,
track_job: None,
mask_job: None,
+22 -2
View File
@@ -27,6 +27,10 @@ pub struct ExportJob<'a> {
pub accel: Option<&'a dyn effectcraft_render::Accelerator>,
/// Resolved output path (templates expanded except the `#` frame-number run).
pub path: &'a str,
/// Free-space hook (Use Storage Overflow).
pub storage: Option<&'a dyn effectcraft_project::render_queue::StorageQuota>,
/// "#n Comp Name", for the render log.
pub label: String,
/// Settings ▸ General ▸ Switches Affect Nested Comps.
pub nested_switches: bool,
}
@@ -41,6 +45,12 @@ pub struct ExportResult {
pub bytes: u64,
pub seconds: f64,
pub audio: bool,
/// The render log written next to the output, if any.
#[serde(skip_serializing_if = "Option::is_none")]
pub log: Option<String>,
/// Files that went to storage overflow folders.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub overflow: Vec<String>,
}
/// Encodes render queue items (implemented by the export layer).
@@ -129,6 +139,7 @@ struct Work {
expr: Option<Arc<dyn ExprHost>>,
accel: Option<Arc<dyn effectcraft_render::Accelerator>>,
exporter: Arc<dyn Exporter>,
storage: Option<Arc<dyn effectcraft_project::render_queue::StorageQuota>>,
/// Per queue item: one (item with that output module, resolved path) per output module
/// (and per segment, see [`Session::segments`]).
items: Vec<Vec<(RenderQueueItem, String)>>,
@@ -199,6 +210,7 @@ pub(crate) fn run_items(
expr: s.expr.clone(),
accel: s.accel.clone(),
exporter,
storage: s.storage_quota.clone(),
items,
nested_switches: s.prefs.general.switches_affect_nested_comps,
};
@@ -232,6 +244,8 @@ fn run_work(w: Work, shared: &JobShared, notify: &mut dyn FnMut(&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 name = w.project.item(mitem.comp).map(|i| i.name.clone()).unwrap_or_default();
let index = w.project.render_queue.iter().position(|i| i.id == mitem.id).map_or(0, |i| i + 1);
let job = ExportJob {
project: &w.project,
footage: w.footage.as_ref(),
@@ -239,6 +253,8 @@ fn run_work(w: Work, shared: &JobShared, notify: &mut dyn FnMut(&JobShared)) {
accel: w.accel.as_deref(),
item: mitem,
path,
storage: w.storage.as_deref(),
label: format!("#{index} {name} (output module {})", mi + 1),
nested_switches: w.nested_switches,
};
let rr = w.exporter.export(&job, &mut |done, total| {
@@ -301,8 +317,7 @@ impl Session {
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 (w, h) = item.output.output_size(comp, &item.settings);
let rate = item.settings.rate(comp);
let n = item.settings.frame_count(comp) as i64;
let f0 = item.settings.first_frame(comp);
@@ -453,6 +468,7 @@ impl Session {
expr: self.expr.clone(),
accel: self.accel.clone(),
exporter,
storage: self.storage_quota.clone(),
items,
nested_switches: self.prefs.general.switches_affect_nested_comps,
};
@@ -552,6 +568,10 @@ impl Session {
} else {
format!("Rendered {} item(s) in {:.1} s", st.items_total, st.elapsed)
};
if self.project.render_prefs.notify {
// Notify: the frontend plays a sound and shows a system notification.
self.events.push(Event::Frontend { command: "renderQueue.notify".into(), params: serde_json::json!({"message": msg, "error": failed > 0}) });
}
self.events.push(Event::Toast { message: msg, error: failed > 0 });
}
for (id, path) in post {
+117
View File
@@ -312,6 +312,123 @@ fn pre_render_imports_and_replaces() {
assert!(matches!(s.project.comp(outer).unwrap().layers[0].source, LayerSource::Comp { .. }));
}
#[test]
fn post_render_actions_import_and_set_proxy() {
let (mut s, _) = rq();
let cid = s.active_comp_id().unwrap();
let items_before = s.project.items.len();
// Import: the rendered file becomes a footage item.
let a = s.execute("renderQueue.add", json!({"output": "/tmp/post/a.mov"})).unwrap();
s.execute("renderQueue.setOutputModule", json!({"item": a["item"], "postRenderAction": "import"})).unwrap();
assert_eq!(s.project.render_queue[0].post_render, PostRenderAction::Import);
s.execute("renderQueue.render", json!({})).unwrap();
assert_eq!(s.project.items.len(), items_before + 1);
assert!(s.project.items.values().any(|i| matches!(&i.kind, effectcraft_project::ItemKind::Footage(f) if f.path == "/tmp/post/a.mov")));
// Set Proxy: the comp gets the render as its proxy.
let b = s.execute("renderQueue.add", json!({"output": "/tmp/post/b.mov"})).unwrap();
s.execute("renderQueue.setOutputModule", json!({"item": b["item"], "postRenderAction": "Set Proxy"})).unwrap();
s.execute("renderQueue.render", json!({})).unwrap();
let px = s.project.item(cid).unwrap().proxy.clone().expect("proxy set");
assert_eq!(px.footage.path, "/tmp/post/b.mov");
// None: nothing happens.
let n = s.project.items.len();
let c = s.execute("renderQueue.add", json!({"output": "/tmp/post/c.mov"})).unwrap();
s.execute("renderQueue.setOutputModule", json!({"item": c["item"], "postRenderAction": "none"})).unwrap();
s.execute("renderQueue.render", json!({})).unwrap();
assert_eq!(s.project.items.len(), n);
assert!(s.execute("renderQueue.setOutputModule", json!({"item": c["item"], "postRenderAction": "explode"})).is_err());
}
#[test]
fn templates_save_apply_default_and_persist() {
let (mut s, _) = rq();
let t = s.execute("renderQueue.templates", json!({})).unwrap();
let names: Vec<&str> = t["renderSettings"].as_array().unwrap().iter().map(|r| r["name"].as_str().unwrap()).collect();
assert_eq!(names, ["Best Settings", "Draft Settings", "DV Settings", "Multi-Machine Settings", "Current Settings"]);
assert!(t["outputModules"].as_array().unwrap().iter().any(|m| m["name"] == "High Quality with Alpha"));
assert_eq!(t["defaults"]["renderSettings"]["Movie"], "Best Settings");
// A new item starts from the Movie defaults.
let a = s.execute("renderQueue.add", json!({"output": "/tmp/t/a.mp4"})).unwrap();
assert_eq!(a["renderSettingsSummary"], "Best Settings");
assert_eq!(a["outputModuleSummary"], "H.264 - Match Render Settings - 15 Mbps");
// Edit it: Custom; save templates from it; make them the defaults.
let r = s
.execute(
"renderQueue.setRenderSettings",
json!({"item": a["item"], "fieldRender": "upper", "pulldown": "WSSWW", "colorDepth": 16, "effects": "allOff", "solo": "allOff", "guideLayers": "current", "frameBlending": "offForAll", "motionBlur": "onForChecked", "duration": 0.5, "storageOverflow": false}),
)
.unwrap();
assert_eq!(r["renderSettingsSummary"], "Custom");
assert_eq!(r["settings"]["field_render"], "UpperFirst");
s.execute(
"renderQueue.setOutputModule",
json!({"item": a["item"], "format": "png", "channels": "rgba", "color": "premultiplied", "crop": {"top": 2, "bottom": 2}, "resize": {"width": 320, "height": 200, "lockAspect": false, "quality": "low"}}),
)
.unwrap();
s.execute("renderQueue.saveTemplate", json!({"kind": "renderSettings", "name": "Interlaced 16", "item": a["item"]})).unwrap();
s.execute("renderQueue.saveTemplate", json!({"kind": "outputModule", "name": "Small PNG", "item": a["item"], "params": {"quality": 50}})).unwrap();
s.execute("renderQueue.setTemplateDefault", json!({"kind": "renderSettings", "slot": "movie", "name": "Interlaced 16"})).unwrap();
s.execute("renderQueue.setTemplateDefault", json!({"kind": "outputModule", "slot": "movie", "name": "Small PNG"})).unwrap();
assert!(s.execute("renderQueue.setTemplateDefault", json!({"kind": "outputModule", "slot": "movie", "name": "Nope"})).is_err());
let b = s.execute("renderQueue.add", json!({"output": "/tmp/t/b_[####].png"})).unwrap();
assert_eq!(b["renderSettingsSummary"], "Interlaced 16");
assert_eq!(b["outputModuleSummary"], "Small PNG");
let it = s.project.render_queue.last().unwrap().clone();
assert_eq!(it.output.resize.width, 320);
assert_eq!(it.output.output, "/tmp/t/b_[####].png");
assert_eq!((b["width"].as_u64(), b["height"].as_u64()), (Some(320), Some(200)));
// Apply built-ins to an item; the output path keeps its name with the new extension.
let r = s.execute("renderQueue.applyTemplate", json!({"item": a["item"], "renderSettings": "DV Settings", "outputModule": "High Quality"})).unwrap();
assert_eq!(r["renderSettingsSummary"], "DV Settings");
assert_eq!(r["outputModuleSummary"], "High Quality");
assert!(r["outputPath"].as_str().unwrap().ends_with("a.mov"), "{r}");
// Persisted with the project.
let p = effectcraft_project::Project::from_json(&s.project.to_json()).unwrap();
assert_eq!(p.render_templates, s.project.render_templates);
assert_eq!(p.render_queue, s.project.render_queue);
// Delete: saved ones go; built-ins stay. Undo restores.
s.execute("renderQueue.deleteTemplate", json!({"kind": "outputModule", "name": "Small PNG"})).unwrap();
assert!(s.project.render_templates.output_module("Small PNG").is_none());
assert!(s.execute("renderQueue.deleteTemplate", json!({"kind": "renderSettings", "name": "Best Settings"})).is_err());
s.execute("edit.undo", json!({})).unwrap();
assert!(s.project.render_templates.output_module("Small PNG").is_some());
}
#[test]
fn save_frame_as_queues_with_the_frame_defaults() {
let (mut s, _) = rq();
s.execute("renderQueue.setTemplateDefault", json!({"kind": "outputModule", "slot": "still", "name": "TIFF Sequence with Alpha"})).unwrap();
let r = s.execute("comp.saveFrameAs", json!({"queue": true, "time": 1.0})).unwrap();
assert_eq!(r["queued"], true);
let it = s.project.render_queue.last().unwrap();
assert_eq!(it.output.format, OutputFormat::TiffSequence);
assert_eq!(it.settings.name, "Current Settings");
let comp = s.project.comp(it.comp).unwrap();
assert_eq!(it.settings.frame_count(comp), 1);
assert_eq!(it.settings.frame_time(comp, 0), Tick::from_seconds_f64(1.0));
}
#[test]
fn log_notify_and_overflow_settings() {
let (mut s, _) = rq();
let a = s.execute("renderQueue.add", json!({"output": "/tmp/n/a.mp4", "log": "plusPerFrameInfo"})).unwrap();
assert_eq!(a["logLabel"], "Plus Per Frame Info");
assert_eq!(a["logPath"], "/tmp/n/a_RenderLog.txt");
s.execute("renderQueue.setLog", json!({"item": a["item"], "log": "plusSettings"})).unwrap();
assert_eq!(s.project.render_queue[0].log, effectcraft_project::render_queue::RenderLog::PlusSettings);
s.execute("renderQueue.setOverflowFolders", json!({"folders": ["/tmp/o1", " ", "/tmp/o2"]})).unwrap();
assert_eq!(s.project.render_prefs.overflow_folders, ["/tmp/o1", "/tmp/o2"]);
assert_eq!(s.execute("renderQueue.setNotify", json!({})).unwrap()["notify"], true);
s.drain_events();
s.execute("renderQueue.render", json!({})).unwrap();
let ev = s.drain_events();
assert!(ev.iter().any(|e| matches!(e, Event::Frontend { command, .. } if command == "renderQueue.notify")), "{ev:?}");
s.execute("renderQueue.setNotify", json!({"notify": false})).unwrap();
s.execute("renderQueue.setRender", json!({"index": 1, "render": true})).unwrap();
s.execute("renderQueue.render", json!({})).unwrap();
assert!(!s.drain_events().iter().any(|e| matches!(e, Event::Frontend { command, .. } if command == "renderQueue.notify")));
}
#[test]
fn save_current_preview_writes_a_movie() {
let (mut s, ex) = rq();