//! Render Queue: Composition ▸ Add to Render Queue, the queue's Render Settings / Output Module / //! 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::{ 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, without_project_extension, }; use effectcraft_project::render_templates::{RenderTemplates, TemplateKind, TemplateSlot}; use effectcraft_time::{FrameRate, Tick}; use serde_json::{Value, json}; 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> { not_rendering(s)?; if s.comp_for_queue(&Value::Null).is_ok() { Ok(()) } else { Err("select or open a composition".into()) } } fn not_rendering(s: &Session) -> std::result::Result<(), String> { if s.is_rendering() { Err("the render queue is rendering".into()) } else { Ok(()) } } fn has_items(s: &Session) -> std::result::Result<(), String> { not_rendering(s)?; if s.project.render_queue.is_empty() { Err("the render queue is empty".into()) } else { Ok(()) } } fn has_queued(s: &Session) -> std::result::Result<(), String> { not_rendering(s)?; if s.exporter.is_none() { return Err("export is not available in this build".into()); } if s.project.render_queue.iter().any(RenderQueueItem::is_queued) { Ok(()) } else { Err("nothing is queued".into()) } } fn rendering(s: &Session) -> std::result::Result<(), String> { if s.is_rendering() { Ok(()) } else { Err("not rendering".into()) } } /// Index of the item named by `item` (id) or `index` (1-based). fn item_index(s: &Session, p: &Value, cmd: &str) -> Result { let q = &s.project.render_queue; if let Some(id) = p.get("item").and_then(Value::as_u64) { return q.iter().position(|i| i.id == id).ok_or_else(|| bad(cmd, format!("no render queue item {id}"))); } if let Some(n) = p.get("index").and_then(Value::as_u64) { return (n as usize).checked_sub(1).filter(|i| *i < q.len()).ok_or_else(|| bad(cmd, format!("no render queue item #{n}"))); } if q.len() == 1 { return Ok(0); } Err(bad(cmd, "pass `item` (id) or `index` (1-based)")) } fn time_p(p: &Value, k: &str) -> Option { f_p(p, k).map(Tick::from_seconds_f64) } /// Parse an enum parameter. fn enum_p(p: &Value, k: &str, cmd: &str, parse: fn(&str) -> Option, help: &str) -> Result> { 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 { 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")), }; } 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|")), }; } Some(Value::Number(n)) => { rs.resolution = n.as_f64().unwrap_or(1.0).clamp(0.01, 4.0); } _ => {} } 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, "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")), }; } 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) }; } if let TimeSpan::Custom { start, end } = rs.time_span && end <= start { return Err(bad(cmd, "end must be after start")); } 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"))?; } match p.get("frameRate") { Some(Value::Null) => { rs.frame_rate = None; } 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)); } None => {} } 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 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(); } /// The `format` parameter's values. const FORMATS: &str = "format: h264|hevc|av1|prores|uncompressed|webm|png|jpeg|tiff|exr|gif|wav|aiff"; /// 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 { 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, FORMATS))?; om.set_format(f); } // Before Channels: the WebM codec decides whether alpha can be written, so an explicit // `channels: rgba` with `webmCodec: av1` is refused rather than dropped (#166). any |= apply_codec_options(om, p, cmd)?; 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, "alpha" | "alphaonly" => Channels::Alpha, _ => return Err(bad(cmd, "channels: rgb|rgba|alpha")), }; if om.channels == Channels::Rgba && !om.supports_alpha() { let what = match om.format { OutputFormat::WebM => format!("{} WebM has no alpha channel (VP9 WebM has)", om.webm_codec.label()), f => format!("{} has no alpha channel", f.label()), }; return Err(bad(cmd, what)); } } 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; } if let Some(b) = f_p(p, "bitrate") { 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"))?; } 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 { 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")), }; } if let Some(r) = p.get("sampleRate").and_then(Value::as_u64) { om.audio_sample_rate = (r as u32).clamp(8_000, 192_000); } 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; } if let Some(l) = b_p(p, "includeProjectLink") { om.include_project_link = l; } // (Without the `.ecproj` a save dialog filtered to projects appended, #293.) if let Some(o) = str_p(p, "output").or(str_p(p, "path")).map(without_project_extension) { om.output = o.to_string(); // Only an extension the format doesn't write changes it: `.mp4` is H.264's, HEVC's and AV1's. if let Some(f) = OutputFormat::from_path(o).filter(|f| f.extension() != om.format.extension() && !o.contains("[fileExtension]")) { if p.get("format").is_some() { // An explicit format wins and the extension follows it, as choosing a format in // the Output Module renames Output To (#154: `--format hevc --out x.mp4`). om.set_format(om.format); } else { // `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; } } // The format's extension exactly once (`Comp 2.mov.mov` → `Comp 2.mov`). om.output = om.format.with_extension(&om.output); } 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) } /// HEVC / AV1 codec options, the WebM video codec and the Opus audio options. fn apply_codec_options(om: &mut OutputModule, p: &Value, cmd: &str) -> Result { use effectcraft_project::render_queue::{CodecProfile, OpusApplication, RateControlMode, VideoCodecOptions, WebmVideoCodec}; let mut any = false; if let Some(c) = str_p(p, "webmCodec") { om.webm_codec = match c.to_ascii_lowercase().as_str() { "vp9" => WebmVideoCodec::Vp9, "av1" => WebmVideoCodec::Av1, _ => return Err(bad(cmd, "webmCodec: vp9|av1")), }; if !om.supports_alpha() { om.channels = Channels::Rgb; } any = true; } if let Some(v) = str_p(p, "profile") { om.codec.profile = CodecProfile::from_name(v).ok_or_else(|| bad(cmd, "profile: main|main10"))?; any = true; } match p.get("level") { Some(Value::Null) => { om.codec.level = None; any = true; } Some(v) => { let s = match v { Value::String(s) => s.clone(), other => other.to_string(), }; om.codec.level = VideoCodecOptions::parse_level(&s).ok_or_else(|| bad(cmd, "level: auto|null or a level like 4.1"))?; any = true; } None => {} } if let Some(v) = str_p(p, "rateControl") { om.codec.rate_control = RateControlMode::from_name(v).ok_or_else(|| bad(cmd, "rateControl: bitrate|quality"))?; any = true; } if let Some(q) = f_p(p, "quality") { om.codec.quality = q.clamp(1.0, 100.0) as u8; } if let Some(b) = f_p(p, "audioBitrate") { om.opus_bitrate_kbps = b.clamp(6.0, 510.0) as u32; any = true; } if let Some(a) = str_p(p, "opusApplication") { om.opus_application = match a.to_ascii_lowercase().as_str() { "audio" | "music" => OpusApplication::Audio, "voip" | "voice" | "speech" => OpusApplication::Voip, _ => return Err(bad(cmd, "opusApplication: audio|voice")), }; any = true; } Ok(any) } /// `module` (1-based; 1 = the first output module). fn module_p(s: &Session, i: usize, p: &Value, cmd: &str) -> Result { let n = p.get("module").and_then(Value::as_u64).unwrap_or(1) as usize; let have = s.project.render_queue[i].extra_outputs.len() + 1; if n == 0 || n > have { return Err(bad(cmd, format!("module must be 1..={have}"))); } Ok(n - 1) } fn module_ref(it: &RenderQueueItem, m: usize) -> &OutputModule { if m == 0 { &it.output } else { &it.extra_outputs[m - 1] } } fn module_mut(it: &mut RenderQueueItem, m: usize) -> &mut OutputModule { if m == 0 { &mut it.output } else { &mut it.extra_outputs[m - 1] } } /// 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.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() { 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())); let extra: Vec = it .extra_outputs .iter() .map(|om| { let mut c = it.clone(); c.output = om.clone(); json!({"summary": om.summary(), "outputPath": s.resolve_output(&c)}) }) .collect(); 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)); } v } fn add(s: &mut Session, p: &Value) -> Result { let cid = s.comp_for_queue(p)?; 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", FORMATS))?; if f != it.output.format { it.output = OutputModule::for_format(f); } } apply_settings(&mut it.settings, &templates, p, "renderQueue.add")?; // "template" belongs to Render Settings on add, not Output Module. let mut output_params = p.clone(); if let Some(object) = output_params.as_object_mut() { object.remove("template"); } apply_output(&mut it.output, &templates, s.state.region_of_interest, &output_params, "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; proj.render_queue.push(it); Ok(id) })?; let idx = s.project.render_queue.len() - 1; let mut v = item_json(s, &s.project.render_queue[idx], idx); v["item"] = json!(id); Ok(v) } fn remove(s: &mut Session, p: &Value) -> Result { let i = item_index(s, p, "renderQueue.remove")?; s.edit("Remove from Render Queue", None, |proj, _| { proj.render_queue.remove(i); Ok(()) })?; Ok(json!({"remaining": s.project.render_queue.len()})) } fn set_render(s: &mut Session, p: &Value) -> Result { let i = item_index(s, p, "renderQueue.setRender")?; let on = b_p(p, "render").unwrap_or(!s.project.render_queue[i].render); s.edit("Render Queue: Render", None, |proj, _| { let it = &mut proj.render_queue[i]; it.render = on; it.status = if on { RenderStatus::Queued } else { RenderStatus::Unqueued }; Ok(()) })?; Ok(json!({"render": on})) } fn set_render_settings(s: &mut Session, p: &Value) -> Result { let i = item_index(s, p, "renderQueue.setRenderSettings")?; let mut rs = s.project.render_queue[i].settings.clone(); if !apply_settings(&mut rs, &s.project.render_templates, p, "renderQueue.setRenderSettings")? { return Err(bad("renderQueue.setRenderSettings", "nothing to change")); } s.edit("Render Settings", None, |proj, _| { let it = &mut proj.render_queue[i]; it.settings = rs; requeue(it); Ok(()) })?; Ok(item_json(s, &s.project.render_queue[i], i)) } fn set_output_module(s: &mut Session, p: &Value) -> Result { let i = item_index(s, p, "renderQueue.setOutputModule")?; let m = module_p(s, i, p, "renderQueue.setOutputModule")?; let mut om = module_ref(&s.project.render_queue[i], m).clone(); let changed = apply_output(&mut om, &s.project.render_templates, s.state.region_of_interest, p, "renderQueue.setOutputModule")?; // When aspect is locked, an explicit height-only edit must drive the size. // frame_size() derives height from width; calculate the matching width from the // cropped render bounds before storing the updated module. let width_given = p.get("resizeWidth").is_some() || p.get("resize").and_then(Value::as_object).is_some_and(|r| r.contains_key("width") || r.contains_key("preset")); let height_given = p.get("resizeHeight").is_some() || p.get("resize").and_then(Value::as_object).is_some_and(|r| r.contains_key("height")); if om.resize.enabled && om.resize.lock_aspect && height_given && !width_given { let item = &s.project.render_queue[i]; let comp = s.project.comp(item.comp).ok_or(EngineError::NoComp)?; let (w, h) = item.settings.output_size(comp); let mut crop_only = om.clone(); crop_only.resize.enabled = false; let (cw, ch) = crop_only.frame_size(w, h, item.settings.resolution.clamp(0.01, 4.0)); om.resize.width = ((om.resize.height as f64 * cw as f64 / ch as f64).round() as u32).clamp(1, 30_000); } 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(()) })?; Ok(item_json(s, &s.project.render_queue[i], i)) } fn set_output(s: &mut Session, p: &Value) -> Result { let i = item_index(s, p, "renderQueue.setOutput")?; let path = str_p(p, "path").or(str_p(p, "output")).ok_or_else(|| bad("renderQueue.setOutput", "pass `path` (file path or template)"))?; let m = module_p(s, i, p, "renderQueue.setOutput")?; let mut om = module_ref(&s.project.render_queue[i], m).clone(); 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; requeue(it); Ok(()) })?; Ok(item_json(s, &s.project.render_queue[i], i)) } fn move_item(s: &mut Session, p: &Value) -> Result { let i = item_index(s, p, "renderQueue.move")?; let n = s.project.render_queue.len(); let to = p.get("to").and_then(Value::as_u64).ok_or_else(|| bad("renderQueue.move", "pass `to` (1-based position)"))? as usize; let to = to.clamp(1, n) - 1; s.edit("Reorder Render Queue", None, |proj, _| { let it = proj.render_queue.remove(i); proj.render_queue.insert(to, it); Ok(()) })?; Ok(json!({"index": to + 1})) } fn duplicate(s: &mut Session, p: &Value) -> Result { let i = item_index(s, p, "renderQueue.duplicate")?; let id = s.edit("Duplicate Render Item", None, |proj, _| { let mut it = proj.render_queue[i].clone(); it.id = proj.render_queue.iter().map(|i| i.id).max().unwrap_or(0) + 1; it.render = true; it.status = RenderStatus::Queued; it.started = None; it.render_time = None; it.last_output = None; let id = it.id; proj.render_queue.insert(i + 1, it); Ok(id) })?; Ok(json!({"item": id, "index": i + 2})) } fn render(s: &mut Session, p: &Value) -> Result { let wait = b_p(p, "wait").unwrap_or(true); // Settings ▸ Project ▸ Auto-Save ▸ Save When Starting Render Queue. if s.prefs.auto_save.enabled && s.prefs.auto_save.save_on_render_start && s.is_dirty() && let Err(e) = s.autosave_now() { log::warn!("{e}"); } let ids = s.start_render(wait).map_err(EngineError::Other)?; let items: Vec = s.project.render_queue.iter().enumerate().filter(|(_, i)| ids.contains(&i.id)).map(|(k, i)| item_json(s, i, k)).collect(); Ok(json!({"rendering": s.is_rendering(), "items": items})) } fn stop(s: &mut Session, _: &Value) -> Result { Ok(json!({"stopped": s.stop_render()})) } fn list(s: &mut Session, _: &Value) -> Result { s.poll_render(); let items: Vec = s.project.render_queue.iter().enumerate().map(|(k, i)| item_json(s, i, k)).collect(); Ok(json!({"items": items, "rendering": s.is_rendering(), "progress": s.render_progress()})) } fn formats(s: &mut Session, _: &Value) -> Result { let avail = s.exporter.as_ref().map(|e| e.formats()).unwrap_or_default(); let v: Vec = OutputFormat::ALL .iter() .map(|f| { json!({ "id": format!("{f:?}"), "label": f.label(), "extension": f.extension(), "sequence": f.is_sequence(), "alpha": f.supports_alpha(), "audio": f.supports_audio(), "available": avail.contains(f), }) }) .collect(); use effectcraft_project::render_queue::{CodecProfile, VideoCodecOptions}; let level = |l: &u8| format!("{}.{}", l / 10, l % 10); Ok(json!({ "formats": v, "proresProfiles": ProResProfile::ALL.iter().map(|p| p.label()).collect::>(), "codecProfiles": CodecProfile::ALL.iter().map(|p| p.label()).collect::>(), "hevcLevels": VideoCodecOptions::HEVC_LEVELS.iter().map(level).collect::>(), "av1Levels": VideoCodecOptions::AV1_LEVELS.iter().map(level).collect::>(), "webmCodecs": ["vp9", "av1"], "opusApplications": ["audio", "voice"], })) } /// `item?|index?`, else the last item (the queue's newest entry). fn item_or_last(s: &Session, p: &Value, cmd: &str) -> Result { if p.get("item").is_some() || p.get("index").is_some() { return item_index(s, p, cmd); } s.project.render_queue.len().checked_sub(1).ok_or_else(|| bad(cmd, "the render queue is empty")) } /// Composition ▸ Add Output Module: another output module on a render item (the same frames are /// encoded once more). fn add_output_module(s: &mut Session, p: &Value) -> Result { let i = item_or_last(s, p, "render.addOutputModule")?; let it = &s.project.render_queue[i]; let n = it.extra_outputs.len() + 2; let mut om = it.output.clone(); if let Some(f) = str_p(p, "format") { let f = OutputFormat::from_name(f).ok_or_else(|| bad("render.addOutputModule", FORMATS))?; om = OutputModule::for_format(f); } if str_p(p, "output").is_none() && str_p(p, "path").is_none() { // Same name with a module suffix so the files don't collide. om.output = match om.output.rfind('.') { Some(dot) => format!("{}_{n}{}", &om.output[..dot], &om.output[dot..]), None => format!("{}_{n}", om.output), }; } apply_output(&mut om, &s.project.render_templates, s.state.region_of_interest, p, "render.addOutputModule")?; s.edit("Add Output Module", None, |proj, _| { let it = &mut proj.render_queue[i]; it.extra_outputs.push(om); requeue(it); Ok(()) })?; let mut v = item_json(s, &s.project.render_queue[i], i); v["module"] = json!(n); Ok(v) } /// Composition ▸ Pre-render…: queue the comp with a lossless-with-alpha module whose post-render /// action imports the result and replaces the comp's uses. fn pre_render(s: &mut Session, p: &Value) -> Result { let cid = s.comp_for_queue(p)?; let mut it = RenderQueueItem::new(0, cid); 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, &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; 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) } /// Composition ▸ Save Current Preview…: render the work area of the comp straight to a movie file. fn save_current_preview(s: &mut Session, p: &Value) -> Result { let path = str_p(p, "path").ok_or_else(|| bad("render.saveCurrentPreview", "missing `path`"))?.to_string(); let cid = comp_id(s, p)?; let format = OutputFormat::from_path(&path).unwrap_or(OutputFormat::H264); let exporter = s.exporter.clone().ok_or_else(|| EngineError::Other("export is not available in this build".into()))?; if !exporter.formats().contains(&format) { return Err(EngineError::Other(format!("{} export is not available", format.label()))); } 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(), 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)?; s.toast(format!("Saved preview to {}", r.path)); Ok(serde_json::to_value(&r).unwrap_or_default()) } // ---------------------------------------------------------------- templates (Edit ▸ Templates) fn kind_p(p: &Value, cmd: &str) -> Result { 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 = 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 = 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::>().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::>(), "resizePresets": RESIZE_PRESETS.iter().map(|(n, w, h)| json!({"name": n, "width": w, "height": h})).collect::>(), }) } fn templates(s: &mut Session, _: &Value) -> Result { 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 { 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, ¶ms, 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, ¶ms, 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 { 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 { 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 { 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 { 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 { 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 { const C: &str = "renderQueue.setOverflowFolders"; let folders: Vec = 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 { vec![ cmd!( "renderQueue.add", "Add to Render Queue", ["Composition"], ["Cmd+M", "Cmd+Shift+/"], "{comp?: id|name, template?: Render Settings template, format?: h264|hevc|av1|prores|uncompressed|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?, profile?, level?, rateControl?, webmCodec?, audioBitrate?, opusApplication?, crop?, resize?, includeProjectLink?, audio?: auto|on|off, sampleRate?, audioChannels?, audioFormat?, loop?: bool (values as in setRenderSettings / setOutputModule)}", 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?, 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 ), cmd!( "renderQueue.setOutputModule", "Output Module Settings...", [], None, "{item?|index?, module?: n (1 = first), template?: name, format?: h264|hevc|av1|prores|uncompressed|webm|png|jpeg|tiff|exr|gif|wav|aiff, channels?: rgb|rgba|alpha, color?: straight|premultiplied, quality?: 1-100, bitrate?: kbps, webmBitrate?: bool, keyframeInterval?: frames (0 = auto), proresProfile?: proxy|lt|standard|hq|4444|4444xq, profile?: main|main10 (HEVC/AV1), level?: auto|4.1, rateControl?: bitrate|quality, webmCodec?: vp9|av1, audioBitrate?: Opus kbps, opusApplication?: audio|voice, 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 ), cmd!( "renderQueue.setOutput", "Output To...", [], None, "{item?|index?, module?: n, path: file path or template like [compName].[fileExtension]}", has_items, set_output ), cmd!( "render.addOutputModule", "Add Output Module", ["Composition"], None, "{item?|index? (default: the last item), format?, output?, channels?, quality?, bitrate?, proresProfile?, audio?}", has_items, add_output_module ), cmd!("render.preRender", "Pre-render...", ["Composition"], None, "{comp?, output?: path|template, format?}", can_add, pre_render), cmd!("render.saveCurrentPreview", "Save Current Preview...", ["Composition"], None, "{comp?, path}", has_comp, save_current_preview), 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.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), ] }