mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-31 13:37:37 -06:00
M10.2: Render Queue completeness (render settings, output module, templates, logs, overflow, notify)
Render Settings: field render with 3:2 pulldown phases, effects/solo/guide layer/color depth/frame blending/motion blur overrides, custom time sampling (duration), storage overflow through a quota hook. Output Module: crop (edges or region of interest), resize (presets, lock aspect, quality), straight/premultiplied/alpha-only channels, post-render actions, include project link, audio channels and PCM formats, WebM bitrate and key frame interval. Render Settings / Output Module templates with built-ins and defaults per slot (Edit > Templates dialogs), render logs next to the output, Notify on completion. All through renderQueue.* commands and persisted in the project. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
b26fbb6247
commit
1242a775fe
Generated
+1
@@ -1236,6 +1236,7 @@ name = "effectcraft-export"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-keyframe",
|
||||
"effectcraft-media",
|
||||
"effectcraft-opusenc",
|
||||
"effectcraft-project",
|
||||
|
||||
@@ -136,8 +136,11 @@ tools, and Front, Top, Left and Custom views.
|
||||
## Export
|
||||
|
||||
A Render Queue like After Effects', with Render Settings and Output Modules: **H.264** MP4 and
|
||||
**ProRes** MOV (Proxy to 4444 XQ with alpha) with audio, PNG, JPEG, TIFF and 32-bit EXR
|
||||
sequences, and animated GIF. The same queue runs from the command line. Every encoder is
|
||||
**ProRes** MOV (Proxy to 4444 XQ with alpha) with audio, **WebM** (VP9 with inter frames and
|
||||
alpha, Opus), PNG, JPEG, TIFF and 32-bit EXR sequences, animated GIF and WAV/AIFF. Field
|
||||
rendering with 3:2 pulldown, effect/solo/guide/depth overrides, crop, region of interest and
|
||||
resize, Render Settings and Output Module templates with defaults, post-render actions, storage
|
||||
overflow and render logs. The same queue runs from the command line. Every encoder is
|
||||
FilmCraft's pure-Rust code; there is no FFmpeg inside.
|
||||
|
||||
**Lottie** goes both ways: File ▸ Export ▸ Lottie JSON… writes a composition (precomps, shape,
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -499,13 +698,202 @@ 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(), accel: s.accel.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,
|
||||
storage: s.storage_quota.as_deref(),
|
||||
label: "Save Current Preview".into(),
|
||||
};
|
||||
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<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, ¶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<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!(
|
||||
@@ -513,7 +901,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
|
||||
),
|
||||
@@ -524,7 +912,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
|
||||
),
|
||||
@@ -533,7 +921,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
|
||||
),
|
||||
@@ -562,6 +950,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),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -275,6 +275,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.
|
||||
@@ -357,6 +359,7 @@ impl Default for Session {
|
||||
expr_check: None,
|
||||
importer: None,
|
||||
exporter: None,
|
||||
storage_quota: None,
|
||||
render_job: None,
|
||||
track_job: None,
|
||||
mask_job: None,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
/// What an export wrote.
|
||||
@@ -39,6 +43,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).
|
||||
@@ -127,6 +137,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.
|
||||
items: Vec<Vec<(RenderQueueItem, String)>>,
|
||||
}
|
||||
@@ -143,7 +154,15 @@ pub(crate) fn run_items(
|
||||
shared: &JobShared,
|
||||
notify: &mut dyn FnMut(&JobShared),
|
||||
) {
|
||||
let work = Work { project: s.project.clone(), footage: s.footage.clone(), expr: s.expr.clone(), accel: s.accel.clone(), exporter, items };
|
||||
let work = Work {
|
||||
project: s.project.clone(),
|
||||
footage: s.footage.clone(),
|
||||
expr: s.expr.clone(),
|
||||
accel: s.accel.clone(),
|
||||
exporter,
|
||||
storage: s.storage_quota.clone(),
|
||||
items,
|
||||
};
|
||||
run_work(work, shared, notify);
|
||||
}
|
||||
|
||||
@@ -174,7 +193,18 @@ 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 job = ExportJob { project: &w.project, footage: w.footage.as_ref(), expr: w.expr.as_deref(), accel: w.accel.as_deref(), item: mitem, path };
|
||||
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(),
|
||||
expr: w.expr.as_deref(),
|
||||
accel: w.accel.as_deref(),
|
||||
item: mitem,
|
||||
path,
|
||||
storage: w.storage.as_deref(),
|
||||
label: format!("#{index} {name} (output module {})", mi + 1),
|
||||
};
|
||||
let rr = w.exporter.export(&job, &mut |done, total| {
|
||||
{
|
||||
let mut s = lock(&shared.state);
|
||||
@@ -235,8 +265,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);
|
||||
@@ -329,7 +358,15 @@ impl Session {
|
||||
self.poll_render();
|
||||
return Ok(ids);
|
||||
}
|
||||
let work = Work { project: self.project.clone(), footage: self.footage.clone(), expr: self.expr.clone(), accel: self.accel.clone(), exporter, items };
|
||||
let work = Work {
|
||||
project: self.project.clone(),
|
||||
footage: self.footage.clone(),
|
||||
expr: self.expr.clone(),
|
||||
accel: self.accel.clone(),
|
||||
exporter,
|
||||
storage: self.storage_quota.clone(),
|
||||
items,
|
||||
};
|
||||
let shared = Arc::new(JobShared::default());
|
||||
if wait {
|
||||
run_work(work, &shared, &mut |_| {});
|
||||
@@ -426,6 +463,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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -32,3 +32,4 @@ effectcraft-media = { workspace = true }
|
||||
effectcraft-color = { workspace = true }
|
||||
filmcraft-opus = { workspace = true }
|
||||
filmcraft-vp9 = { workspace = true }
|
||||
effectcraft-keyframe = { workspace = true }
|
||||
|
||||
+58
-22
@@ -4,12 +4,12 @@
|
||||
use std::io::Write;
|
||||
|
||||
use effectcraft_project::Comp;
|
||||
use effectcraft_project::render_queue::{Channels, OutputFormat, ProResProfile};
|
||||
use effectcraft_project::render_queue::{AudioFormat, Channels, OutputFormat, ProResProfile};
|
||||
use effectcraft_time::{FrameRate, TICKS_PER_SECOND, Tick};
|
||||
use filmcraft_isobmff::{Brand, FourCc, Mp4Writer, PcmConfig, SampleEntry, TrackConfig, WriteSample, WriterOptions};
|
||||
use rayon::prelude::*;
|
||||
|
||||
use crate::{ExportError, Job, Report, Result, State, batch_size, render_frame, rgba8, wants_audio};
|
||||
use crate::{Cx, ExportError, Report, Result, State, batch_size, wants_audio};
|
||||
|
||||
fn enc(e: impl std::fmt::Display) -> ExportError {
|
||||
ExportError::Encode(e.to_string())
|
||||
@@ -208,29 +208,60 @@ enum Audio {
|
||||
Pcm,
|
||||
}
|
||||
|
||||
const AUDIO_CHANNELS: u32 = 2;
|
||||
fn deinterleave(buf: &[f32], channels: usize) -> Vec<Vec<f32>> {
|
||||
(0..channels).map(|c| buf.chunks_exact(channels).map(|p| p[c]).collect()).collect()
|
||||
}
|
||||
|
||||
fn deinterleave(buf: &[f32]) -> [Vec<f32>; 2] {
|
||||
let l = buf.chunks_exact(2).map(|p| p[0]).collect();
|
||||
let r = buf.chunks_exact(2).map(|p| p[1]).collect();
|
||||
[l, r]
|
||||
/// The comp's audio over `n` samples from `start`, interleaved with the module's channel count
|
||||
/// (mono = the average of left and right).
|
||||
pub(crate) fn mix(cx: &Cx, start: effectcraft_time::Tick, n: usize, sr: u32) -> Vec<f32> {
|
||||
let st = effectcraft_render::audio::mix_comp(&cx.project, cx.footage, cx.expr, cx.comp, start, n, sr);
|
||||
if cx.output.audio_channels == 1 { st.chunks_exact(2).map(|p| (p[0] + p[1]) * 0.5).collect() } else { st }
|
||||
}
|
||||
|
||||
/// PCM bytes of samples in `fmt` (little or big endian).
|
||||
pub(crate) fn pcm_bytes(buf: &[f32], fmt: AudioFormat, big_endian: bool) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(buf.len() * 4);
|
||||
for &s in buf {
|
||||
let s = s.clamp(-1.0, 1.0);
|
||||
match fmt {
|
||||
AudioFormat::S16 => {
|
||||
let v = (s * 32767.0).round() as i16;
|
||||
out.extend_from_slice(&if big_endian { v.to_be_bytes() } else { v.to_le_bytes() });
|
||||
}
|
||||
AudioFormat::S24 => {
|
||||
let v = (s * 8_388_607.0).round() as i32;
|
||||
let b = v.to_le_bytes();
|
||||
if big_endian {
|
||||
out.extend_from_slice(&[b[2], b[1], b[0]]);
|
||||
} else {
|
||||
out.extend_from_slice(&b[..3]);
|
||||
}
|
||||
}
|
||||
AudioFormat::F32 => out.extend_from_slice(&if big_endian { s.to_be_bytes() } else { s.to_le_bytes() }),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- the movie
|
||||
|
||||
type Writer<'f, 's> = Mp4Writer<&'f mut crate::out::Out<'s>>;
|
||||
|
||||
pub(crate) fn movie(job: &Job, comp: &Comp, w: u32, h: u32, st: &mut State) -> Result<Report> {
|
||||
pub(crate) fn movie(job: &Cx, comp: &Comp, w: u32, h: u32, st: &mut State) -> Result<Report> {
|
||||
let rate = job.settings.rate(comp);
|
||||
let fmt = job.output.format;
|
||||
let channels = if fmt == OutputFormat::ProRes { job.output.channels } else { Channels::Rgb };
|
||||
let channels = match job.output.channels {
|
||||
Channels::Rgba if fmt != OutputFormat::ProRes => Channels::Rgb,
|
||||
c => c,
|
||||
};
|
||||
let mut venc: Box<dyn VideoEncoder> = match fmt {
|
||||
OutputFormat::H264 => Box::new(H264::new(w, h, rate, job.output.bitrate_kbps)?),
|
||||
_ => Box::new(ProRes::new(w, h, job.output.prores_profile, channels)),
|
||||
_ => Box::new(ProRes::new(w, h, job.output.prores_profile, if channels == Channels::Alpha { Channels::Rgb } else { channels })),
|
||||
};
|
||||
let brand = if fmt == OutputFormat::H264 { Brand::Mp4 } else { Brand::Mov };
|
||||
let batch = batch_size();
|
||||
let render = |k: u64| -> Vec<u8> { rgba8(&render_frame(job, comp, k), comp, channels, w, h) };
|
||||
let render = |k: u64| -> Vec<u8> { job.pixels(&job.frame(comp, k), comp, channels, w, h) };
|
||||
|
||||
// Encode the first batch before creating tracks: encoders finalise their config on frame 1.
|
||||
let first_end = batch.min(st.total);
|
||||
@@ -242,7 +273,8 @@ pub(crate) fn movie(job: &Job, comp: &Comp, w: u32, h: u32, st: &mut State) -> R
|
||||
drop(first);
|
||||
st.advance(first_end)?;
|
||||
|
||||
let mut file = crate::out::create(job.sink, job.path)?;
|
||||
let path = job.place(job.path, 1);
|
||||
let mut file = crate::out::create(job.sink, &path)?;
|
||||
let opts = WriterOptions::new(brand);
|
||||
let movie_ts = opts.movie_timescale.max(1) as i128;
|
||||
let mut mux: Writer = Mp4Writer::new(&mut file, opts).map_err(mux_err)?;
|
||||
@@ -257,15 +289,22 @@ pub(crate) fn movie(job: &Job, comp: &Comp, w: u32, h: u32, st: &mut State) -> R
|
||||
let vt = mux.add_track(vcfg).map_err(mux_err)?;
|
||||
let sr = job.output.audio_sample_rate.clamp(8_000, 192_000);
|
||||
let with_audio = wants_audio(job);
|
||||
let chans = if job.output.audio_channels == 1 { 1u32 } else { 2 };
|
||||
let afmt = job.output.audio_format;
|
||||
let mut audio = None;
|
||||
if with_audio {
|
||||
let (a, entry, start) = if brand == Brand::Mp4 {
|
||||
let e = filmcraft_aac::Encoder::new(filmcraft_aac::EncoderConfig::cbr(sr, AUDIO_CHANNELS as usize, 320_000)).map_err(enc)?;
|
||||
let entry = SampleEntry::aac(e.audio_specific_config(), AUDIO_CHANNELS, sr);
|
||||
let e = filmcraft_aac::Encoder::new(filmcraft_aac::EncoderConfig::cbr(sr, chans as usize, 160_000 * chans)).map_err(enc)?;
|
||||
let entry = SampleEntry::aac(e.audio_specific_config(), chans, sr);
|
||||
let priming = e.priming_samples() as i64;
|
||||
(Audio::Aac(Box::new(e)), entry, Some(priming))
|
||||
} else {
|
||||
let pcm = PcmConfig { bits: 16, float: false, big_endian: false, signed: true, channels: AUDIO_CHANNELS, sample_rate: sr as f64 };
|
||||
let (bits, float) = match afmt {
|
||||
AudioFormat::S16 => (16, false),
|
||||
AudioFormat::S24 => (24, false),
|
||||
AudioFormat::F32 => (32, true),
|
||||
};
|
||||
let pcm = PcmConfig { bits, float, big_endian: false, signed: true, channels: chans, sample_rate: sr as f64 };
|
||||
(Audio::Pcm, SampleEntry::pcm(pcm), None)
|
||||
};
|
||||
let mut c = TrackConfig::new(entry, sr);
|
||||
@@ -290,21 +329,18 @@ pub(crate) fn movie(job: &Job, comp: &Comp, w: u32, h: u32, st: &mut State) -> R
|
||||
}
|
||||
let n = (end - *cursor) as usize;
|
||||
let start = Tick(((*cursor as i128 * TICKS_PER_SECOND as i128) / sr as i128) as i64);
|
||||
let buf = effectcraft_render::audio::mix_comp(job.project, job.footage, job.expr, job.comp, start, n, sr);
|
||||
let buf = mix(job, start, n, sr);
|
||||
*cursor = end;
|
||||
match a {
|
||||
Audio::Aac(e) => {
|
||||
let planar = deinterleave(&buf);
|
||||
let planar = deinterleave(&buf, chans as usize);
|
||||
let refs: Vec<&[f32]> = planar.iter().map(Vec::as_slice).collect();
|
||||
for au in e.encode(&refs) {
|
||||
mux.write_sample(*at, WriteSample { data: &au, duration: 1024, composition_offset: 0, is_sync: true }).map_err(mux_err)?;
|
||||
}
|
||||
}
|
||||
Audio::Pcm => {
|
||||
let mut pcm = Vec::with_capacity(n * 4);
|
||||
for s in buf {
|
||||
pcm.extend_from_slice(&((s.clamp(-1.0, 1.0) * 32767.0).round() as i16).to_le_bytes());
|
||||
}
|
||||
let pcm = pcm_bytes(&buf, afmt, false);
|
||||
mux.write_sample(*at, WriteSample { data: &pcm, duration: n as u32, composition_offset: 0, is_sync: true }).map_err(mux_err)?;
|
||||
}
|
||||
}
|
||||
@@ -336,7 +372,7 @@ pub(crate) fn movie(job: &Job, comp: &Comp, w: u32, h: u32, st: &mut State) -> R
|
||||
let out = mux.finish().map_err(mux_err)?;
|
||||
out.flush().map_err(mux_err)?;
|
||||
let bytes = file.finish()?;
|
||||
Ok(Report { path: job.path.to_string(), frames: 0, width: w, height: h, seconds: 0.0, bytes, audio: with_audio })
|
||||
Ok(Report { path, frames: 0, width: w, height: h, seconds: 0.0, bytes, audio: with_audio, log: None, overflow: vec![] })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
+102
-68
@@ -2,13 +2,21 @@
|
||||
//!
|
||||
//! | Format | Container / files | Video | Alpha | Audio |
|
||||
//! |---|---|---|---|---|
|
||||
//! | H.264 | MP4 | FilmCraft H.264 High, 8-bit 4:2:0 BT.709 limited, VBR | no | AAC-LC (FilmCraft), stereo |
|
||||
//! | ProRes | QuickTime MOV | FilmCraft ProRes 422 Proxy/LT/422/HQ, 10-bit; 4444/4444 XQ 4:4:4 | 4444 (16-bit alpha coding) | 16-bit PCM, stereo |
|
||||
//! | H.264 | MP4 | FilmCraft H.264 High, 8-bit 4:2:0 BT.709 limited, VBR | no | AAC-LC (FilmCraft), mono/stereo |
|
||||
//! | ProRes | QuickTime MOV | FilmCraft ProRes 422 Proxy/LT/422/HQ, 10-bit; 4444/4444 XQ 4:4:4 | 4444 (16-bit alpha coding) | PCM 16/24-bit or 32-bit float, mono/stereo |
|
||||
//! | PNG / JPEG / TIFF sequence | one file per frame | 8-bit | PNG, TIFF | — |
|
||||
//! | OpenEXR sequence | one file per frame | 32-bit float, linear light, premultiplied | yes | — |
|
||||
//! | Animated GIF | GIF89a | 256-colour palette per frame (NeuQuant) | 1-bit | — |
|
||||
//! | WebM | WebM (Matroska) | VP9 profile 0 (`effectcraft-vp9enc`: key + inter frames, a key frame every 2 s, loop filter), 8-bit 4:2:0 | VP9 alpha (BlockAdditional) | Opus (`effectcraft-opusenc`), 48 kHz stereo |
|
||||
//! | WAV / AIFF | RIFF WAVE / AIFF | — | — | 16-bit PCM stereo (audio only) |
|
||||
//! | WAV / AIFF | RIFF WAVE / AIFF | — | — | PCM 16/24-bit (WAV also 32-bit float), mono/stereo (audio only) |
|
||||
//!
|
||||
//! Every frame goes through [`pipeline`]: the Render Settings overrides (Effects, Solo Switches,
|
||||
//! Guide Layers, Frame Blending, Motion Blur, Color Depth), field rendering (two fields half a
|
||||
//! frame apart woven line by line, with optional 3:2 pulldown from film frames at 4/5 of the
|
||||
//! rate), then the Output Module's Crop (edges or the region of interest) and Resize. Channels
|
||||
//! may be RGB, RGB + Alpha (straight or premultiplied) or the alpha matte alone. With Use Storage
|
||||
//! Overflow and a [`StorageQuota`] hook, files that don't fit go to the overflow folders. A
|
||||
//! render log (Errors Only / Plus Settings / Plus Per Frame Info) is written next to the output.
|
||||
//!
|
||||
//! Frames are rendered in parallel batches (rayon; one batch ≈ one frame per core) and handed to
|
||||
//! the encoder in order. Progress is reported after each batch through a callback that can cancel
|
||||
@@ -20,19 +28,21 @@
|
||||
|
||||
mod encode;
|
||||
mod out;
|
||||
mod pipeline;
|
||||
mod webm;
|
||||
|
||||
use web_time::Instant;
|
||||
|
||||
use effectcraft_project::render_queue::{AudioOutput, Channels, OutputFormat, OutputModule, RenderQuality, RenderSettings, sequence_path};
|
||||
use effectcraft_project::render_queue::{AudioOutput, Channels, OutputFormat, OutputModule, RenderLog, RenderQuality, RenderSettings, sequence_path};
|
||||
use effectcraft_project::{Comp, ItemId, Project};
|
||||
use effectcraft_raster::Image;
|
||||
use effectcraft_render::{ExprHost, FootageSource, RenderOpts, Renderer};
|
||||
use effectcraft_time::Tick;
|
||||
use effectcraft_render::{ExprHost, FootageSource};
|
||||
use rayon::prelude::*;
|
||||
|
||||
pub use effectcraft_project::render_queue;
|
||||
pub use effectcraft_project::render_queue::StorageQuota;
|
||||
pub use out::Sink;
|
||||
pub(crate) use pipeline::Cx;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ExportError {
|
||||
@@ -69,6 +79,21 @@ pub struct Job<'a> {
|
||||
pub path: &'a str,
|
||||
/// Hand finished files to this instead of writing them to disk (web downloads, tests).
|
||||
pub sink: Option<&'a Sink>,
|
||||
/// Render log, storage overflow.
|
||||
pub options: JobOptions<'a>,
|
||||
}
|
||||
|
||||
/// Job options beyond the Render Settings and Output Module.
|
||||
#[derive(Default)]
|
||||
pub struct JobOptions<'a> {
|
||||
/// The render log written next to the output.
|
||||
pub log: RenderLog,
|
||||
/// What the log calls the job (e.g. "#2 Main Comp").
|
||||
pub label: String,
|
||||
/// Free-space hook for Use Storage Overflow (`None`: never full).
|
||||
pub storage: Option<&'a dyn StorageQuota>,
|
||||
/// Overflow folders, in order.
|
||||
pub overflow: Vec<String>,
|
||||
}
|
||||
|
||||
/// Progress after a batch of frames.
|
||||
@@ -100,6 +125,10 @@ pub struct Report {
|
||||
pub seconds: f64,
|
||||
pub bytes: u64,
|
||||
pub audio: bool,
|
||||
/// The render log, when one was written.
|
||||
pub log: Option<String>,
|
||||
/// Files that went to an overflow folder.
|
||||
pub overflow: Vec<String>,
|
||||
}
|
||||
|
||||
/// Formats this build can write (all of them; kept as the hook for optional encoders).
|
||||
@@ -107,10 +136,9 @@ pub fn available_formats() -> Vec<OutputFormat> {
|
||||
OutputFormat::ALL.to_vec()
|
||||
}
|
||||
|
||||
/// The size of the written frames for a job (H.264 rounds to even).
|
||||
/// The size of the written frames for a job (crop, resize; H.264 rounds to even).
|
||||
pub fn output_size(comp: &Comp, settings: &RenderSettings, output: &OutputModule) -> (u32, u32) {
|
||||
let (w, h) = settings.output_size(comp);
|
||||
output.format.coded_size(w, h)
|
||||
output.output_size(comp, settings)
|
||||
}
|
||||
|
||||
/// Whether the job writes an audio track.
|
||||
@@ -125,19 +153,30 @@ pub fn wants_audio(job: &Job) -> bool {
|
||||
|
||||
/// Run the export (blocking). `progress` is called after every batch; returning `false` cancels.
|
||||
pub fn export(job: &Job, progress: &mut dyn FnMut(&Progress) -> bool) -> Result<Report> {
|
||||
let comp = job.project.comp(job.comp).ok_or(ExportError::NoComp)?;
|
||||
let t0 = Instant::now();
|
||||
let total = job.settings.frame_count(comp);
|
||||
let cx = Cx::new(job);
|
||||
let mut r = export_with(&cx, progress, t0);
|
||||
let log = cx.write_log(&r, t0.elapsed().as_secs_f64());
|
||||
if let Ok(rep) = &mut r {
|
||||
rep.log = log;
|
||||
rep.overflow = cx.overflowed.lock().unwrap_or_else(|e| e.into_inner()).clone();
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
fn export_with(cx: &Cx, progress: &mut dyn FnMut(&Progress) -> bool, t0: Instant) -> Result<Report> {
|
||||
let comp = cx.comp().ok_or(ExportError::NoComp)?;
|
||||
let total = cx.settings.frame_count(comp);
|
||||
let mut st = State { t0, total, done: 0, progress };
|
||||
st.advance(0)?;
|
||||
let (w, h) = output_size(comp, job.settings, job.output);
|
||||
let mut report = match job.output.format {
|
||||
OutputFormat::H264 | OutputFormat::ProRes => encode::movie(job, comp, w, h, &mut st)?,
|
||||
OutputFormat::Gif => gif_export(job, comp, w, h, &mut st)?,
|
||||
OutputFormat::WebM => webm::webm(job, comp, w, h, &mut st)?,
|
||||
OutputFormat::Wav => webm::audio_file(job, comp, false, &mut st)?,
|
||||
OutputFormat::Aiff => webm::audio_file(job, comp, true, &mut st)?,
|
||||
f if f.is_sequence() => sequence(job, comp, w, h, &mut st)?,
|
||||
let (w, h) = output_size(comp, cx.settings, cx.output);
|
||||
let mut report = match cx.output.format {
|
||||
OutputFormat::H264 | OutputFormat::ProRes => encode::movie(cx, comp, w, h, &mut st)?,
|
||||
OutputFormat::Gif => gif_export(cx, comp, w, h, &mut st)?,
|
||||
OutputFormat::WebM => webm::webm(cx, comp, w, h, &mut st)?,
|
||||
OutputFormat::Wav => webm::audio_file(cx, comp, false, &mut st)?,
|
||||
OutputFormat::Aiff => webm::audio_file(cx, comp, true, &mut st)?,
|
||||
f if f.is_sequence() => sequence(cx, comp, w, h, &mut st)?,
|
||||
f => return Err(ExportError::Unsupported(f.label().into())),
|
||||
};
|
||||
report.seconds = t0.elapsed().as_secs_f64();
|
||||
@@ -170,37 +209,8 @@ pub(crate) fn batch_size() -> u64 {
|
||||
rayon::current_num_threads().clamp(2, 16) as u64
|
||||
}
|
||||
|
||||
/// Render output frame `i` of the job at the settings' resolution.
|
||||
pub(crate) fn render_frame(job: &Job, comp: &Comp, i: u64) -> Image {
|
||||
let t: Tick = job.settings.frame_time(comp, i);
|
||||
let opts = RenderOpts {
|
||||
scale: job.settings.resolution.clamp(0.01, 4.0),
|
||||
motion_blur: job.settings.motion_blur,
|
||||
guides: false,
|
||||
draft: job.settings.quality == RenderQuality::Draft,
|
||||
// Output renders always look through the comp's active camera.
|
||||
view: None,
|
||||
backend: effectcraft_render::Backend::Auto,
|
||||
roi: None,
|
||||
proxy: job.settings.proxy_use,
|
||||
};
|
||||
let mut r = Renderer::new(job.project, job.footage, opts);
|
||||
r.expr = job.expr;
|
||||
r.accel = job.accel;
|
||||
r.comp_frame(job.comp, t)
|
||||
}
|
||||
|
||||
/// 8-bit RGBA of the requested channels (RGB: over the comp background, opaque; RGBA: straight),
|
||||
/// cropped/padded to `w`×`h`.
|
||||
pub(crate) fn rgba8(img: &Image, comp: &Comp, channels: Channels, w: u32, h: u32) -> Vec<u8> {
|
||||
let px = match channels {
|
||||
Channels::Rgb => img.to_rgba8_over(comp.background),
|
||||
Channels::Rgba => img.to_rgba8(),
|
||||
};
|
||||
fit(px, img.width, img.height, w, h)
|
||||
}
|
||||
|
||||
fn fit(px: Vec<u8>, iw: u32, ih: u32, w: u32, h: u32) -> Vec<u8> {
|
||||
/// Crop/pad 8-bit RGBA to `w`×`h` (codec rounding).
|
||||
pub(crate) fn fit(px: Vec<u8>, iw: u32, ih: u32, w: u32, h: u32) -> Vec<u8> {
|
||||
if iw == w && ih == h {
|
||||
return px;
|
||||
}
|
||||
@@ -217,10 +227,13 @@ fn first_frame_number(job: &Job, comp: &Comp) -> i64 {
|
||||
job.settings.first_frame(comp)
|
||||
}
|
||||
|
||||
fn sequence(job: &Job, comp: &Comp, w: u32, h: u32, st: &mut State) -> Result<Report> {
|
||||
let f0 = first_frame_number(job, comp);
|
||||
let fmt = job.output.format;
|
||||
let channels = if fmt.supports_alpha() { job.output.channels } else { Channels::Rgb };
|
||||
fn sequence(cx: &Cx, comp: &Comp, w: u32, h: u32, st: &mut State) -> Result<Report> {
|
||||
let f0 = first_frame_number(cx, comp);
|
||||
let fmt = cx.output.format;
|
||||
let channels = match cx.output.channels {
|
||||
Channels::Rgba if !fmt.supports_alpha() => Channels::Rgb,
|
||||
c => c,
|
||||
};
|
||||
let batch = batch_size();
|
||||
let mut bytes = 0u64;
|
||||
let mut i = 0;
|
||||
@@ -229,13 +242,18 @@ fn sequence(job: &Job, comp: &Comp, w: u32, h: u32, st: &mut State) -> Result<Re
|
||||
let written: Vec<Result<u64>> = (i..end)
|
||||
.into_par_iter()
|
||||
.map(|k| {
|
||||
let path = sequence_path(job.path, f0 + k as i64);
|
||||
if job.settings.skip_existing && out::exists(job.sink, &path) {
|
||||
let path = sequence_path(cx.path, f0 + k as i64);
|
||||
if cx.settings.skip_existing && out::exists(cx.sink, &path) {
|
||||
return Ok(0);
|
||||
}
|
||||
let img = render_frame(job, comp, k);
|
||||
let mut f = out::create(job.sink, &path)?;
|
||||
write_still(&mut f, fmt, &img, comp, channels, w, h, job.output.quality)?;
|
||||
let img = cx.frame(comp, k);
|
||||
// Encode in memory first: the storage hook decides where it fits.
|
||||
let mut buf = std::io::Cursor::new(Vec::new());
|
||||
write_still(cx, &mut buf, fmt, &img, comp, channels, w, h, cx.output.quality)?;
|
||||
let data = buf.into_inner();
|
||||
let path = cx.place(&path, data.len() as u64);
|
||||
let mut f = out::create(cx.sink, &path)?;
|
||||
std::io::Write::write_all(&mut f, &data).map_err(io)?;
|
||||
f.finish()
|
||||
})
|
||||
.collect();
|
||||
@@ -245,11 +263,21 @@ fn sequence(job: &Job, comp: &Comp, w: u32, h: u32, st: &mut State) -> Result<Re
|
||||
st.advance(end - i)?;
|
||||
i = end;
|
||||
}
|
||||
Ok(Report { path: sequence_path(job.path, f0), frames: 0, width: w, height: h, seconds: 0.0, bytes, audio: false })
|
||||
Ok(Report { path: sequence_path(cx.path, f0), frames: 0, width: w, height: h, seconds: 0.0, bytes, audio: false, log: None, overflow: vec![] })
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn write_still(file: &mut out::Out, fmt: OutputFormat, img: &Image, comp: &Comp, channels: Channels, w: u32, h: u32, quality: u8) -> Result<()> {
|
||||
fn write_still<W: std::io::Write + std::io::Seek>(
|
||||
cx: &Cx,
|
||||
file: &mut W,
|
||||
fmt: OutputFormat,
|
||||
img: &Image,
|
||||
comp: &Comp,
|
||||
channels: Channels,
|
||||
w: u32,
|
||||
h: u32,
|
||||
quality: u8,
|
||||
) -> Result<()> {
|
||||
use image::{ExtendedColorType, ImageEncoder, ImageFormat};
|
||||
match fmt {
|
||||
OutputFormat::ExrSequence => {
|
||||
@@ -260,6 +288,10 @@ fn write_still(file: &mut out::Out, fmt: OutputFormat, img: &Image, comp: &Comp,
|
||||
for x in 0..w {
|
||||
let p = if x < img.width && y < img.height { img.data[img.idx(x, y)] } else { [0.0; 4] };
|
||||
let (c, a) = match channels {
|
||||
Channels::Alpha => {
|
||||
let a = p[3].clamp(0.0, 1.0);
|
||||
([lin(a); 3], 1.0)
|
||||
}
|
||||
Channels::Rgba => {
|
||||
let a = p[3].clamp(0.0, 1.0);
|
||||
let s = |c: f32| if a > 0.0 { lin((c / a).clamp(0.0, 1.0)) * a } else { 0.0 };
|
||||
@@ -278,16 +310,17 @@ fn write_still(file: &mut out::Out, fmt: OutputFormat, img: &Image, comp: &Comp,
|
||||
image::DynamicImage::ImageRgba32F(buf).write_to(file, ImageFormat::OpenExr).map_err(|e| ExportError::Encode(e.to_string()))
|
||||
}
|
||||
OutputFormat::JpegSequence => {
|
||||
let px = rgba8(img, comp, Channels::Rgb, w, h);
|
||||
let px = cx.pixels(img, comp, if channels == Channels::Alpha { Channels::Alpha } else { Channels::Rgb }, w, h);
|
||||
let rgb: Vec<u8> = px.chunks_exact(4).flat_map(|p| [p[0], p[1], p[2]]).collect();
|
||||
image::codecs::jpeg::JpegEncoder::new_with_quality(file, quality.clamp(1, 100))
|
||||
.write_image(&rgb, w, h, ExtendedColorType::Rgb8)
|
||||
.map_err(|e| ExportError::Encode(e.to_string()))
|
||||
}
|
||||
OutputFormat::PngSequence | OutputFormat::TiffSequence => {
|
||||
let px = rgba8(img, comp, channels, w, h);
|
||||
let px = cx.pixels(img, comp, channels, w, h);
|
||||
let (data, ct) = match channels {
|
||||
Channels::Rgba => (px, ExtendedColorType::Rgba8),
|
||||
Channels::Alpha => (px.chunks_exact(4).map(|p| p[0]).collect(), ExtendedColorType::L8),
|
||||
Channels::Rgb => (px.chunks_exact(4).flat_map(|p| [p[0], p[1], p[2]]).collect(), ExtendedColorType::Rgb8),
|
||||
};
|
||||
if fmt == OutputFormat::PngSequence {
|
||||
@@ -301,11 +334,12 @@ fn write_still(file: &mut out::Out, fmt: OutputFormat, img: &Image, comp: &Comp,
|
||||
}
|
||||
}
|
||||
|
||||
fn gif_export(job: &Job, comp: &Comp, w: u32, h: u32, st: &mut State) -> Result<Report> {
|
||||
fn gif_export(job: &Cx, comp: &Comp, w: u32, h: u32, st: &mut State) -> Result<Report> {
|
||||
if w > u16::MAX as u32 || h > u16::MAX as u32 {
|
||||
return Err(ExportError::Unsupported("GIF frames are limited to 65535 px".into()));
|
||||
}
|
||||
let mut file = out::create(job.sink, job.path)?;
|
||||
let path = job.place(job.path, 1);
|
||||
let mut file = out::create(job.sink, &path)?;
|
||||
let mut enc = gif::Encoder::new(&mut file, w as u16, h as u16, &[]).map_err(|e| ExportError::Encode(e.to_string()))?;
|
||||
enc.set_repeat(if job.output.gif_loop { gif::Repeat::Infinite } else { gif::Repeat::Finite(0) }).map_err(|e| ExportError::Encode(e.to_string()))?;
|
||||
let rate = job.settings.rate(comp);
|
||||
@@ -320,8 +354,8 @@ fn gif_export(job: &Job, comp: &Comp, w: u32, h: u32, st: &mut State) -> Result<
|
||||
let frames: Vec<gif::Frame<'static>> = (i..end)
|
||||
.into_par_iter()
|
||||
.map(|k| {
|
||||
let img = render_frame(job, comp, k);
|
||||
let mut px = rgba8(&img, comp, job.output.channels, w, h);
|
||||
let img = job.frame(comp, k);
|
||||
let mut px = job.pixels(&img, comp, job.output.channels, w, h);
|
||||
gif::Frame::from_rgba_speed(w as u16, h as u16, &mut px, 10)
|
||||
})
|
||||
.collect();
|
||||
@@ -340,7 +374,7 @@ fn gif_export(job: &Job, comp: &Comp, w: u32, h: u32, st: &mut State) -> Result<
|
||||
}
|
||||
drop(enc);
|
||||
let bytes = file.finish()?;
|
||||
Ok(Report { path: job.path.to_string(), frames: 0, width: w, height: h, seconds: 0.0, bytes, audio: false })
|
||||
Ok(Report { path, frames: 0, width: w, height: h, seconds: 0.0, bytes, audio: false, log: None, overflow: vec![] })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
//! The frame pipeline shared by every output format: Render Settings overrides (effects, solo,
|
||||
//! guide layers, frame blending, motion blur), field rendering with 3:2 pulldown, colour depth,
|
||||
//! the Output Module's crop and resize, alpha handling, storage overflow and the render log.
|
||||
|
||||
use std::sync::Mutex;
|
||||
|
||||
use effectcraft_project::render_queue::{
|
||||
AlphaMode, Channels, ColorDepth, CurrentOrOff, EffectsMode, FrameSample, RenderLog, ResizeQuality, SwitchOverride, log_path,
|
||||
};
|
||||
use effectcraft_project::{Comp, Project};
|
||||
use effectcraft_raster::Image;
|
||||
use effectcraft_render::{RenderOpts, Renderer};
|
||||
use effectcraft_time::Tick;
|
||||
use web_time::Instant;
|
||||
|
||||
use crate::{Job, RenderQuality, out};
|
||||
|
||||
/// A job being exported: the job, the project with the Render Settings overrides applied, and
|
||||
/// per-frame timing for the render log.
|
||||
pub(crate) struct Cx<'a> {
|
||||
pub job: &'a Job<'a>,
|
||||
pub project: Project,
|
||||
/// (output frame, seconds) of every rendered frame (Plus Per Frame Info logs).
|
||||
pub frame_times: Option<Mutex<Vec<(u64, f64)>>>,
|
||||
/// Files written to an overflow folder.
|
||||
pub overflowed: Mutex<Vec<String>>,
|
||||
}
|
||||
|
||||
impl<'a> std::ops::Deref for Cx<'a> {
|
||||
type Target = Job<'a>;
|
||||
fn deref(&self) -> &Job<'a> {
|
||||
self.job
|
||||
}
|
||||
}
|
||||
|
||||
/// The project as the Render Settings render it: Effects All On / All Off, Solo Switches All
|
||||
/// Off, Frame Blending and Motion Blur "On for Checked Layers" (the composition switches on).
|
||||
pub(crate) fn apply_overrides(p: &mut Project, job: &Job) {
|
||||
let s = job.settings;
|
||||
let ids: Vec<_> = p.comps().map(|(i, _)| *i).collect();
|
||||
for id in ids {
|
||||
let Some(c) = p.comp_mut(id) else { continue };
|
||||
match s.frame_blending {
|
||||
SwitchOverride::Current => {}
|
||||
SwitchOverride::OnForChecked => c.enable_frame_blending = true,
|
||||
SwitchOverride::OffForAll => c.enable_frame_blending = false,
|
||||
}
|
||||
match s.motion_blur_override() {
|
||||
SwitchOverride::Current => {}
|
||||
SwitchOverride::OnForChecked => c.enable_motion_blur = true,
|
||||
SwitchOverride::OffForAll => c.enable_motion_blur = false,
|
||||
}
|
||||
for l in c.layers.iter_mut() {
|
||||
match s.effects {
|
||||
EffectsMode::Current => {}
|
||||
EffectsMode::AllOn => l.switches.effects = true,
|
||||
EffectsMode::AllOff => l.switches.effects = false,
|
||||
}
|
||||
if s.solo == CurrentOrOff::AllOff {
|
||||
l.switches.solo = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Cx<'a> {
|
||||
pub fn new(job: &'a Job<'a>) -> Cx<'a> {
|
||||
let mut project = job.project.clone();
|
||||
apply_overrides(&mut project, job);
|
||||
let frame_times = (job.options.log == RenderLog::PlusPerFrameInfo).then(|| Mutex::new(Vec::new()));
|
||||
Cx { job, project, frame_times, overflowed: Mutex::new(vec![]) }
|
||||
}
|
||||
|
||||
pub fn comp(&self) -> Option<&Comp> {
|
||||
self.project.comp(self.job.comp)
|
||||
}
|
||||
|
||||
fn render_at(&self, t: Tick) -> Image {
|
||||
let s = self.job.settings;
|
||||
let opts = RenderOpts {
|
||||
scale: s.resolution.clamp(0.01, 4.0),
|
||||
motion_blur: s.motion_blur_override() != SwitchOverride::OffForAll,
|
||||
guides: s.guide_layers == CurrentOrOff::Current,
|
||||
draft: s.quality == RenderQuality::Draft,
|
||||
// Output renders always look through the comp's active camera.
|
||||
view: None,
|
||||
backend: effectcraft_render::Backend::Auto,
|
||||
roi: None,
|
||||
proxy: s.proxy_use,
|
||||
};
|
||||
let mut r = Renderer::new(&self.project, self.job.footage, opts);
|
||||
r.expr = self.job.expr;
|
||||
r.accel = self.job.accel;
|
||||
r.comp_frame(self.job.comp, t)
|
||||
}
|
||||
|
||||
/// Output frame `i`: sampled (fields, pulldown), at the colour depth, cropped and resized.
|
||||
pub fn frame(&self, comp: &Comp, i: u64) -> Image {
|
||||
let t0 = Instant::now();
|
||||
let img = match self.job.settings.sample(comp, i) {
|
||||
FrameSample::Progressive(t) => self.render_at(t),
|
||||
FrameSample::Fields { first, second, upper_first } => {
|
||||
let a = self.render_at(first);
|
||||
if second == first { a } else { interleave(&a, &self.render_at(second), upper_first) }
|
||||
}
|
||||
};
|
||||
let mut img = img;
|
||||
// Color Depth: renders run at the project's depth; another depth quantises the frame.
|
||||
let depth = self.job.settings.color_depth;
|
||||
if depth != ColorDepth::Current
|
||||
&& let Some(levels) = depth.resolve(self.project.settings.bit_depth).levels()
|
||||
{
|
||||
img.quantize(levels);
|
||||
}
|
||||
let img = self.crop_resize(img);
|
||||
if let Some(ft) = &self.frame_times {
|
||||
ft.lock().unwrap_or_else(|e| e.into_inner()).push((i, t0.elapsed().as_secs_f64()));
|
||||
}
|
||||
img
|
||||
}
|
||||
|
||||
/// Output Module ▸ Crop, then Resize.
|
||||
fn crop_resize(&self, img: Image) -> Image {
|
||||
let om = self.job.output;
|
||||
let scale = self.job.settings.resolution.clamp(0.01, 4.0);
|
||||
let (t, l, b, r) = om.crop.edges(img.width, img.height, scale);
|
||||
let img = if (t, l, b, r) == (0, 0, 0, 0) { img } else { crop(&img, t, l, b, r) };
|
||||
if !om.resize.enabled {
|
||||
return img;
|
||||
}
|
||||
let (w, h) = om.frame_size(img.width, img.height, 1.0);
|
||||
if (w, h) == (img.width, img.height) {
|
||||
return img;
|
||||
}
|
||||
match om.resize.quality {
|
||||
ResizeQuality::Low => effectcraft_raster::warp::resample(&img, w, h),
|
||||
ResizeQuality::High => bicubic(&img, w, h),
|
||||
}
|
||||
}
|
||||
|
||||
/// 8-bit RGBA of the requested channels cropped/padded to `w`×`h` (codec rounding): RGB over
|
||||
/// the comp background (opaque), RGB + Alpha straight or premultiplied, or the alpha matte.
|
||||
pub fn pixels(&self, img: &Image, comp: &Comp, channels: Channels, w: u32, h: u32) -> Vec<u8> {
|
||||
let px = match channels {
|
||||
Channels::Rgb => img.to_rgba8_over(comp.background),
|
||||
Channels::Rgba => match self.job.output.alpha_mode {
|
||||
AlphaMode::Straight => img.to_rgba8(),
|
||||
AlphaMode::Premultiplied => premultiplied8(img),
|
||||
},
|
||||
Channels::Alpha => img
|
||||
.data
|
||||
.iter()
|
||||
.flat_map(|p| {
|
||||
let a = (p[3].clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
|
||||
[a, a, a, 255]
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
crate::fit(px, img.width, img.height, w, h)
|
||||
}
|
||||
|
||||
/// Where a file goes: `path`, or (Use Storage Overflow) the first overflow folder with room
|
||||
/// for `bytes` when the quota hook says the output volume is full.
|
||||
pub fn place(&self, path: &str, bytes: u64) -> String {
|
||||
let Some(q) = self.job.options.storage else { return path.to_string() };
|
||||
if !self.job.settings.storage_overflow || q.has_room(path, bytes) {
|
||||
return path.to_string();
|
||||
}
|
||||
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string());
|
||||
for dir in &self.job.options.overflow {
|
||||
let p = std::path::Path::new(dir).join(&name).to_string_lossy().to_string();
|
||||
if q.has_room(&p, bytes) {
|
||||
self.overflowed.lock().unwrap_or_else(|e| e.into_inner()).push(p.clone());
|
||||
return p;
|
||||
}
|
||||
}
|
||||
path.to_string()
|
||||
}
|
||||
|
||||
/// Write the render log next to the output, as the Log setting asks. Returns its path.
|
||||
pub fn write_log(&self, result: &Result<crate::Report, crate::ExportError>, seconds: f64) -> Option<String> {
|
||||
let level = self.job.options.log;
|
||||
// Errors Only: a log for failures (a user stop is not an error).
|
||||
if level == RenderLog::ErrorsOnly && matches!(result, Ok(_) | Err(crate::ExportError::Cancelled)) {
|
||||
return None;
|
||||
}
|
||||
let comp = self.comp();
|
||||
let path = log_path(self.job.path);
|
||||
let mut s = String::new();
|
||||
s.push_str("EffectCraft Render Log\n");
|
||||
s.push_str(&format!("Item: {}\n", if self.job.options.label.is_empty() { "(render)" } else { &self.job.options.label }));
|
||||
s.push_str(&format!("Output: {}\n", self.job.path));
|
||||
match result {
|
||||
Ok(r) => s.push_str(&format!("Result: Done ({} frames, {}×{}, {} bytes) in {seconds:.2} s\n", r.frames, r.width, r.height, r.bytes)),
|
||||
Err(e) => s.push_str(&format!("Result: Failed\nError: {e}\n")),
|
||||
}
|
||||
let over = self.overflowed.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if !over.is_empty() {
|
||||
s.push_str(&format!("Storage overflow: {} file(s) written to overflow folders (first: {})\n", over.len(), over[0]));
|
||||
}
|
||||
if level != RenderLog::ErrorsOnly {
|
||||
s.push_str("\nRender Settings:\n");
|
||||
for (k, v) in self.job.settings.describe(comp) {
|
||||
s.push_str(&format!(" {k}: {v}\n"));
|
||||
}
|
||||
s.push_str("\nOutput Module:\n");
|
||||
for (k, v) in self.job.output.describe() {
|
||||
s.push_str(&format!(" {k}: {v}\n"));
|
||||
}
|
||||
}
|
||||
if let (RenderLog::PlusPerFrameInfo, Some(ft), Some(c)) = (level, &self.frame_times, comp) {
|
||||
let mut v = ft.lock().unwrap_or_else(|e| e.into_inner()).clone();
|
||||
v.sort_by_key(|(i, _)| *i);
|
||||
s.push_str("\nFrames:\n");
|
||||
let f0 = self.job.settings.first_frame(c);
|
||||
for (i, secs) in v {
|
||||
let t = self.job.settings.frame_time(c, i);
|
||||
s.push_str(&format!(" Frame {} ({:.3} s): rendered in {:.1} ms\n", f0 + i as i64, t.seconds(), secs * 1000.0));
|
||||
}
|
||||
}
|
||||
let mut f = out::create(self.job.sink, &path).ok()?;
|
||||
std::io::Write::write_all(&mut f, s.as_bytes()).ok()?;
|
||||
f.finish().ok()?;
|
||||
Some(path)
|
||||
}
|
||||
}
|
||||
|
||||
/// Weave two fields: the dominant field's lines (even lines when `upper_first`) from `first`.
|
||||
pub(crate) fn interleave(first: &Image, second: &Image, upper_first: bool) -> Image {
|
||||
let mut out = first.clone();
|
||||
let w = out.width as usize;
|
||||
for y in 0..out.height as usize {
|
||||
let from_first = (y % 2 == 0) == upper_first;
|
||||
if !from_first && y < second.height as usize && second.width as usize == w {
|
||||
out.data[y * w..(y + 1) * w].copy_from_slice(&second.data[y * w..(y + 1) * w]);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Remove `t`/`l`/`b`/`r` pixels from the edges (negative values add transparent pixels).
|
||||
pub(crate) fn crop(img: &Image, t: i32, l: i32, b: i32, r: i32) -> Image {
|
||||
let w = (img.width as i32 - l - r).max(1) as u32;
|
||||
let h = (img.height as i32 - t - b).max(1) as u32;
|
||||
let mut out = Image::new(w, h);
|
||||
for y in 0..h as i32 {
|
||||
let sy = y + t;
|
||||
if sy < 0 || sy >= img.height as i32 {
|
||||
continue;
|
||||
}
|
||||
for x in 0..w as i32 {
|
||||
let sx = x + l;
|
||||
if sx >= 0 && sx < img.width as i32 {
|
||||
let o = out.idx(x as u32, y as u32);
|
||||
out.data[o] = img.data[img.idx(sx as u32, sy as u32)];
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Bicubic resize (Resize Quality: High).
|
||||
fn bicubic(src: &Image, w: u32, h: u32) -> Image {
|
||||
// Shrinking a lot: bilinear with its box pre-filter first, then bicubic for the last step.
|
||||
if src.width > w * 2 && src.height > h * 2 {
|
||||
let mid = effectcraft_raster::warp::resample(src, w * 2, h * 2);
|
||||
return bicubic(&mid, w, h);
|
||||
}
|
||||
let mut out = Image::new(w, h);
|
||||
let (sx, sy) = (src.width as f64 / w as f64, src.height as f64 / h as f64);
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let o = out.idx(x, y);
|
||||
// Clamp to the edge so borders stay opaque.
|
||||
let fx = ((x as f64 + 0.5) * sx).clamp(0.5, src.width as f64 - 0.5);
|
||||
let fy = ((y as f64 + 0.5) * sy).clamp(0.5, src.height as f64 - 0.5);
|
||||
let mut p = src.sample_bicubic(fx, fy);
|
||||
p[3] = p[3].min(1.0);
|
||||
out.data[o] = p;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Premultiplied 8-bit RGBA (colour matted with black).
|
||||
fn premultiplied8(img: &Image) -> Vec<u8> {
|
||||
img.data
|
||||
.iter()
|
||||
.flat_map(|p| {
|
||||
let a = p[3].clamp(0.0, 1.0);
|
||||
[0, 1, 2].map(|c| (p[c].clamp(0.0, a) * 255.0 + 0.5) as u8).into_iter().chain([(a * 255.0 + 0.5) as u8])
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn interleave_takes_alternate_lines() {
|
||||
let a = Image::filled(3, 4, [1.0, 0.0, 0.0, 1.0]);
|
||||
let b = Image::filled(3, 4, [0.0, 0.0, 1.0, 1.0]);
|
||||
let up = interleave(&a, &b, true);
|
||||
let lo = interleave(&a, &b, false);
|
||||
for y in 0..4u32 {
|
||||
assert_eq!(up.data[up.idx(1, y)][0], if y % 2 == 0 { 1.0 } else { 0.0 }, "upper first: line {y}");
|
||||
assert_eq!(lo.data[lo.idx(1, y)][0], if y % 2 == 1 { 1.0 } else { 0.0 }, "lower first: line {y}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crop_and_pad() {
|
||||
let mut img = Image::new(4, 3);
|
||||
for (i, p) in img.data.iter_mut().enumerate() {
|
||||
*p = [i as f32, 0.0, 0.0, 1.0];
|
||||
}
|
||||
let c = crop(&img, 1, 1, 0, 1);
|
||||
assert_eq!((c.width, c.height), (2, 2));
|
||||
assert_eq!(c.data[0][0], 5.0);
|
||||
let p = crop(&img, -1, 0, 0, 0);
|
||||
assert_eq!((p.width, p.height), (4, 4));
|
||||
assert_eq!(p.data[0], [0.0; 4]);
|
||||
assert_eq!(p.data[4][0], 0.0);
|
||||
assert_eq!(p.data[5][0], 1.0);
|
||||
}
|
||||
}
|
||||
+49
-30
@@ -15,11 +15,12 @@
|
||||
use std::io::{Seek, SeekFrom, Write};
|
||||
|
||||
use effectcraft_project::Comp;
|
||||
use effectcraft_project::render_queue::Channels;
|
||||
use effectcraft_project::render_queue::{AudioFormat, Channels};
|
||||
use effectcraft_time::{TICKS_PER_SECOND, Tick};
|
||||
use rayon::prelude::*;
|
||||
|
||||
use crate::{Job, Report, Result, State, batch_size, io, render_frame, rgba8, wants_audio};
|
||||
use crate::encode::{mix, pcm_bytes};
|
||||
use crate::{Cx, Report, Result, State, batch_size, io, wants_audio};
|
||||
|
||||
const OPUS_RATE: u32 = 48_000;
|
||||
const OPUS_BITRATE: u32 = 192_000;
|
||||
@@ -124,9 +125,10 @@ fn cluster(frames: &mut [Frame]) -> Vec<u8> {
|
||||
out
|
||||
}
|
||||
|
||||
pub(crate) fn webm(job: &Job, comp: &Comp, w: u32, h: u32, st: &mut State) -> Result<Report> {
|
||||
pub(crate) fn webm(job: &Cx, comp: &Comp, w: u32, h: u32, st: &mut State) -> Result<Report> {
|
||||
let rate = job.settings.rate(comp);
|
||||
let alpha = job.output.channels == Channels::Rgba;
|
||||
let colour_channels = if job.output.channels == Channels::Alpha { Channels::Alpha } else { Channels::Rgb };
|
||||
let quality = job.output.quality.clamp(1, 100);
|
||||
let fps = rate.as_f64().max(1.0);
|
||||
let cfg = effectcraft_vp9enc::EncoderConfig {
|
||||
@@ -134,20 +136,22 @@ pub(crate) fn webm(job: &Job, comp: &Comp, w: u32, h: u32, st: &mut State) -> Re
|
||||
height: h,
|
||||
quality,
|
||||
full_range: false,
|
||||
keyframe_interval: (fps * 2.0).round().max(1.0) as u32,
|
||||
target_kbps: None,
|
||||
keyframe_interval: if job.output.keyframe_interval > 0 { job.output.keyframe_interval } else { (fps * 2.0).round().max(1.0) as u32 },
|
||||
target_kbps: job.output.webm_bitrate.then_some(job.output.bitrate_kbps.max(50)),
|
||||
frame_rate: fps,
|
||||
loop_filter: true,
|
||||
};
|
||||
let mut color_enc = effectcraft_vp9enc::Vp9Encoder::new(cfg.clone());
|
||||
let mut alpha_enc = alpha.then(|| effectcraft_vp9enc::Vp9Encoder::new(cfg.clone()));
|
||||
let with_audio = wants_audio(job);
|
||||
let mut opus = with_audio.then(|| effectcraft_opusenc::OpusEncoder::new(2, OPUS_BITRATE));
|
||||
let opus_channels = if job.output.audio_channels == 1 { 1usize } else { 2 };
|
||||
let mut opus = with_audio.then(|| effectcraft_opusenc::OpusEncoder::new(opus_channels as _, OPUS_BITRATE));
|
||||
let total = st.total;
|
||||
let frame_ms = |k: u64| -> i64 { ((k as i128 * 1000 * rate.den as i128 + rate.num as i128 / 2) / rate.num as i128) as i64 };
|
||||
let duration_ms = frame_ms(total) as f64;
|
||||
|
||||
let mut file = crate::out::create(job.sink, job.path)?;
|
||||
let path = job.place(job.path, 1);
|
||||
let mut file = crate::out::create(job.sink, &path)?;
|
||||
// EBML header.
|
||||
let mut head = vec![];
|
||||
let mut eb = vec![];
|
||||
@@ -201,7 +205,7 @@ pub(crate) fn webm(job: &Job, comp: &Comp, w: u32, h: u32, st: &mut State) -> Re
|
||||
el_uint(&mut a, 0x56BB, 80_000_000);
|
||||
let mut au = vec![];
|
||||
el_float(&mut au, 0xB5, OPUS_RATE as f64);
|
||||
el_uint(&mut au, 0x9F, 2);
|
||||
el_uint(&mut au, 0x9F, opus_channels as u64);
|
||||
el(&mut a, 0xE1, &au);
|
||||
el(&mut tracks, TRACK_ENTRY, &a);
|
||||
}
|
||||
@@ -220,10 +224,10 @@ pub(crate) fn webm(job: &Job, comp: &Comp, w: u32, h: u32, st: &mut State) -> Re
|
||||
if end > cursor {
|
||||
let n = (end - cursor) as usize;
|
||||
let start = Tick(((cursor as i128 * TICKS_PER_SECOND as i128) / OPUS_RATE as i128) as i64);
|
||||
pcm_left.extend(effectcraft_render::audio::mix_comp(job.project, job.footage, job.expr, job.comp, start, n, OPUS_RATE));
|
||||
pcm_left.extend(mix(job, start, n, OPUS_RATE));
|
||||
cursor = end;
|
||||
}
|
||||
let fs = effectcraft_opusenc::OpusEncoder::FRAME_SIZE * 2;
|
||||
let fs = effectcraft_opusenc::OpusEncoder::FRAME_SIZE * opus_channels;
|
||||
if finish && !pcm_left.is_empty() {
|
||||
// Pad the last packet (pre-skip + padding cover the encoder delay).
|
||||
let pad = (fs - pcm_left.len() % fs) % fs + fs;
|
||||
@@ -244,7 +248,7 @@ pub(crate) fn webm(job: &Job, comp: &Comp, w: u32, h: u32, st: &mut State) -> Re
|
||||
// Frames render in parallel; the VP9 streams (inter-coded) encode in order, colour and
|
||||
// alpha side by side.
|
||||
let pixels: Vec<Vec<u8>> =
|
||||
(i..end).into_par_iter().map(|k| rgba8(&render_frame(job, comp, k), comp, if alpha { Channels::Rgba } else { Channels::Rgb }, w, h)).collect();
|
||||
(i..end).into_par_iter().map(|k| job.pixels(&job.frame(comp, k), comp, if alpha { Channels::Rgba } else { colour_channels }, w, h)).collect();
|
||||
let mut frames: Vec<Frame> = Vec::with_capacity(pixels.len());
|
||||
for (j, px) in pixels.iter().enumerate() {
|
||||
let (color, a) = rayon::join(
|
||||
@@ -288,7 +292,7 @@ pub(crate) fn webm(job: &Job, comp: &Comp, w: u32, h: u32, st: &mut State) -> Re
|
||||
file.write_all(&size).map_err(io)?;
|
||||
file.seek(SeekFrom::End(0)).map_err(io)?;
|
||||
let bytes = file.finish()?;
|
||||
Ok(Report { path: job.path.to_string(), frames: 0, width: w, height: h, seconds: 0.0, bytes, audio: with_audio })
|
||||
Ok(Report { path, frames: 0, width: w, height: h, seconds: 0.0, bytes, audio: with_audio, log: None, overflow: vec![] })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- audio-only outputs
|
||||
@@ -306,14 +310,25 @@ fn ieee_extended(v: f64) -> [u8; 10] {
|
||||
out
|
||||
}
|
||||
|
||||
/// WAV or AIFF: the comp's audio over the render span, 16-bit stereo.
|
||||
pub(crate) fn audio_file(job: &Job, comp: &Comp, aiff: bool, st: &mut State) -> Result<Report> {
|
||||
/// WAV or AIFF: the comp's audio over the render span (Audio Output: sample rate, mono/stereo,
|
||||
/// 16/24-bit integer or 32-bit float; AIFF has no float in plain AIFF, so it writes 24-bit).
|
||||
pub(crate) fn audio_file(job: &Cx, comp: &Comp, aiff: bool, st: &mut State) -> Result<Report> {
|
||||
let sr = job.output.audio_sample_rate.clamp(8_000, 192_000);
|
||||
let channels = if job.output.audio_channels == 1 { 1u16 } else { 2 };
|
||||
let fmt = match job.output.audio_format {
|
||||
AudioFormat::F32 if aiff => AudioFormat::S24,
|
||||
f => f,
|
||||
};
|
||||
let bytes_per_sample: u16 = match fmt {
|
||||
AudioFormat::S16 => 2,
|
||||
AudioFormat::S24 => 3,
|
||||
AudioFormat::F32 => 4,
|
||||
};
|
||||
let (span_start, span_end) = job.settings.span(comp);
|
||||
let start = span_start.to_units_floor(sr as i64);
|
||||
let end = span_end.to_units_floor(sr as i64);
|
||||
let n = (end - start).max(0) as usize;
|
||||
let mut pcm = Vec::with_capacity(n * 4);
|
||||
let mut pcm = Vec::with_capacity(n * (channels * bytes_per_sample) as usize);
|
||||
// Mix in one-second blocks, reporting progress in frames.
|
||||
let block = sr as usize;
|
||||
let mut done = 0usize;
|
||||
@@ -322,10 +337,7 @@ pub(crate) fn audio_file(job: &Job, comp: &Comp, aiff: bool, st: &mut State) ->
|
||||
while done < n {
|
||||
let k = block.min(n - done);
|
||||
let t = Tick((((start + done as i64) as i128 * TICKS_PER_SECOND as i128) / sr as i128) as i64);
|
||||
for s in effectcraft_render::audio::mix_comp(job.project, job.footage, job.expr, job.comp, t, k, sr) {
|
||||
let v = (s.clamp(-1.0, 1.0) * 32767.0).round() as i16;
|
||||
pcm.extend_from_slice(&if aiff { v.to_be_bytes() } else { v.to_le_bytes() });
|
||||
}
|
||||
pcm.extend(pcm_bytes(&mix(job, t, k, sr), fmt, aiff));
|
||||
done += k;
|
||||
let now = (done as u64 * total_frames / n.max(1) as u64).min(total_frames);
|
||||
if now > reported && st.total > 0 {
|
||||
@@ -336,13 +348,14 @@ pub(crate) fn audio_file(job: &Job, comp: &Comp, aiff: bool, st: &mut State) ->
|
||||
if reported < st.total {
|
||||
st.advance(st.total - reported)?;
|
||||
}
|
||||
let block_align = channels * bytes_per_sample;
|
||||
let frames = (pcm.len() / block_align as usize) as u32;
|
||||
let mut out = vec![];
|
||||
let frames = (pcm.len() / 4) as u32;
|
||||
if aiff {
|
||||
let mut comm = vec![];
|
||||
comm.extend_from_slice(&2u16.to_be_bytes());
|
||||
comm.extend_from_slice(&channels.to_be_bytes());
|
||||
comm.extend_from_slice(&frames.to_be_bytes());
|
||||
comm.extend_from_slice(&16u16.to_be_bytes());
|
||||
comm.extend_from_slice(&(bytes_per_sample * 8).to_be_bytes());
|
||||
comm.extend_from_slice(&ieee_extended(sr as f64));
|
||||
let ssnd_len = 8 + pcm.len();
|
||||
out.extend_from_slice(b"FORM");
|
||||
@@ -359,24 +372,30 @@ pub(crate) fn audio_file(job: &Job, comp: &Comp, aiff: bool, st: &mut State) ->
|
||||
out.push(0);
|
||||
}
|
||||
} else {
|
||||
// WAVE_FORMAT_PCM (1) or WAVE_FORMAT_IEEE_FLOAT (3).
|
||||
let tag: u16 = if fmt == AudioFormat::F32 { 3 } else { 1 };
|
||||
out.extend_from_slice(b"RIFF");
|
||||
out.extend_from_slice(&((36 + pcm.len()) as u32).to_le_bytes());
|
||||
out.extend_from_slice(&((36 + pcm.len() + pcm.len() % 2) as u32).to_le_bytes());
|
||||
out.extend_from_slice(b"WAVEfmt ");
|
||||
out.extend_from_slice(&16u32.to_le_bytes());
|
||||
out.extend_from_slice(&1u16.to_le_bytes());
|
||||
out.extend_from_slice(&2u16.to_le_bytes());
|
||||
out.extend_from_slice(&tag.to_le_bytes());
|
||||
out.extend_from_slice(&channels.to_le_bytes());
|
||||
out.extend_from_slice(&sr.to_le_bytes());
|
||||
out.extend_from_slice(&(sr * 4).to_le_bytes());
|
||||
out.extend_from_slice(&4u16.to_le_bytes());
|
||||
out.extend_from_slice(&16u16.to_le_bytes());
|
||||
out.extend_from_slice(&(sr * block_align as u32).to_le_bytes());
|
||||
out.extend_from_slice(&block_align.to_le_bytes());
|
||||
out.extend_from_slice(&(bytes_per_sample * 8).to_le_bytes());
|
||||
out.extend_from_slice(b"data");
|
||||
out.extend_from_slice(&(pcm.len() as u32).to_le_bytes());
|
||||
out.extend_from_slice(&pcm);
|
||||
if pcm.len() % 2 == 1 {
|
||||
out.push(0);
|
||||
}
|
||||
}
|
||||
let mut file = crate::out::create(job.sink, job.path)?;
|
||||
let path = job.place(job.path, out.len() as u64);
|
||||
let mut file = crate::out::create(job.sink, &path)?;
|
||||
file.write_all(&out).map_err(io)?;
|
||||
let bytes = file.finish()?;
|
||||
Ok(Report { path: job.path.to_string(), frames: 0, width: 0, height: 0, seconds: 0.0, bytes, audio: true })
|
||||
Ok(Report { path, frames: 0, width: 0, height: 0, seconds: 0.0, bytes, audio: true, log: None, overflow: vec![] })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
//! Render Settings and Output Module features end to end (through a [`Sink`], so nothing touches
|
||||
//! the disk): field rendering and 3:2 pulldown, crop / region of interest / resize, alpha-only
|
||||
//! and premultiplied output, the solo and guide-layer overrides, the render log, storage
|
||||
//! overflow and PCM audio formats.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use effectcraft_color::Label;
|
||||
use effectcraft_export::render_queue::*;
|
||||
use effectcraft_export::{Job, JobOptions, Report, Sink, StorageQuota, export};
|
||||
use effectcraft_keyframe::{Keyframe, Value};
|
||||
use effectcraft_project::{Comp, ItemId, ItemKind, LayerSource, Project, Solid, build};
|
||||
use effectcraft_render::NoFootage;
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
|
||||
const W: u32 = 64;
|
||||
const H: u32 = 48;
|
||||
|
||||
/// A comp at `rate`, black, with a white bar 8 px wide sweeping left to right (64 px/s) and a
|
||||
/// static red block at the bottom left; `extra` adds layers.
|
||||
fn project(rate: FrameRate, extra: impl FnOnce(&mut Project, &mut Comp)) -> (Project, ItemId) {
|
||||
let mut p = Project::default();
|
||||
let mut comp = Comp::new(W, H, rate, Tick::from_seconds_f64(2.0));
|
||||
comp.background = [0.0, 0.0, 0.0];
|
||||
let bar = p.add_item("Bar", Label::Red, None, ItemKind::Solid(Solid { color: [1.0, 1.0, 1.0], width: 8, height: 32, pixel_aspect: 1.0 }));
|
||||
let mut l = build::layer(&mut p, &comp, "Bar", LayerSource::Solid { item: bar }, (8, 32), None);
|
||||
l.props.prop_mut("transform/position").unwrap().keys =
|
||||
vec![Keyframe::new(Tick::ZERO, Value::Vec3([0.0, 16.0, 0.0])), Keyframe::new(Tick::from_seconds_f64(1.0), Value::Vec3([64.0, 16.0, 0.0]))];
|
||||
comp.layers.push(l);
|
||||
let red = p.add_item("Block", Label::Red, None, ItemKind::Solid(Solid { color: [1.0, 0.0, 0.0], width: 16, height: 8, pixel_aspect: 1.0 }));
|
||||
let mut b = build::layer(&mut p, &comp, "Block", LayerSource::Solid { item: red }, (16, 8), None);
|
||||
b.props.prop_mut("transform/position").unwrap().value = Value::Vec3([8.0, 44.0, 0.0]);
|
||||
comp.layers.push(b);
|
||||
extra(&mut p, &mut comp);
|
||||
let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.into()));
|
||||
(p, cid)
|
||||
}
|
||||
|
||||
type Files = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
|
||||
|
||||
fn custom(start: f64, end: f64) -> RenderSettings {
|
||||
RenderSettings { time_span: TimeSpan::Custom { start: Tick::from_seconds_f64(start), end: Tick::from_seconds_f64(end) }, ..Default::default() }
|
||||
}
|
||||
|
||||
fn run_with(p: &Project, cid: ItemId, s: &RenderSettings, om: &OutputModule, path: &str, options: JobOptions, cancel: bool) -> (Result<Report, String>, Files) {
|
||||
let files: Files = Default::default();
|
||||
let f = files.clone();
|
||||
let sink: Box<Sink> = Box::new(move |path: &str, data: Vec<u8>| f.lock().unwrap().push((path.to_string(), data)));
|
||||
let job = Job { project: p, footage: &NoFootage, expr: None, accel: None, comp: cid, settings: s, output: om, path, sink: Some(&*sink), options };
|
||||
let r = export(&job, &mut |pr| !(cancel && pr.done > 0)).map_err(|e| e.to_string());
|
||||
(r, files)
|
||||
}
|
||||
|
||||
fn run(p: &Project, cid: ItemId, s: &RenderSettings, om: &OutputModule, path: &str) -> Files {
|
||||
let (r, files) = run_with(p, cid, s, om, path, JobOptions::default(), false);
|
||||
r.expect("export");
|
||||
files
|
||||
}
|
||||
|
||||
fn png(files: &Files, i: usize) -> image::RgbaImage {
|
||||
let f = files.lock().unwrap();
|
||||
let pngs: Vec<&(String, Vec<u8>)> = f.iter().filter(|(p, _)| p.ends_with(".png")).collect();
|
||||
let mut pngs = pngs;
|
||||
pngs.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
image::load_from_memory(&pngs[i].1).expect("png").to_rgba8()
|
||||
}
|
||||
|
||||
fn row(img: &image::RgbaImage, y: u32) -> Vec<u8> {
|
||||
(0..img.width()).flat_map(|x| img.get_pixel(x, y).0).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn field_render_interleaves_two_times() {
|
||||
let (p, cid) = project(FrameRate::new(25, 1), |_, _| {});
|
||||
let om = OutputModule::for_format(OutputFormat::PngSequence);
|
||||
// Frame 10 at 25 fps with fields: 0.40 s and 0.42 s.
|
||||
for (fr, upper) in [(FieldRender::UpperFirst, true), (FieldRender::LowerFirst, false)] {
|
||||
let s = RenderSettings { field_render: fr, ..custom(0.4, 0.44) };
|
||||
let fields = png(&run(&p, cid, &s, &om, "/f/field_[#####].png"), 0);
|
||||
// Progressive references at 50 fps: frames 20 (0.40 s) and 21 (0.42 s).
|
||||
let r = RenderSettings { frame_rate: Some(FrameRate::new(50, 1)), ..custom(0.4, 0.44) };
|
||||
let refs = run(&p, cid, &r, &om, "/f/ref_[#####].png");
|
||||
let (a, b) = (png(&refs, 0), png(&refs, 1));
|
||||
assert_ne!(row(&a, 10), row(&b, 10), "the bar moved between the fields");
|
||||
for y in 0..H {
|
||||
let first = (y % 2 == 0) == upper;
|
||||
assert_eq!(row(&fields, y), row(if first { &a } else { &b }, y), "{fr:?}: line {y}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pulldown_follows_the_cadence() {
|
||||
let ntsc = FrameRate::new(30000, 1001);
|
||||
let (p, cid) = project(ntsc, |_, _| {});
|
||||
let om = OutputModule::for_format(OutputFormat::PngSequence);
|
||||
let five = 5.0 * 1001.0 / 30000.0;
|
||||
let s = RenderSettings { field_render: FieldRender::UpperFirst, pulldown: Pulldown::Wssww, ..custom(0.0, five) };
|
||||
let out = run(&p, cid, &s, &om, "/p/pd_[#####].png");
|
||||
assert_eq!(out.lock().unwrap().len(), 5);
|
||||
// Film frames at 23.976 fps.
|
||||
let r = RenderSettings { frame_rate: Some(FrameRate::new(24000, 1001)), ..custom(0.0, 0.2) };
|
||||
let film = run(&p, cid, &r, &om, "/p/film_[#####].png");
|
||||
let mut pattern = String::new();
|
||||
for k in 0..5 {
|
||||
let (a, b) = Pulldown::Wssww.fields(k as u64).unwrap();
|
||||
pattern.push(if a == b { 'W' } else { 'S' });
|
||||
let img = png(&out, k);
|
||||
let (fa, fb) = (png(&film, a as usize), png(&film, b as usize));
|
||||
for y in 0..H {
|
||||
assert_eq!(row(&img, y), row(if y % 2 == 0 { &fa } else { &fb }, y), "frame {k} line {y}");
|
||||
}
|
||||
}
|
||||
assert_eq!(pattern, "WSSWW");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crop_roi_and_resize() {
|
||||
let (p, cid) = project(FrameRate::new(25, 1), |_, _| {});
|
||||
let s = custom(0.4, 0.44);
|
||||
let full = png(&run(&p, cid, &s, &OutputModule::for_format(OutputFormat::PngSequence), "/c/full_[#####].png"), 0);
|
||||
let mut om = OutputModule::for_format(OutputFormat::PngSequence);
|
||||
om.crop = Crop { enabled: true, top: 4, left: 10, bottom: 6, right: 2, ..Default::default() };
|
||||
let c = png(&run(&p, cid, &s, &om, "/c/crop_[#####].png"), 0);
|
||||
assert_eq!((c.width(), c.height()), (52, 38));
|
||||
for (x, y) in [(0, 0), (20, 11), (51, 37), (3, 30)] {
|
||||
assert_eq!(c.get_pixel(x, y), full.get_pixel(x + 10, y + 4), "({x}, {y})");
|
||||
}
|
||||
// Region of interest at half resolution.
|
||||
om.crop = Crop { enabled: true, use_roi: true, roi: Some([16.0, 8.0, 32.0, 32.0]), ..Default::default() };
|
||||
let half = RenderSettings { resolution: 0.5, ..s.clone() };
|
||||
let r = png(&run(&p, cid, &half, &om, "/c/roi_[#####].png"), 0);
|
||||
assert_eq!((r.width(), r.height()), (16, 16));
|
||||
// Resize (fixed size, low and high quality) after the crop.
|
||||
om.crop = Crop::default();
|
||||
for q in [ResizeQuality::Low, ResizeQuality::High] {
|
||||
om.resize = Resize { enabled: true, width: 32, height: 20, lock_aspect: false, quality: q };
|
||||
let z = png(&run(&p, cid, &s, &om, "/c/resize_[#####].png"), 0);
|
||||
assert_eq!((z.width(), z.height()), (32, 20));
|
||||
// The red block (bottom left) survives the resize.
|
||||
let px = z.get_pixel(4, 18).0;
|
||||
assert!(px[0] > 150 && px[1] < 80, "{q:?}: {px:?}");
|
||||
}
|
||||
om.resize.lock_aspect = true;
|
||||
let z = png(&run(&p, cid, &s, &om, "/c/aspect_[#####].png"), 0);
|
||||
assert_eq!((z.width(), z.height()), (32, 24));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alpha_only_and_premultiplied() {
|
||||
let (p, cid) = project(FrameRate::new(25, 1), |p, comp| {
|
||||
// A half-transparent green layer over the right half.
|
||||
let g = p.add_item("Green", Label::Green, None, ItemKind::Solid(Solid { color: [0.0, 1.0, 0.0], width: 32, height: 48, pixel_aspect: 1.0 }));
|
||||
let mut l = build::layer(p, comp, "Green", LayerSource::Solid { item: g }, (32, 48), None);
|
||||
l.props.prop_mut("transform/position").unwrap().value = Value::Vec3([48.0, 24.0, 0.0]);
|
||||
l.props.prop_mut("transform/opacity").unwrap().value = Value::Scalar(50.0);
|
||||
comp.layers.insert(0, l);
|
||||
});
|
||||
let s = custom(0.0, 0.04);
|
||||
let mut om = OutputModule::for_format(OutputFormat::PngSequence);
|
||||
om.channels = Channels::Alpha;
|
||||
let a = png(&run(&p, cid, &s, &om, "/a/alpha_[#####].png"), 0);
|
||||
assert_eq!(a.get_pixel(60, 2).0[0], 128, "half-opaque green");
|
||||
assert_eq!(a.get_pixel(20, 2).0[0], 0, "transparent");
|
||||
assert_eq!(a.get_pixel(8, 44).0[0], 255, "opaque block");
|
||||
om.channels = Channels::Rgba;
|
||||
let straight = png(&run(&p, cid, &s, &om, "/a/s_[#####].png"), 0);
|
||||
om.alpha_mode = AlphaMode::Premultiplied;
|
||||
let premul = png(&run(&p, cid, &s, &om, "/a/p_[#####].png"), 0);
|
||||
assert_eq!(straight.get_pixel(60, 2).0, [0, 255, 0, 128]);
|
||||
assert_eq!(premul.get_pixel(60, 2).0, [0, 128, 0, 128]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn solo_and_guide_layer_overrides() {
|
||||
let (p, cid) = project(FrameRate::new(25, 1), |p, comp| {
|
||||
let g = p.add_item("Guide", Label::Green, None, ItemKind::Solid(Solid { color: [0.0, 0.0, 1.0], width: 8, height: 8, pixel_aspect: 1.0 }));
|
||||
let mut l = build::layer(p, comp, "Guide", LayerSource::Solid { item: g }, (8, 8), None);
|
||||
l.props.prop_mut("transform/position").unwrap().value = Value::Vec3([56.0, 44.0, 0.0]);
|
||||
l.switches.guide = true;
|
||||
comp.layers.insert(0, l);
|
||||
// Solo the moving bar.
|
||||
comp.layers[1].switches.solo = true;
|
||||
});
|
||||
let om = OutputModule::for_format(OutputFormat::PngSequence);
|
||||
let s = custom(0.4, 0.44);
|
||||
let cur = png(&run(&p, cid, &s, &om, "/s/cur_[#####].png"), 0);
|
||||
assert_eq!(cur.get_pixel(8, 44).0[0], 0, "solo: the red block is hidden");
|
||||
assert_eq!(cur.get_pixel(56, 44).0[2], 0, "guide layers off by default");
|
||||
let off = RenderSettings { solo: CurrentOrOff::AllOff, guide_layers: CurrentOrOff::Current, ..s.clone() };
|
||||
let all = png(&run(&p, cid, &off, &om, "/s/off_[#####].png"), 0);
|
||||
assert_eq!(all.get_pixel(8, 44).0[0], 255, "Solo Switches: All Off");
|
||||
// (with solo off the guide renders too: Guide Layers: Current Settings)
|
||||
assert_eq!(all.get_pixel(56, 44).0[2], 255);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_logs() {
|
||||
let (p, cid) = project(FrameRate::new(25, 1), |_, _| {});
|
||||
let om = OutputModule::for_format(OutputFormat::PngSequence);
|
||||
let s = RenderSettings { field_render: FieldRender::UpperFirst, name: "DV-ish".into(), ..custom(0.0, 0.2) };
|
||||
let log_of = |files: &Files| {
|
||||
files.lock().unwrap().iter().find(|(p, _)| p.ends_with("_RenderLog.txt")).map(|(p, d)| (p.clone(), String::from_utf8(d.clone()).unwrap()))
|
||||
};
|
||||
// Errors Only + success: no log.
|
||||
let (r, files) = run_with(&p, cid, &s, &om, "/l/a_[#####].png", JobOptions::default(), false);
|
||||
assert!(r.unwrap().log.is_none());
|
||||
assert!(log_of(&files).is_none());
|
||||
// Plus Per Frame Info: settings and one line per frame.
|
||||
let opts = JobOptions { log: RenderLog::PlusPerFrameInfo, label: "#1 Comp".into(), ..Default::default() };
|
||||
let (r, files) = run_with(&p, cid, &s, &om, "/l/b_[#####].png", opts, false);
|
||||
let r = r.unwrap();
|
||||
let (path, text) = log_of(&files).expect("log");
|
||||
assert_eq!(r.log.as_deref(), Some(path.as_str()));
|
||||
assert_eq!(path, "/l/b_RenderLog.txt");
|
||||
assert!(text.contains("Item: #1 Comp") && text.contains("Result: Done (5 frames"), "{text}");
|
||||
assert!(text.contains("Field Render: Upper Field First") && text.contains("Template: DV-ish") && text.contains("Format: PNG Sequence"), "{text}");
|
||||
assert_eq!(text.lines().filter(|l| l.contains("rendered in")).count(), 5, "{text}");
|
||||
assert!(text.contains("Frame 0 (0.000 s): rendered in") && text.contains("Frame 4 (0.160 s)"), "{text}");
|
||||
// Plus Settings: no per-frame lines.
|
||||
let opts = JobOptions { log: RenderLog::PlusSettings, ..Default::default() };
|
||||
let (_, files) = run_with(&p, cid, &s, &om, "/l/c.gif", opts, false);
|
||||
let (_, text) = log_of(&files).expect("log");
|
||||
assert!(text.contains("Render Settings:") && !text.contains("rendered in"), "{text}");
|
||||
// Errors Only + failure (the composition is gone): the error is logged.
|
||||
let gif = OutputModule::for_format(OutputFormat::Gif);
|
||||
let (r, files) = run_with(&p, ItemId(424_242), &s, &gif, "/l/d.gif", JobOptions::default(), false);
|
||||
assert!(r.is_err());
|
||||
let (path, text) = log_of(&files).expect("error log");
|
||||
assert_eq!(path, "/l/d_RenderLog.txt");
|
||||
assert!(text.contains("Result: Failed") && text.contains("Error: composition not found") && !text.contains("Render Settings:"), "{text}");
|
||||
// A user stop is not an error: no log.
|
||||
let (r, files) = run_with(&p, cid, &s, &gif, "/l/e.gif", JobOptions::default(), true);
|
||||
assert!(r.is_err());
|
||||
assert!(log_of(&files).is_none());
|
||||
}
|
||||
|
||||
/// The primary folder holds `limit` bytes; overflow folders are unlimited.
|
||||
struct Quota {
|
||||
limit: u64,
|
||||
used: Mutex<u64>,
|
||||
}
|
||||
|
||||
impl StorageQuota for Quota {
|
||||
fn has_room(&self, path: &str, bytes: u64) -> bool {
|
||||
if !path.starts_with("/primary/") {
|
||||
return true;
|
||||
}
|
||||
let mut u = self.used.lock().unwrap();
|
||||
if *u + bytes > self.limit {
|
||||
return false;
|
||||
}
|
||||
*u += bytes;
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn storage_overflow_moves_files_to_the_overflow_folder() {
|
||||
let (p, cid) = project(FrameRate::new(25, 1), |_, _| {});
|
||||
let om = OutputModule::for_format(OutputFormat::PngSequence);
|
||||
let s = RenderSettings { skip_existing: false, ..custom(0.0, 0.4) };
|
||||
let q = Quota { limit: 1, used: Mutex::new(0) };
|
||||
let opts = JobOptions { storage: Some(&q), overflow: vec!["/overflow".into()], log: RenderLog::PlusSettings, ..Default::default() };
|
||||
let (r, files) = run_with(&p, cid, &s, &om, "/primary/seq_[#####].png", opts, false);
|
||||
let r = r.unwrap();
|
||||
assert_eq!(r.overflow.len(), 10, "{:?}", r.overflow);
|
||||
let f = files.lock().unwrap();
|
||||
assert_eq!(f.iter().filter(|(p, _)| p.starts_with("/overflow/seq_") && p.ends_with(".png")).count(), 10);
|
||||
let log = f.iter().find(|(p, _)| p.ends_with("_RenderLog.txt")).map(|(_, d)| String::from_utf8_lossy(d).to_string()).unwrap();
|
||||
assert!(log.contains("Storage overflow: 10 file(s)"), "{log}");
|
||||
drop(f);
|
||||
// Use Storage Overflow off: everything stays in place.
|
||||
let off = RenderSettings { storage_overflow: false, ..s };
|
||||
let q = Quota { limit: 1, used: Mutex::new(0) };
|
||||
let opts = JobOptions { storage: Some(&q), overflow: vec!["/overflow".into()], ..Default::default() };
|
||||
let (r, files) = run_with(&p, cid, &off, &om, "/primary/seq_[#####].png", opts, false);
|
||||
assert!(r.unwrap().overflow.is_empty());
|
||||
assert!(files.lock().unwrap().iter().all(|(p, _)| p.starts_with("/primary/")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wav_and_aiff_channels_and_formats() {
|
||||
let (p, cid) = project(FrameRate::new(25, 1), |_, _| {});
|
||||
let s = custom(0.0, 0.5);
|
||||
let mut om = OutputModule::for_format(OutputFormat::Wav);
|
||||
om.audio_channels = 1;
|
||||
om.audio_format = AudioFormat::S24;
|
||||
om.audio_sample_rate = 44_100;
|
||||
let files = run(&p, cid, &s, &om, "/w/a.wav");
|
||||
let d = files.lock().unwrap()[0].1.clone();
|
||||
assert_eq!(&d[..4], b"RIFF");
|
||||
let u16_at = |o: usize| u16::from_le_bytes([d[o], d[o + 1]]);
|
||||
let u32_at = |o: usize| u32::from_le_bytes([d[o], d[o + 1], d[o + 2], d[o + 3]]);
|
||||
assert_eq!((u16_at(20), u16_at(22), u32_at(24), u16_at(32), u16_at(34)), (1, 1, 44_100, 3, 24));
|
||||
assert_eq!(u32_at(40), 22_050 * 3, "half a second of mono 24-bit");
|
||||
om.audio_channels = 2;
|
||||
om.audio_format = AudioFormat::F32;
|
||||
let files = run(&p, cid, &s, &om, "/w/b.wav");
|
||||
let d = files.lock().unwrap()[0].1.clone();
|
||||
let u16_at = |o: usize| u16::from_le_bytes([d[o], d[o + 1]]);
|
||||
assert_eq!((u16_at(20), u16_at(22), u16_at(32), u16_at(34)), (3, 2, 8, 32), "IEEE float stereo");
|
||||
// AIFF has no float: 24-bit.
|
||||
let mut aiff = OutputModule::for_format(OutputFormat::Aiff);
|
||||
aiff.audio_format = AudioFormat::F32;
|
||||
let files = run(&p, cid, &s, &aiff, "/w/c.aif");
|
||||
let d = files.lock().unwrap()[0].1.clone();
|
||||
assert_eq!(&d[8..12], b"AIFF");
|
||||
assert_eq!(u16::from_be_bytes([d[26], d[27]]), 24, "sample size");
|
||||
}
|
||||
@@ -55,7 +55,7 @@ fn settings() -> RenderSettings {
|
||||
fn run(p: &Project, cid: ItemId, footage: &dyn FootageSource, om: &OutputModule, path: &Path) -> effectcraft_export::Report {
|
||||
let s = settings();
|
||||
let path = path.to_string_lossy().to_string();
|
||||
let job = Job { project: p, footage, expr: None, accel: None, comp: cid, settings: &s, output: om, path: &path, sink: None };
|
||||
let job = Job { project: p, footage, expr: None, accel: None, comp: cid, settings: &s, output: om, path: &path, sink: None, options: Default::default() };
|
||||
let mut last = Progress::default();
|
||||
let mut calls = 0;
|
||||
let r = export(&job, &mut |pr| {
|
||||
@@ -167,7 +167,18 @@ fn h264_odd_size_is_cropped_even() {
|
||||
let comp = p.comp(cid).expect("comp");
|
||||
assert_eq!(s.output_size(comp).0 % 2, 1);
|
||||
let path = d.join("odd.mp4").to_string_lossy().to_string();
|
||||
let job = Job { project: &p, footage: &NoFootage, expr: None, accel: None, comp: cid, settings: &s, output: &om, path: &path, sink: None };
|
||||
let job = Job {
|
||||
project: &p,
|
||||
footage: &NoFootage,
|
||||
expr: None,
|
||||
accel: None,
|
||||
comp: cid,
|
||||
settings: &s,
|
||||
output: &om,
|
||||
path: &path,
|
||||
sink: None,
|
||||
options: Default::default(),
|
||||
};
|
||||
let r = export(&job, &mut |_| true).expect("export");
|
||||
assert_eq!((r.width % 2, r.height % 2), (0, 0));
|
||||
}
|
||||
@@ -287,7 +298,18 @@ fn cancel_stops_early() {
|
||||
let om = OutputModule::for_format(OutputFormat::PngSequence);
|
||||
let s = settings();
|
||||
let path = d.join("c_[#####].png").to_string_lossy().to_string();
|
||||
let job = Job { project: &p, footage: &NoFootage, expr: None, accel: None, comp: cid, settings: &s, output: &om, path: &path, sink: None };
|
||||
let job = Job {
|
||||
project: &p,
|
||||
footage: &NoFootage,
|
||||
expr: None,
|
||||
accel: None,
|
||||
comp: cid,
|
||||
settings: &s,
|
||||
output: &om,
|
||||
path: &path,
|
||||
sink: None,
|
||||
options: Default::default(),
|
||||
};
|
||||
let r = export(&job, &mut |_| false);
|
||||
assert!(matches!(r, Err(effectcraft_export::ExportError::Cancelled)));
|
||||
let written = std::fs::read_dir(&d).map(|r| r.count()).unwrap_or(0);
|
||||
|
||||
+17
-1
@@ -40,9 +40,25 @@ impl Exporter for FileExporter {
|
||||
output: &job.item.output,
|
||||
path: job.path,
|
||||
sink: self.sink.as_deref(),
|
||||
options: effectcraft_export::JobOptions {
|
||||
log: job.item.log,
|
||||
label: job.label.clone(),
|
||||
storage: job.storage,
|
||||
overflow: job.project.render_prefs.overflow_folders.clone(),
|
||||
},
|
||||
};
|
||||
match effectcraft_export::export(&j, &mut |p| progress(p.done, p.total)) {
|
||||
Ok(r) => Ok(ExportResult { path: r.path, frames: r.frames, width: r.width, height: r.height, bytes: r.bytes, seconds: r.seconds, audio: r.audio }),
|
||||
Ok(r) => Ok(ExportResult {
|
||||
path: r.path,
|
||||
frames: r.frames,
|
||||
width: r.width,
|
||||
height: r.height,
|
||||
bytes: r.bytes,
|
||||
seconds: r.seconds,
|
||||
audio: r.audio,
|
||||
log: r.log,
|
||||
overflow: r.overflow,
|
||||
}),
|
||||
Err(effectcraft_export::ExportError::Cancelled) => Err(effectcraft_engine::render_queue::CANCELLED.into()),
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ pub mod build;
|
||||
pub mod essential;
|
||||
pub mod props;
|
||||
pub mod render_queue;
|
||||
pub mod render_templates;
|
||||
pub mod styles;
|
||||
pub mod tracking;
|
||||
|
||||
@@ -931,11 +932,25 @@ pub struct Project {
|
||||
/// The Render Queue (Composition ▸ Add to Render Queue).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub render_queue: Vec<render_queue::RenderQueueItem>,
|
||||
/// Render Settings / Output Module templates and their defaults.
|
||||
#[serde(default, skip_serializing_if = "render_templates::RenderTemplates::is_default")]
|
||||
pub render_templates: render_templates::RenderTemplates,
|
||||
/// Render Queue preferences (Notify, storage overflow folders).
|
||||
#[serde(default, skip_serializing_if = "render_queue::RenderQueuePrefs::is_default")]
|
||||
pub render_prefs: render_queue::RenderQueuePrefs,
|
||||
}
|
||||
|
||||
impl Default for Project {
|
||||
fn default() -> Self {
|
||||
Project { schema: SCHEMA_VERSION, settings: ProjectSettings::default(), items: BTreeMap::new(), next_id: 1, render_queue: Vec::new() }
|
||||
Project {
|
||||
schema: SCHEMA_VERSION,
|
||||
settings: ProjectSettings::default(),
|
||||
items: BTreeMap::new(),
|
||||
next_id: 1,
|
||||
render_queue: Vec::new(),
|
||||
render_templates: Default::default(),
|
||||
render_prefs: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -84,38 +84,329 @@ impl ProxyUse {
|
||||
}
|
||||
}
|
||||
|
||||
/// Render Settings ▸ Field Render: interlaced output, each frame woven from two fields rendered
|
||||
/// half a frame apart.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum FieldRender {
|
||||
#[default]
|
||||
Off,
|
||||
/// The upper field (lines 0, 2, 4…) holds the earlier time.
|
||||
UpperFirst,
|
||||
LowerFirst,
|
||||
}
|
||||
|
||||
impl FieldRender {
|
||||
pub const ALL: [FieldRender; 3] = [FieldRender::Off, FieldRender::UpperFirst, FieldRender::LowerFirst];
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
FieldRender::Off => "Off",
|
||||
FieldRender::UpperFirst => "Upper Field First",
|
||||
FieldRender::LowerFirst => "Lower Field First",
|
||||
}
|
||||
}
|
||||
pub fn parse(s: &str) -> Option<FieldRender> {
|
||||
match norm(s).as_str() {
|
||||
"off" | "none" | "progressive" => Some(FieldRender::Off),
|
||||
"upper" | "upperfirst" | "upperfieldfirst" => Some(FieldRender::UpperFirst),
|
||||
"lower" | "lowerfirst" | "lowerfieldfirst" => Some(FieldRender::LowerFirst),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn norm(s: &str) -> String {
|
||||
s.to_ascii_lowercase().replace([' ', '_', '-', '.', ':', '&'], "")
|
||||
}
|
||||
|
||||
/// Render Settings ▸ 3:2 Pulldown: with field rendering, four film frames (at 4/5 of the output
|
||||
/// rate) spread over five interlaced frames in 2:3 cadence. The phase names which of the five
|
||||
/// frames are Whole (both fields from one film frame) or Split.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Pulldown {
|
||||
#[default]
|
||||
Off,
|
||||
Wssww,
|
||||
Sswww,
|
||||
Swwws,
|
||||
Wwwss,
|
||||
Wwssw,
|
||||
}
|
||||
|
||||
/// Film frame (within a group of four) of each of the ten fields of the 2:3 cadence.
|
||||
const CADENCE: [i64; 10] = [0, 0, 1, 1, 1, 2, 2, 3, 3, 3];
|
||||
|
||||
impl Pulldown {
|
||||
pub const ALL: [Pulldown; 6] = [Pulldown::Off, Pulldown::Wssww, Pulldown::Sswww, Pulldown::Swwws, Pulldown::Wwwss, Pulldown::Wwssw];
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Pulldown::Off => "Off",
|
||||
Pulldown::Wssww => "WSSWW",
|
||||
Pulldown::Sswww => "SSWWW",
|
||||
Pulldown::Swwws => "SWWWS",
|
||||
Pulldown::Wwwss => "WWWSS",
|
||||
Pulldown::Wwssw => "WWSSW",
|
||||
}
|
||||
}
|
||||
pub fn parse(s: &str) -> Option<Pulldown> {
|
||||
let n = norm(s);
|
||||
if matches!(n.as_str(), "off" | "none") {
|
||||
return Some(Pulldown::Off);
|
||||
}
|
||||
Pulldown::ALL.into_iter().find(|p| p.label().eq_ignore_ascii_case(&n))
|
||||
}
|
||||
/// Offset (in frames) into the WWSSW base cadence.
|
||||
fn rotation(self) -> Option<i64> {
|
||||
Some(match self {
|
||||
Pulldown::Off => return None,
|
||||
Pulldown::Wwssw => 0,
|
||||
Pulldown::Wssww => 1,
|
||||
Pulldown::Sswww => 2,
|
||||
Pulldown::Swwws => 3,
|
||||
Pulldown::Wwwss => 4,
|
||||
})
|
||||
}
|
||||
/// The film frames (counted from the first film frame of the render) of the first and
|
||||
/// second field of output frame `k`.
|
||||
pub fn fields(self, k: u64) -> Option<(i64, i64)> {
|
||||
let r = self.rotation()?;
|
||||
let film = |f: i64| f.div_euclid(10) * 4 + CADENCE[f.rem_euclid(10) as usize];
|
||||
let f0 = 2 * (k as i64 + r);
|
||||
let base = film(2 * r);
|
||||
Some((film(f0) - base, film(f0 + 1) - base))
|
||||
}
|
||||
/// The W/S pattern of five consecutive output frames.
|
||||
pub fn pattern(self) -> String {
|
||||
(0..5).filter_map(|k| self.fields(k).map(|(a, b)| if a == b { 'W' } else { 'S' })).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// A layer switch override (Render Settings ▸ Frame Blending / Motion Blur).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum SwitchOverride {
|
||||
/// The layer switches and the composition's enable switch.
|
||||
#[default]
|
||||
Current,
|
||||
/// Every layer whose switch is on, whatever the composition's enable switch.
|
||||
OnForChecked,
|
||||
OffForAll,
|
||||
}
|
||||
|
||||
impl SwitchOverride {
|
||||
pub const ALL: [SwitchOverride; 3] = [SwitchOverride::Current, SwitchOverride::OnForChecked, SwitchOverride::OffForAll];
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
SwitchOverride::Current => "Current Settings",
|
||||
SwitchOverride::OnForChecked => "On for Checked Layers",
|
||||
SwitchOverride::OffForAll => "Off for All Layers",
|
||||
}
|
||||
}
|
||||
pub fn parse(s: &str) -> Option<SwitchOverride> {
|
||||
match norm(s).as_str() {
|
||||
"current" | "currentsettings" => Some(SwitchOverride::Current),
|
||||
"on" | "onforchecked" | "onforcheckedlayers" | "true" => Some(SwitchOverride::OnForChecked),
|
||||
"off" | "offforall" | "offforalllayers" | "false" => Some(SwitchOverride::OffForAll),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Render Settings ▸ Effects.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum EffectsMode {
|
||||
#[default]
|
||||
Current,
|
||||
AllOn,
|
||||
AllOff,
|
||||
}
|
||||
|
||||
impl EffectsMode {
|
||||
pub const ALL: [EffectsMode; 3] = [EffectsMode::Current, EffectsMode::AllOn, EffectsMode::AllOff];
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
EffectsMode::Current => "Current Settings",
|
||||
EffectsMode::AllOn => "All On",
|
||||
EffectsMode::AllOff => "All Off",
|
||||
}
|
||||
}
|
||||
pub fn parse(s: &str) -> Option<EffectsMode> {
|
||||
match norm(s).as_str() {
|
||||
"current" | "currentsettings" => Some(EffectsMode::Current),
|
||||
"allon" | "on" => Some(EffectsMode::AllOn),
|
||||
"alloff" | "off" => Some(EffectsMode::AllOff),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Render Settings ▸ Solo Switches / Guide Layers: the current switches, or all off.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum CurrentOrOff {
|
||||
#[default]
|
||||
Current,
|
||||
AllOff,
|
||||
}
|
||||
|
||||
impl CurrentOrOff {
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
CurrentOrOff::Current => "Current Settings",
|
||||
CurrentOrOff::AllOff => "All Off",
|
||||
}
|
||||
}
|
||||
pub fn parse(s: &str) -> Option<CurrentOrOff> {
|
||||
match norm(s).as_str() {
|
||||
"current" | "currentsettings" | "on" => Some(CurrentOrOff::Current),
|
||||
"alloff" | "off" => Some(CurrentOrOff::AllOff),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Render Settings ▸ Color Depth.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ColorDepth {
|
||||
/// The project's bit depth.
|
||||
#[default]
|
||||
Current,
|
||||
Bpc8,
|
||||
Bpc16,
|
||||
Bpc32,
|
||||
}
|
||||
|
||||
impl ColorDepth {
|
||||
pub const ALL: [ColorDepth; 4] = [ColorDepth::Current, ColorDepth::Bpc8, ColorDepth::Bpc16, ColorDepth::Bpc32];
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
ColorDepth::Current => "Current Settings",
|
||||
ColorDepth::Bpc8 => "8 bits per channel",
|
||||
ColorDepth::Bpc16 => "16 bits per channel",
|
||||
ColorDepth::Bpc32 => "32 bits per channel",
|
||||
}
|
||||
}
|
||||
pub fn parse(s: &str) -> Option<ColorDepth> {
|
||||
match norm(s).as_str() {
|
||||
"current" | "currentsettings" => Some(ColorDepth::Current),
|
||||
"8" | "8bpc" | "8bitsperchannel" => Some(ColorDepth::Bpc8),
|
||||
"16" | "16bpc" | "16bitsperchannel" => Some(ColorDepth::Bpc16),
|
||||
"32" | "32bpc" | "32bitsperchannel" | "float" => Some(ColorDepth::Bpc32),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// The depth renders use in a project of depth `project`.
|
||||
pub fn resolve(self, project: crate::BitDepth) -> crate::BitDepth {
|
||||
match self {
|
||||
ColorDepth::Current => project,
|
||||
ColorDepth::Bpc8 => crate::BitDepth::Bpc8,
|
||||
ColorDepth::Bpc16 => crate::BitDepth::Bpc16,
|
||||
ColorDepth::Bpc32 => crate::BitDepth::Bpc32,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How one output frame is sampled in time.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum FrameSample {
|
||||
Progressive(Tick),
|
||||
/// Two fields woven together: `first` goes to the dominant field.
|
||||
Fields {
|
||||
first: Tick,
|
||||
second: Tick,
|
||||
upper_first: bool,
|
||||
},
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct RenderSettings {
|
||||
/// The template these settings came from ("" or "Custom" once edited).
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
pub name: String,
|
||||
pub quality: RenderQuality,
|
||||
/// Output scale (1 = Full, 0.5 = Half, 1/3 = Third, 0.25 = Quarter).
|
||||
pub resolution: f64,
|
||||
pub time_span: TimeSpan,
|
||||
/// `None` = use the comp's frame rate.
|
||||
/// `None` = use the comp's frame rate (Time Sampling ▸ Use comp's frame rate).
|
||||
pub frame_rate: Option<FrameRate>,
|
||||
/// Motion blur for layers with the switch on (and the comp's Enable Motion Blur).
|
||||
/// Motion blur allowed at all (false = Off for All Layers; see `motion_blur_mode`).
|
||||
pub motion_blur: bool,
|
||||
/// Motion Blur: Current Settings / On for Checked Layers / Off for All Layers.
|
||||
pub motion_blur_mode: SwitchOverride,
|
||||
/// Frame Blending: Current Settings / On for Checked Layers / Off for All Layers.
|
||||
pub frame_blending: SwitchOverride,
|
||||
/// Field Render.
|
||||
pub field_render: FieldRender,
|
||||
/// 3:2 Pulldown phase (with field rendering).
|
||||
pub pulldown: Pulldown,
|
||||
/// Effects: Current Settings / All On / All Off.
|
||||
pub effects: EffectsMode,
|
||||
/// Solo Switches: Current Settings / All Off.
|
||||
pub solo: CurrentOrOff,
|
||||
/// Guide Layers: Current Settings / All Off.
|
||||
pub guide_layers: CurrentOrOff,
|
||||
/// Color Depth.
|
||||
pub color_depth: ColorDepth,
|
||||
/// Image sequences: skip frames whose file already exists.
|
||||
pub skip_existing: bool,
|
||||
/// Proxy Use (Best Settings: Use No Proxies).
|
||||
pub proxy_use: ProxyUse,
|
||||
/// Use Storage Overflow: when the output volume is full, continue in the project's overflow
|
||||
/// folders.
|
||||
#[serde(default = "default_true")]
|
||||
pub storage_overflow: bool,
|
||||
}
|
||||
|
||||
impl Default for RenderSettings {
|
||||
fn default() -> Self {
|
||||
RenderSettings {
|
||||
name: String::new(),
|
||||
quality: RenderQuality::Best,
|
||||
resolution: 1.0,
|
||||
time_span: TimeSpan::WorkArea,
|
||||
frame_rate: None,
|
||||
motion_blur: true,
|
||||
motion_blur_mode: SwitchOverride::Current,
|
||||
frame_blending: SwitchOverride::Current,
|
||||
field_render: FieldRender::Off,
|
||||
pulldown: Pulldown::Off,
|
||||
effects: EffectsMode::Current,
|
||||
solo: CurrentOrOff::Current,
|
||||
guide_layers: CurrentOrOff::AllOff,
|
||||
color_depth: ColorDepth::Current,
|
||||
skip_existing: false,
|
||||
proxy_use: ProxyUse::UseNone,
|
||||
storage_overflow: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderSettings {
|
||||
/// Effective Motion Blur override.
|
||||
pub fn motion_blur_override(&self) -> SwitchOverride {
|
||||
if self.motion_blur { self.motion_blur_mode } else { SwitchOverride::OffForAll }
|
||||
}
|
||||
/// How output frame `i` is sampled (field rendering and 3:2 pulldown).
|
||||
pub fn sample(&self, comp: &Comp, i: u64) -> FrameSample {
|
||||
let upper_first = match self.field_render {
|
||||
FieldRender::Off => return FrameSample::Progressive(self.frame_time(comp, i)),
|
||||
FieldRender::UpperFirst => true,
|
||||
FieldRender::LowerFirst => false,
|
||||
};
|
||||
let r = self.rate(comp);
|
||||
if let Some((a, b)) = self.pulldown.fields(i) {
|
||||
// Film frames at 4/5 of the output rate, from the first one starting in the span.
|
||||
let film = FrameRate::new(r.num * 4, r.den * 5);
|
||||
let f0 = Self::ceil_frame(film, self.span(comp).0);
|
||||
return FrameSample::Fields { first: film.tick_of(f0 + a), second: film.tick_of(f0 + b), upper_first };
|
||||
}
|
||||
let f = self.first_frame(comp) + i as i64;
|
||||
let t = r.tick_of(f);
|
||||
let half = Tick((r.tick_of(f + 1).0 - t.0) / 2);
|
||||
FrameSample::Fields { first: t, second: Tick(t.0 + half.0), upper_first }
|
||||
}
|
||||
/// The comp-time span `[start, end)` to render.
|
||||
pub fn span(&self, comp: &Comp) -> (Tick, Tick) {
|
||||
let (a, b) = match self.time_span {
|
||||
@@ -162,11 +453,45 @@ impl RenderSettings {
|
||||
_ => "Custom",
|
||||
}
|
||||
}
|
||||
/// One-line summary shown next to "Render Settings:" (AE shows the template name).
|
||||
/// One-line summary shown next to "Render Settings:" (the template name, like AE).
|
||||
pub fn summary(&self) -> String {
|
||||
if !self.name.is_empty() {
|
||||
return self.name.clone();
|
||||
}
|
||||
let q = if self.quality == RenderQuality::Best { "Best Settings" } else { "Draft Settings" };
|
||||
format!("{q} · {}", self.resolution_label())
|
||||
}
|
||||
/// Every setting as `(label, value)` lines (render logs, the settings dialog).
|
||||
pub fn describe(&self, comp: Option<&Comp>) -> Vec<(&'static str, String)> {
|
||||
let span = match self.time_span {
|
||||
TimeSpan::WorkArea => "Work Area Only".to_string(),
|
||||
TimeSpan::LengthOfComp => "Length of Comp".to_string(),
|
||||
TimeSpan::Custom { start, end } => format!("Custom ({:.3}s – {:.3}s)", start.seconds(), end.seconds()),
|
||||
};
|
||||
let rate = match (self.frame_rate, comp) {
|
||||
(Some(r), _) => format!("{:.3} fps", r.as_f64()),
|
||||
(None, Some(c)) => format!("Use comp's frame rate ({:.3})", c.frame_rate.as_f64()),
|
||||
(None, None) => "Use comp's frame rate".into(),
|
||||
};
|
||||
vec![
|
||||
("Template", if self.name.is_empty() { "Custom".into() } else { self.name.clone() }),
|
||||
("Quality", format!("{:?}", self.quality)),
|
||||
("Resolution", format!("{} ({:.3})", self.resolution_label(), self.resolution)),
|
||||
("Proxy Use", self.proxy_use.label().into()),
|
||||
("Effects", self.effects.label().into()),
|
||||
("Solo Switches", self.solo.label().into()),
|
||||
("Guide Layers", self.guide_layers.label().into()),
|
||||
("Color Depth", self.color_depth.label().into()),
|
||||
("Frame Blending", self.frame_blending.label().into()),
|
||||
("Field Render", self.field_render.label().into()),
|
||||
("3:2 Pulldown", self.pulldown.label().into()),
|
||||
("Motion Blur", self.motion_blur_override().label().into()),
|
||||
("Time Span", span),
|
||||
("Frame Rate", rate),
|
||||
("Skip Existing Files", if self.skip_existing { "On" } else { "Off" }.into()),
|
||||
("Use Storage Overflow", if self.storage_overflow { "On" } else { "Off" }.into()),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/// Output Module ▸ Format.
|
||||
@@ -287,8 +612,30 @@ pub enum Channels {
|
||||
/// Opaque, composited over the comp background colour.
|
||||
#[default]
|
||||
Rgb,
|
||||
/// With straight (unmatted) alpha.
|
||||
/// With alpha (straight or premultiplied: [`AlphaMode`]).
|
||||
Rgba,
|
||||
/// The alpha channel alone, as an opaque greyscale matte.
|
||||
Alpha,
|
||||
}
|
||||
|
||||
impl Channels {
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Channels::Rgb => "RGB",
|
||||
Channels::Rgba => "RGB + Alpha",
|
||||
Channels::Alpha => "Alpha",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Output Module ▸ Color: how RGB is stored with alpha.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum AlphaMode {
|
||||
/// Unmatted colour.
|
||||
#[default]
|
||||
Straight,
|
||||
/// Colour multiplied by alpha (matted with black).
|
||||
Premultiplied,
|
||||
}
|
||||
|
||||
/// Output Module ▸ Audio Output.
|
||||
@@ -301,6 +648,121 @@ pub enum AudioOutput {
|
||||
Off,
|
||||
}
|
||||
|
||||
/// Output Module ▸ Audio Output ▸ sample format (PCM outputs: WAV, AIFF, QuickTime).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum AudioFormat {
|
||||
#[default]
|
||||
S16,
|
||||
S24,
|
||||
/// 32-bit float (WAV, QuickTime; AIFF writes 24-bit).
|
||||
F32,
|
||||
}
|
||||
|
||||
impl AudioFormat {
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
AudioFormat::S16 => "16 Bit",
|
||||
AudioFormat::S24 => "24 Bit",
|
||||
AudioFormat::F32 => "32 Bit Float",
|
||||
}
|
||||
}
|
||||
pub fn parse(s: &str) -> Option<AudioFormat> {
|
||||
match norm(s).as_str() {
|
||||
"16" | "16bit" | "s16" => Some(AudioFormat::S16),
|
||||
"24" | "24bit" | "s24" => Some(AudioFormat::S24),
|
||||
"32" | "32bit" | "32bitfloat" | "f32" | "float" => Some(AudioFormat::F32),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Output Module ▸ Crop (pixels of the rendered frame; negative values add transparent pixels).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Crop {
|
||||
pub enabled: bool,
|
||||
/// Use Region of Interest: crop to `roi` (comp pixels, captured from the viewer's region of
|
||||
/// interest when the option is turned on) instead of the edge values.
|
||||
pub use_roi: bool,
|
||||
pub roi: Option<[f64; 4]>,
|
||||
pub top: i32,
|
||||
pub left: i32,
|
||||
pub bottom: i32,
|
||||
pub right: i32,
|
||||
}
|
||||
|
||||
impl Crop {
|
||||
/// Edges `(top, left, bottom, right)` in pixels of a `w`×`h` frame rendered at `scale`.
|
||||
pub fn edges(&self, w: u32, h: u32, scale: f64) -> (i32, i32, i32, i32) {
|
||||
if !self.enabled {
|
||||
return (0, 0, 0, 0);
|
||||
}
|
||||
if self.use_roi
|
||||
&& let Some([x, y, rw, rh]) = self.roi
|
||||
{
|
||||
let l = (x * scale).round() as i32;
|
||||
let t = (y * scale).round() as i32;
|
||||
let r = w as i32 - ((x + rw) * scale).round() as i32;
|
||||
let b = h as i32 - ((y + rh) * scale).round() as i32;
|
||||
return (t, l, b, r);
|
||||
}
|
||||
(self.top, self.left, self.bottom, self.right)
|
||||
}
|
||||
}
|
||||
|
||||
/// Output Module ▸ Resize quality.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ResizeQuality {
|
||||
/// Bilinear.
|
||||
Low,
|
||||
/// Bicubic.
|
||||
#[default]
|
||||
High,
|
||||
}
|
||||
|
||||
/// Output Module ▸ Resize.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Resize {
|
||||
pub enabled: bool,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
/// Lock Aspect Ratio: setting one dimension derives the other from the rendered frame.
|
||||
pub lock_aspect: bool,
|
||||
pub quality: ResizeQuality,
|
||||
}
|
||||
|
||||
impl Default for Resize {
|
||||
fn default() -> Self {
|
||||
Resize { enabled: false, width: 1920, height: 1080, lock_aspect: true, quality: ResizeQuality::High }
|
||||
}
|
||||
}
|
||||
|
||||
/// Output Module ▸ Resize ▸ Resize to: presets.
|
||||
pub const RESIZE_PRESETS: [(&str, u32, u32); 10] = [
|
||||
("HDTV 1080", 1920, 1080),
|
||||
("HDTV 720", 1280, 720),
|
||||
("UHD 4K", 3840, 2160),
|
||||
("DCI 2K", 2048, 1080),
|
||||
("DCI 4K", 4096, 2160),
|
||||
("NTSC DV", 720, 480),
|
||||
("PAL D1/DV", 720, 576),
|
||||
("Square 1080", 1080, 1080),
|
||||
("Vertical 1080×1920", 1080, 1920),
|
||||
("Web 640×360", 640, 360),
|
||||
];
|
||||
|
||||
/// Output Module ▸ Post-Render Action.
|
||||
pub fn post_render_parse(s: &str) -> Option<PostRenderAction> {
|
||||
match norm(s).as_str() {
|
||||
"none" => Some(PostRenderAction::None),
|
||||
"import" => Some(PostRenderAction::Import),
|
||||
"importreplace" | "importandreplace" | "importandreplaceusage" | "importreplaceusage" | "replace" => Some(PostRenderAction::ImportAndReplace),
|
||||
"setproxy" | "proxy" => Some(PostRenderAction::SetProxy),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// ProRes flavour.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ProResProfile {
|
||||
@@ -341,38 +803,133 @@ impl ProResProfile {
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct OutputModule {
|
||||
/// The template this module came from ("" or "Custom" once edited).
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
pub name: String,
|
||||
pub format: OutputFormat,
|
||||
pub channels: Channels,
|
||||
/// Color: straight or premultiplied RGB with alpha.
|
||||
pub alpha_mode: AlphaMode,
|
||||
/// Output path or template (see the module docs).
|
||||
pub output: String,
|
||||
/// JPEG quality 1–100.
|
||||
/// JPEG / WebM quality 1–100.
|
||||
pub quality: u8,
|
||||
/// H.264 target bitrate.
|
||||
/// H.264 target bitrate (and WebM's when `webm_bitrate`).
|
||||
pub bitrate_kbps: u32,
|
||||
/// WebM: rate control by `bitrate_kbps` instead of `quality`.
|
||||
pub webm_bitrate: bool,
|
||||
/// Movies: frames between key frames (0 = automatic, two seconds).
|
||||
pub keyframe_interval: u32,
|
||||
pub prores_profile: ProResProfile,
|
||||
pub crop: Crop,
|
||||
pub resize: Resize,
|
||||
pub audio: AudioOutput,
|
||||
pub audio_sample_rate: u32,
|
||||
/// 1 (mono) or 2 (stereo).
|
||||
pub audio_channels: u8,
|
||||
pub audio_format: AudioFormat,
|
||||
/// GIF: loop forever.
|
||||
pub gif_loop: bool,
|
||||
/// Include Project Link: kept for parity; EffectCraft's writers have no place to store a
|
||||
/// link back to the project, so it changes nothing in the file.
|
||||
pub include_project_link: bool,
|
||||
}
|
||||
|
||||
impl Default for OutputModule {
|
||||
fn default() -> Self {
|
||||
OutputModule {
|
||||
name: String::new(),
|
||||
format: OutputFormat::H264,
|
||||
channels: Channels::Rgb,
|
||||
alpha_mode: AlphaMode::Straight,
|
||||
output: DEFAULT_TEMPLATE.into(),
|
||||
quality: 90,
|
||||
bitrate_kbps: 10_000,
|
||||
webm_bitrate: false,
|
||||
keyframe_interval: 0,
|
||||
prores_profile: ProResProfile::Hq,
|
||||
crop: Crop::default(),
|
||||
resize: Resize::default(),
|
||||
audio: AudioOutput::Auto,
|
||||
audio_sample_rate: 48_000,
|
||||
audio_channels: 2,
|
||||
audio_format: AudioFormat::S16,
|
||||
gif_loop: true,
|
||||
include_project_link: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl OutputModule {
|
||||
/// The size of the frames this module writes for frames rendered at `w`×`h` with render
|
||||
/// scale `scale`: after Crop and Resize (before codec rounding).
|
||||
pub fn frame_size(&self, w: u32, h: u32, scale: f64) -> (u32, u32) {
|
||||
let (t, l, b, r) = self.crop.edges(w, h, scale);
|
||||
let cw = (w as i32 - l - r).max(1) as u32;
|
||||
let ch = (h as i32 - t - b).max(1) as u32;
|
||||
if !self.resize.enabled {
|
||||
return (cw, ch);
|
||||
}
|
||||
let (mut rw, mut rh) = (self.resize.width.max(1), self.resize.height.max(1));
|
||||
if self.resize.lock_aspect {
|
||||
// Width rules; height follows the cropped frame's aspect.
|
||||
rh = ((rw as f64 * ch as f64 / cw as f64).round() as u32).max(1);
|
||||
}
|
||||
rw = rw.min(30_000);
|
||||
rh = rh.min(30_000);
|
||||
(rw, rh)
|
||||
}
|
||||
/// The written frame size of an item (Render Settings resolution, Crop, Resize, codec
|
||||
/// rounding).
|
||||
pub fn output_size(&self, comp: &Comp, settings: &RenderSettings) -> (u32, u32) {
|
||||
let (w, h) = settings.output_size(comp);
|
||||
let (w, h) = self.frame_size(w, h, settings.resolution.clamp(0.01, 4.0));
|
||||
self.format.coded_size(w, h)
|
||||
}
|
||||
/// Every setting as `(label, value)` lines (render logs).
|
||||
pub fn describe(&self) -> Vec<(&'static str, String)> {
|
||||
let mut v = vec![
|
||||
("Template", if self.name.is_empty() { "Custom".into() } else { self.name.clone() }),
|
||||
("Format", self.format.label().into()),
|
||||
("Channels", self.channels.label().into()),
|
||||
("Output To", self.output.clone()),
|
||||
];
|
||||
if self.channels == Channels::Rgba {
|
||||
v.insert(3, ("Color", format!("{:?}", self.alpha_mode)));
|
||||
}
|
||||
match self.format {
|
||||
OutputFormat::H264 => v.push(("Bitrate", format!("{} kbps", self.bitrate_kbps))),
|
||||
OutputFormat::ProRes => v.push(("Codec", self.prores_profile.label().into())),
|
||||
OutputFormat::WebM if self.webm_bitrate => v.push(("Bitrate", format!("{} kbps", self.bitrate_kbps))),
|
||||
OutputFormat::WebM | OutputFormat::JpegSequence => v.push(("Quality", self.quality.to_string())),
|
||||
_ => {}
|
||||
}
|
||||
if self.crop.enabled {
|
||||
let c = &self.crop;
|
||||
v.push((
|
||||
"Crop",
|
||||
if c.use_roi { format!("Region of Interest {:?}", c.roi) } else { format!("T {} L {} B {} R {}", c.top, c.left, c.bottom, c.right) },
|
||||
));
|
||||
}
|
||||
if self.resize.enabled {
|
||||
let r = &self.resize;
|
||||
v.push(("Resize", format!("{}×{} ({:?} quality{})", r.width, r.height, r.quality, if r.lock_aspect { ", aspect locked" } else { "" })));
|
||||
}
|
||||
if self.format.supports_audio() {
|
||||
v.push((
|
||||
"Audio Output",
|
||||
format!(
|
||||
"{:?} · {} Hz · {} · {}",
|
||||
self.audio,
|
||||
self.audio_sample_rate,
|
||||
if self.audio_channels == 1 { "Mono" } else { "Stereo" },
|
||||
self.audio_format.label()
|
||||
),
|
||||
));
|
||||
}
|
||||
v.push(("Include Project Link", if self.include_project_link { "On" } else { "Off" }.into()));
|
||||
v
|
||||
}
|
||||
pub fn for_format(format: OutputFormat) -> OutputModule {
|
||||
let mut m = OutputModule { format, ..Default::default() };
|
||||
if format.is_sequence() {
|
||||
@@ -384,7 +941,7 @@ impl OutputModule {
|
||||
pub fn set_format(&mut self, format: OutputFormat) {
|
||||
let old = self.format;
|
||||
self.format = format;
|
||||
if !format.supports_alpha() {
|
||||
if !format.supports_alpha() && self.channels == Channels::Rgba {
|
||||
self.channels = Channels::Rgb;
|
||||
}
|
||||
if self.output.contains("[fileExtension]") {
|
||||
@@ -406,10 +963,10 @@ impl OutputModule {
|
||||
}
|
||||
/// One-line summary shown next to "Output Module:".
|
||||
pub fn summary(&self) -> String {
|
||||
let ch = match self.channels {
|
||||
Channels::Rgb => "RGB",
|
||||
Channels::Rgba => "RGB + Alpha",
|
||||
};
|
||||
if !self.name.is_empty() {
|
||||
return self.name.clone();
|
||||
}
|
||||
let ch = self.channels.label();
|
||||
match self.format {
|
||||
OutputFormat::H264 => format!("H.264 · {} kbps", self.bitrate_kbps),
|
||||
OutputFormat::ProRes => format!(
|
||||
@@ -478,6 +1035,85 @@ pub struct RenderQueueItem {
|
||||
/// Post-Render Action of the first output module.
|
||||
#[serde(default, skip_serializing_if = "PostRenderAction::is_none")]
|
||||
pub post_render: PostRenderAction,
|
||||
/// The Log menu: what the render log next to the output records.
|
||||
#[serde(default, skip_serializing_if = "RenderLog::is_default")]
|
||||
pub log: RenderLog,
|
||||
}
|
||||
|
||||
/// Render Queue item ▸ Log.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum RenderLog {
|
||||
/// A log only when the render fails.
|
||||
#[default]
|
||||
ErrorsOnly,
|
||||
/// Always: the result plus every Render Settings and Output Module setting.
|
||||
PlusSettings,
|
||||
/// Also one line per rendered frame.
|
||||
PlusPerFrameInfo,
|
||||
}
|
||||
|
||||
impl RenderLog {
|
||||
pub const ALL: [RenderLog; 3] = [RenderLog::ErrorsOnly, RenderLog::PlusSettings, RenderLog::PlusPerFrameInfo];
|
||||
pub fn is_default(&self) -> bool {
|
||||
*self == RenderLog::ErrorsOnly
|
||||
}
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
RenderLog::ErrorsOnly => "Errors Only",
|
||||
RenderLog::PlusSettings => "Plus Settings",
|
||||
RenderLog::PlusPerFrameInfo => "Plus Per Frame Info",
|
||||
}
|
||||
}
|
||||
pub fn parse(s: &str) -> Option<RenderLog> {
|
||||
match norm(s).as_str() {
|
||||
"errors" | "errorsonly" => Some(RenderLog::ErrorsOnly),
|
||||
"settings" | "plussettings" => Some(RenderLog::PlusSettings),
|
||||
"perframe" | "plusperframe" | "plusperframeinfo" | "frames" => Some(RenderLog::PlusPerFrameInfo),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The render log's path for an output: next to it, `<name>_RenderLog.txt` (a sequence's
|
||||
/// frame-number run is dropped).
|
||||
pub fn log_path(output: &str) -> String {
|
||||
let p = std::path::Path::new(output);
|
||||
let stem = p.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();
|
||||
let mut stem: String = stem.replace("_[#####]", "").replace("[#####]", "");
|
||||
if let Some(i) = stem.find('#') {
|
||||
let n = stem[i..].chars().take_while(|c| *c == '#').count();
|
||||
stem.replace_range(i..i + n, "");
|
||||
}
|
||||
let stem = stem.trim_end_matches(['_', '.', ' ']);
|
||||
let name = format!("{}_RenderLog.txt", if stem.is_empty() { "Render" } else { stem });
|
||||
match p.parent().filter(|d| !d.as_os_str().is_empty()) {
|
||||
Some(d) => d.join(name).to_string_lossy().to_string(),
|
||||
None => name,
|
||||
}
|
||||
}
|
||||
|
||||
/// Storage hook (Render Settings ▸ Use Storage Overflow): whether `bytes` more fit on the volume
|
||||
/// holding `path`. The desktop app has no quota by default; tests and hosts with quotas supply
|
||||
/// one.
|
||||
pub trait StorageQuota: Send + Sync {
|
||||
fn has_room(&self, path: &str, bytes: u64) -> bool;
|
||||
}
|
||||
|
||||
/// Project-wide Render Queue preferences.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct RenderQueuePrefs {
|
||||
/// Notify when the render finishes (a sound and a notification in the desktop app).
|
||||
pub notify: bool,
|
||||
/// Storage overflow folders, used in order when the output volume is full.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub overflow_folders: Vec<String>,
|
||||
}
|
||||
|
||||
impl RenderQueuePrefs {
|
||||
pub fn is_default(&self) -> bool {
|
||||
*self == RenderQueuePrefs::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// What happens after an item renders (Output Module ▸ Post-Render Action).
|
||||
@@ -521,6 +1157,7 @@ impl RenderQueueItem {
|
||||
last_output: None,
|
||||
extra_outputs: vec![],
|
||||
post_render: PostRenderAction::None,
|
||||
log: RenderLog::ErrorsOnly,
|
||||
}
|
||||
}
|
||||
/// Every output module, the first one first.
|
||||
@@ -612,6 +1249,90 @@ mod tests {
|
||||
assert_eq!(ProResProfile::from_name("hq"), Some(ProResProfile::Hq));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pulldown_cadence() {
|
||||
assert_eq!(Pulldown::Wwssw.pattern(), "WWSSW");
|
||||
for p in &Pulldown::ALL[1..] {
|
||||
assert_eq!(p.pattern(), p.label(), "{p:?}");
|
||||
}
|
||||
// 2:3 cadence: ten fields carry four film frames; every output frame starts on film 0.
|
||||
for p in &Pulldown::ALL[1..] {
|
||||
let mut fields = vec![];
|
||||
for k in 0..10 {
|
||||
let (a, b) = p.fields(k).unwrap();
|
||||
fields.extend([a, b]);
|
||||
}
|
||||
assert_eq!(fields[0], 0, "{p:?}");
|
||||
assert!(fields.windows(2).all(|w| w[1] == w[0] || w[1] == w[0] + 1), "{p:?}: {fields:?}");
|
||||
// 20 fields = 8 film frames' worth of cadence (a phase may start or end mid-frame).
|
||||
assert!((7..=8).contains(&(*fields.last().unwrap() - fields[0])), "{p:?} {fields:?}");
|
||||
// Each film frame appears in 2 or 3 consecutive fields, alternating.
|
||||
let mut runs = vec![];
|
||||
let mut n = 1;
|
||||
for w in fields.windows(2) {
|
||||
if w[1] == w[0] {
|
||||
n += 1;
|
||||
} else {
|
||||
runs.push(n);
|
||||
n = 1;
|
||||
}
|
||||
}
|
||||
assert!(runs[1..].iter().all(|r| *r == 2 || *r == 3), "{p:?}: {runs:?}");
|
||||
assert!(runs[1..].windows(2).all(|w| w[0] != w[1]), "2 and 3 alternate: {p:?}: {runs:?}");
|
||||
}
|
||||
assert_eq!(Pulldown::parse("sswww"), Some(Pulldown::Sswww));
|
||||
// With field rendering at 29.97, film frames are 23.976 fps.
|
||||
let c = Comp::new(64, 48, FrameRate::new(30000, 1001), Tick::from_seconds_f64(2.0));
|
||||
let s = RenderSettings { field_render: FieldRender::UpperFirst, pulldown: Pulldown::Wssww, time_span: TimeSpan::LengthOfComp, ..Default::default() };
|
||||
let film = FrameRate::new(24000, 1001);
|
||||
match s.sample(&c, 1) {
|
||||
FrameSample::Fields { first, second, upper_first } => {
|
||||
assert!(upper_first);
|
||||
assert_eq!((first, second), (film.tick_of(0), film.tick_of(1)), "WSSWW: frame 1 is split between film frames 0 and 1");
|
||||
}
|
||||
f => panic!("{f:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn field_samples_are_half_a_frame_apart() {
|
||||
let c = Comp::new(64, 48, FrameRate::new(25, 1), Tick::from_seconds_f64(2.0));
|
||||
let s = RenderSettings { field_render: FieldRender::LowerFirst, time_span: TimeSpan::LengthOfComp, ..Default::default() };
|
||||
assert_eq!(s.sample(&c, 3), FrameSample::Fields { first: Tick::from_seconds_f64(0.12), second: Tick::from_seconds_f64(0.14), upper_first: false });
|
||||
let p = RenderSettings { time_span: TimeSpan::LengthOfComp, ..Default::default() };
|
||||
assert_eq!(p.sample(&c, 3), FrameSample::Progressive(Tick::from_seconds_f64(0.12)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crop_and_resize_sizes() {
|
||||
let c = Comp::new(1920, 1080, FrameRate::new(25, 1), Tick::from_seconds_f64(1.0));
|
||||
let rs = RenderSettings::default();
|
||||
let mut m = OutputModule::default();
|
||||
assert_eq!(m.output_size(&c, &rs), (1920, 1080));
|
||||
m.crop = Crop { enabled: true, top: 10, left: 20, bottom: 30, right: 41, ..Default::default() };
|
||||
assert_eq!(m.output_size(&c, &rs), (1858, 1040), "H.264 rounds the odd width down");
|
||||
m.format = OutputFormat::PngSequence;
|
||||
assert_eq!(m.output_size(&c, &rs), (1859, 1040));
|
||||
// Region of interest, at half resolution.
|
||||
m.crop = Crop { enabled: true, use_roi: true, roi: Some([100.0, 50.0, 800.0, 400.0]), ..Default::default() };
|
||||
let half = RenderSettings { resolution: 0.5, ..Default::default() };
|
||||
assert_eq!(m.output_size(&c, &half), (400, 200));
|
||||
// Negative crop pads.
|
||||
m.crop = Crop { enabled: true, top: -10, bottom: -10, ..Default::default() };
|
||||
assert_eq!(m.output_size(&c, &rs), (1920, 1100));
|
||||
// Resize: fixed, and with the aspect locked to the (cropped) frame.
|
||||
m.crop = Crop::default();
|
||||
m.resize = Resize { enabled: true, width: 1280, height: 999, lock_aspect: false, quality: ResizeQuality::High };
|
||||
assert_eq!(m.output_size(&c, &rs), (1280, 999));
|
||||
m.resize.lock_aspect = true;
|
||||
assert_eq!(m.output_size(&c, &rs), (1280, 720));
|
||||
m.crop = Crop { enabled: true, left: 480, right: 480, ..Default::default() };
|
||||
assert_eq!(m.output_size(&c, &rs), (1280, 1440), "960×1080 cropped, then 1280 wide at 8:9");
|
||||
assert_eq!(log_path("/out/Main_[#####].png"), "/out/Main_RenderLog.txt");
|
||||
assert_eq!(log_path("/out/a.mov"), "/out/a_RenderLog.txt");
|
||||
assert_eq!(log_path("seq###.tif"), "seq_RenderLog.txt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spans() {
|
||||
let mut c = Comp::new(100, 50, FrameRate::new(25, 1), Tick::from_seconds_f64(4.0));
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
//! Render Settings and Output Module templates (Edit ▸ Templates ▸ Render Settings… / Output
|
||||
//! Module…): the built-in presets, templates saved with the project, and the defaults new
|
||||
//! Render Queue items, pre-renders and proxies start from.
|
||||
//!
|
||||
//! Built-ins always exist; a saved template with a built-in's name overrides it. Templates
|
||||
//! carry their name in [`RenderSettings::name`] / [`OutputModule::name`], which the Render Queue
|
||||
//! shows like After Effects does.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::render_queue::*;
|
||||
use effectcraft_time::FrameRate;
|
||||
|
||||
/// A default slot (the Defaults section of the Templates dialogs).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum TemplateSlot {
|
||||
/// New Render Queue items rendering a movie.
|
||||
Movie,
|
||||
/// Single-frame renders (Composition ▸ Save Frame As ▸ File…).
|
||||
Still,
|
||||
/// Composition ▸ Pre-render….
|
||||
PreRender,
|
||||
/// File ▸ Create Proxy ▸ Movie….
|
||||
MovieProxy,
|
||||
/// File ▸ Create Proxy ▸ Still….
|
||||
StillProxy,
|
||||
}
|
||||
|
||||
impl TemplateSlot {
|
||||
pub const ALL: [TemplateSlot; 5] = [TemplateSlot::Movie, TemplateSlot::Still, TemplateSlot::PreRender, TemplateSlot::MovieProxy, TemplateSlot::StillProxy];
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
TemplateSlot::Movie => "Movie Default",
|
||||
TemplateSlot::Still => "Frame Default",
|
||||
TemplateSlot::PreRender => "Pre-Render Default",
|
||||
TemplateSlot::MovieProxy => "Movie Proxy Default",
|
||||
TemplateSlot::StillProxy => "Still Proxy Default",
|
||||
}
|
||||
}
|
||||
pub fn parse(s: &str) -> Option<TemplateSlot> {
|
||||
match s.to_ascii_lowercase().replace([' ', '_', '-'], "").as_str() {
|
||||
"movie" | "moviedefault" => Some(TemplateSlot::Movie),
|
||||
"still" | "frame" | "framedefault" | "stilldefault" => Some(TemplateSlot::Still),
|
||||
"prerender" | "prerenderdefault" => Some(TemplateSlot::PreRender),
|
||||
"movieproxy" | "movieproxydefault" => Some(TemplateSlot::MovieProxy),
|
||||
"stillproxy" | "stillproxydefault" => Some(TemplateSlot::StillProxy),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Which template list.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum TemplateKind {
|
||||
RenderSettings,
|
||||
OutputModule,
|
||||
}
|
||||
|
||||
impl TemplateKind {
|
||||
pub fn parse(s: &str) -> Option<TemplateKind> {
|
||||
match s.to_ascii_lowercase().replace([' ', '_', '-'], "").as_str() {
|
||||
"rendersettings" | "settings" | "render" => Some(TemplateKind::RenderSettings),
|
||||
"outputmodule" | "output" | "module" => Some(TemplateKind::OutputModule),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Defaults per slot (template names).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct TemplateDefaults {
|
||||
pub render_settings: Vec<(TemplateSlot, String)>,
|
||||
pub output_modules: Vec<(TemplateSlot, String)>,
|
||||
}
|
||||
|
||||
impl Default for TemplateDefaults {
|
||||
fn default() -> Self {
|
||||
use TemplateSlot::*;
|
||||
let s = |v: &[(TemplateSlot, &str)]| v.iter().map(|(k, n)| (*k, n.to_string())).collect();
|
||||
TemplateDefaults {
|
||||
render_settings: s(&[
|
||||
(Movie, "Best Settings"),
|
||||
(Still, "Current Settings"),
|
||||
(PreRender, "Best Settings"),
|
||||
(MovieProxy, "Draft Settings"),
|
||||
(StillProxy, "Best Settings"),
|
||||
]),
|
||||
output_modules: s(&[
|
||||
(Movie, "H.264 - Match Render Settings - 15 Mbps"),
|
||||
(Still, "PNG Sequence"),
|
||||
(PreRender, "Lossless with Alpha"),
|
||||
(MovieProxy, "Lossless with Alpha"),
|
||||
(StillProxy, "PNG Sequence with Alpha"),
|
||||
]),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Templates saved with the project, plus the defaults.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct RenderTemplates {
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub render_settings: Vec<RenderSettings>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub output_modules: Vec<OutputModule>,
|
||||
pub defaults: TemplateDefaults,
|
||||
}
|
||||
|
||||
/// The built-in Render Settings templates.
|
||||
pub fn builtin_render_settings() -> Vec<RenderSettings> {
|
||||
let best = RenderSettings {
|
||||
name: "Best Settings".into(),
|
||||
frame_blending: SwitchOverride::OnForChecked,
|
||||
motion_blur_mode: SwitchOverride::OnForChecked,
|
||||
..RenderSettings::default()
|
||||
};
|
||||
let draft = RenderSettings {
|
||||
name: "Draft Settings".into(),
|
||||
quality: RenderQuality::Draft,
|
||||
resolution: 0.5,
|
||||
proxy_use: ProxyUse::CurrentSettings,
|
||||
frame_blending: SwitchOverride::Current,
|
||||
motion_blur_mode: SwitchOverride::Current,
|
||||
..RenderSettings::default()
|
||||
};
|
||||
let dv =
|
||||
RenderSettings { name: "DV Settings".into(), field_render: FieldRender::LowerFirst, frame_rate: Some(FrameRate::new(30000, 1001)), ..best.clone() };
|
||||
let multi = RenderSettings { name: "Multi-Machine Settings".into(), skip_existing: true, ..best.clone() };
|
||||
let current = RenderSettings {
|
||||
name: "Current Settings".into(),
|
||||
proxy_use: ProxyUse::CurrentSettings,
|
||||
guide_layers: CurrentOrOff::Current,
|
||||
..RenderSettings::default()
|
||||
};
|
||||
vec![best, draft, dv, multi, current]
|
||||
}
|
||||
|
||||
fn om(name: &str, format: OutputFormat) -> OutputModule {
|
||||
let mut m = OutputModule::for_format(format);
|
||||
m.name = name.into();
|
||||
m
|
||||
}
|
||||
|
||||
/// The built-in Output Module templates.
|
||||
pub fn builtin_output_modules() -> Vec<OutputModule> {
|
||||
let mut v = vec![];
|
||||
let mut hq = om("High Quality", OutputFormat::ProRes);
|
||||
hq.prores_profile = ProResProfile::Hq;
|
||||
v.push(hq);
|
||||
let mut hqa = om("High Quality with Alpha", OutputFormat::ProRes);
|
||||
hqa.prores_profile = ProResProfile::P4444;
|
||||
hqa.channels = Channels::Rgba;
|
||||
hqa.alpha_mode = AlphaMode::Premultiplied;
|
||||
v.push(hqa);
|
||||
// Lossless: the highest-fidelity movie codec this build writes (ProRes 4444 XQ).
|
||||
let mut ll = om("Lossless", OutputFormat::ProRes);
|
||||
ll.prores_profile = ProResProfile::P4444Xq;
|
||||
v.push(ll);
|
||||
let mut lla = om("Lossless with Alpha", OutputFormat::ProRes);
|
||||
lla.prores_profile = ProResProfile::P4444Xq;
|
||||
lla.channels = Channels::Rgba;
|
||||
v.push(lla);
|
||||
for mbps in [5u32, 15, 40] {
|
||||
let mut h = om(&format!("H.264 - Match Render Settings - {mbps} Mbps"), OutputFormat::H264);
|
||||
h.bitrate_kbps = mbps * 1000;
|
||||
v.push(h);
|
||||
}
|
||||
let mut alpha = om("Alpha Only", OutputFormat::ProRes);
|
||||
alpha.channels = Channels::Alpha;
|
||||
v.push(alpha);
|
||||
v.push(om("AIFF 48kHz", OutputFormat::Aiff));
|
||||
v.push(om("WAV 48kHz", OutputFormat::Wav));
|
||||
v.push(om("PNG Sequence", OutputFormat::PngSequence));
|
||||
let mut pa = om("PNG Sequence with Alpha", OutputFormat::PngSequence);
|
||||
pa.channels = Channels::Rgba;
|
||||
v.push(pa);
|
||||
let mut ta = om("TIFF Sequence with Alpha", OutputFormat::TiffSequence);
|
||||
ta.channels = Channels::Rgba;
|
||||
v.push(ta);
|
||||
v.push(om("JPEG Sequence", OutputFormat::JpegSequence));
|
||||
let mut exr = om("OpenEXR Sequence with Alpha", OutputFormat::ExrSequence);
|
||||
exr.channels = Channels::Rgba;
|
||||
v.push(exr);
|
||||
v.push(om("Animated GIF", OutputFormat::Gif));
|
||||
v.push(om("WebM (VP9 + Opus)", OutputFormat::WebM));
|
||||
let mut wa = om("WebM with Alpha", OutputFormat::WebM);
|
||||
wa.channels = Channels::Rgba;
|
||||
v.push(wa);
|
||||
v
|
||||
}
|
||||
|
||||
fn same(a: &str, b: &str) -> bool {
|
||||
a.trim().eq_ignore_ascii_case(b.trim())
|
||||
}
|
||||
|
||||
impl RenderTemplates {
|
||||
pub fn is_default(&self) -> bool {
|
||||
*self == RenderTemplates::default()
|
||||
}
|
||||
/// Every Render Settings template: built-ins (unless overridden), then saved ones.
|
||||
pub fn render_settings_list(&self) -> Vec<RenderSettings> {
|
||||
let mut v: Vec<RenderSettings> =
|
||||
builtin_render_settings().into_iter().filter(|b| !self.render_settings.iter().any(|u| same(&u.name, &b.name))).collect();
|
||||
v.extend(self.render_settings.iter().cloned());
|
||||
v
|
||||
}
|
||||
pub fn output_module_list(&self) -> Vec<OutputModule> {
|
||||
let mut v: Vec<OutputModule> = builtin_output_modules().into_iter().filter(|b| !self.output_modules.iter().any(|u| same(&u.name, &b.name))).collect();
|
||||
v.extend(self.output_modules.iter().cloned());
|
||||
v
|
||||
}
|
||||
pub fn render_settings(&self, name: &str) -> Option<RenderSettings> {
|
||||
self.render_settings_list().into_iter().find(|t| same(&t.name, name))
|
||||
}
|
||||
pub fn output_module(&self, name: &str) -> Option<OutputModule> {
|
||||
self.output_module_list().into_iter().find(|t| same(&t.name, name))
|
||||
}
|
||||
pub fn is_builtin(kind: TemplateKind, name: &str) -> bool {
|
||||
match kind {
|
||||
TemplateKind::RenderSettings => builtin_render_settings().iter().any(|t| same(&t.name, name)),
|
||||
TemplateKind::OutputModule => builtin_output_modules().iter().any(|t| same(&t.name, name)),
|
||||
}
|
||||
}
|
||||
/// Save (or replace) a template under `name`.
|
||||
pub fn save_render_settings(&mut self, name: &str, mut s: RenderSettings) {
|
||||
s.name = name.trim().to_string();
|
||||
self.render_settings.retain(|t| !same(&t.name, name));
|
||||
self.render_settings.push(s);
|
||||
}
|
||||
pub fn save_output_module(&mut self, name: &str, mut m: OutputModule) {
|
||||
m.name = name.trim().to_string();
|
||||
self.output_modules.retain(|t| !same(&t.name, name));
|
||||
self.output_modules.push(m);
|
||||
}
|
||||
/// Delete a saved template (a built-in it overrode comes back). Built-ins themselves can't
|
||||
/// be deleted.
|
||||
pub fn delete(&mut self, kind: TemplateKind, name: &str) -> Result<(), String> {
|
||||
let before = (self.render_settings.len(), self.output_modules.len());
|
||||
match kind {
|
||||
TemplateKind::RenderSettings => self.render_settings.retain(|t| !same(&t.name, name)),
|
||||
TemplateKind::OutputModule => self.output_modules.retain(|t| !same(&t.name, name)),
|
||||
}
|
||||
if before != (self.render_settings.len(), self.output_modules.len()) {
|
||||
return Ok(());
|
||||
}
|
||||
if Self::is_builtin(kind, name) { Err(format!("“{name}” is a built-in template")) } else { Err(format!("no template “{name}”")) }
|
||||
}
|
||||
/// The default template name of a slot.
|
||||
pub fn default_name(&self, kind: TemplateKind, slot: TemplateSlot) -> String {
|
||||
let list = match kind {
|
||||
TemplateKind::RenderSettings => &self.defaults.render_settings,
|
||||
TemplateKind::OutputModule => &self.defaults.output_modules,
|
||||
};
|
||||
list.iter().find(|(s, _)| *s == slot).map(|(_, n)| n.clone()).unwrap_or_else(|| {
|
||||
let d = TemplateDefaults::default();
|
||||
let l = if kind == TemplateKind::RenderSettings { d.render_settings } else { d.output_modules };
|
||||
l.into_iter().find(|(s, _)| *s == slot).map(|(_, n)| n).unwrap_or_default()
|
||||
})
|
||||
}
|
||||
pub fn set_default(&mut self, kind: TemplateKind, slot: TemplateSlot, name: &str) -> Result<(), String> {
|
||||
let exists = match kind {
|
||||
TemplateKind::RenderSettings => self.render_settings(name).is_some(),
|
||||
TemplateKind::OutputModule => self.output_module(name).is_some(),
|
||||
};
|
||||
if !exists {
|
||||
return Err(format!("no template “{name}”"));
|
||||
}
|
||||
let list = match kind {
|
||||
TemplateKind::RenderSettings => &mut self.defaults.render_settings,
|
||||
TemplateKind::OutputModule => &mut self.defaults.output_modules,
|
||||
};
|
||||
list.retain(|(s, _)| *s != slot);
|
||||
list.push((slot, name.trim().to_string()));
|
||||
Ok(())
|
||||
}
|
||||
/// The Render Settings a slot starts from (a missing template falls back to the built-in
|
||||
/// default).
|
||||
pub fn default_render_settings(&self, slot: TemplateSlot) -> RenderSettings {
|
||||
self.render_settings(&self.default_name(TemplateKind::RenderSettings, slot)).unwrap_or_else(|| builtin_render_settings().remove(0))
|
||||
}
|
||||
pub fn default_output_module(&self, slot: TemplateSlot) -> OutputModule {
|
||||
self.output_module(&self.default_name(TemplateKind::OutputModule, slot)).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn builtins_defaults_and_round_trip() {
|
||||
let mut t = RenderTemplates::default();
|
||||
let names: Vec<String> = t.render_settings_list().into_iter().map(|s| s.name).collect();
|
||||
assert_eq!(names, ["Best Settings", "Draft Settings", "DV Settings", "Multi-Machine Settings", "Current Settings"]);
|
||||
assert_eq!(t.default_render_settings(TemplateSlot::Movie).name, "Best Settings");
|
||||
assert_eq!(t.default_output_module(TemplateSlot::PreRender).channels, Channels::Rgba);
|
||||
let dv = t.render_settings("dv settings").unwrap();
|
||||
assert_eq!(dv.field_render, FieldRender::LowerFirst);
|
||||
// Save a custom template, make it the movie default, round-trip through JSON.
|
||||
let mut s = RenderSettings { resolution: 0.25, ..Default::default() };
|
||||
s.color_depth = ColorDepth::Bpc16;
|
||||
t.save_render_settings("Quarter 16", s);
|
||||
t.set_default(TemplateKind::RenderSettings, TemplateSlot::Movie, "Quarter 16").unwrap();
|
||||
let mut m = OutputModule::for_format(OutputFormat::WebM);
|
||||
m.crop = Crop { enabled: true, top: 4, left: 2, ..Default::default() };
|
||||
t.save_output_module("Web Crop", m);
|
||||
let json = serde_json::to_string(&t).unwrap();
|
||||
let back: RenderTemplates = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back, t);
|
||||
assert_eq!(back.default_render_settings(TemplateSlot::Movie).color_depth, ColorDepth::Bpc16);
|
||||
assert_eq!(back.output_module("web crop").unwrap().crop.top, 4);
|
||||
assert!(t.delete(TemplateKind::RenderSettings, "Best Settings").is_err());
|
||||
t.delete(TemplateKind::OutputModule, "Web Crop").unwrap();
|
||||
assert!(t.output_module("Web Crop").is_none());
|
||||
// Overriding a built-in, then deleting the override, restores it.
|
||||
t.save_output_module("High Quality", OutputModule::for_format(OutputFormat::Gif));
|
||||
assert_eq!(t.output_module("High Quality").unwrap().format, OutputFormat::Gif);
|
||||
t.delete(TemplateKind::OutputModule, "High Quality").unwrap();
|
||||
assert_eq!(t.output_module("High Quality").unwrap().format, OutputFormat::ProRes);
|
||||
}
|
||||
}
|
||||
@@ -73,6 +73,8 @@ pub enum Dialog {
|
||||
Precompose,
|
||||
/// Layer ▸ Layer Styles ▸ Layer Style Options…
|
||||
LayerStyles,
|
||||
/// Edit ▸ Templates ▸ Render Settings… / Output Module….
|
||||
RenderTemplates,
|
||||
}
|
||||
|
||||
/// Host hooks provided by the native app (file pickers etc.).
|
||||
|
||||
@@ -506,8 +506,18 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu
|
||||
}
|
||||
"app.templates" => {
|
||||
let kind = p.get("kind").and_then(Value::as_str).unwrap_or("renderSettings");
|
||||
let title = if kind == "outputModule" { "Output Module Templates" } else { "Render Settings Templates" };
|
||||
crate::panels::dialogs::info(app, title, "Render and output templates are managed from the Render Queue panel (Window ▸ Render Queue).");
|
||||
crate::panels::rq_templates::open(app, ctx, kind);
|
||||
Value::Null
|
||||
}
|
||||
"renderQueue.notify" => {
|
||||
// Render finished with Notify on: a toast, the dock/taskbar attention request, and the
|
||||
// host's system sound (the desktop app handles `renderQueue.notify`).
|
||||
let msg = p.get("message").and_then(Value::as_str).unwrap_or("Render finished").to_string();
|
||||
app.toast = Some((msg, now));
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::RequestUserAttention(egui::UserAttentionType::Informational));
|
||||
if let Some(f) = app.hooks.app_action.as_ref() {
|
||||
f("renderQueue.notify");
|
||||
}
|
||||
Value::Null
|
||||
}
|
||||
"app.find" => {
|
||||
|
||||
@@ -129,6 +129,7 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context) {
|
||||
Dialog::LayerStyles => super::layer_styles_dialog::show(app, ctx, &t),
|
||||
Dialog::TrackTarget => super::tracker::target_dialog(app, ctx, &t),
|
||||
Dialog::TrackApply => super::tracker::apply_dialog(app, ctx, &t),
|
||||
Dialog::RenderTemplates => super::rq_templates::show(app, ctx, &t),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ pub mod properties;
|
||||
pub mod puppet_tool;
|
||||
pub mod render_queue;
|
||||
pub mod roto_tool;
|
||||
pub mod rq_templates;
|
||||
pub mod script_console;
|
||||
pub mod settings;
|
||||
pub mod shortcut_editor;
|
||||
|
||||
@@ -4,10 +4,18 @@
|
||||
//! Output Module and Output To rows, and a status footer.
|
||||
//!
|
||||
//! Every control dispatches an engine `renderQueue.*` command (so agents can do the same) and
|
||||
//! registers an automation id: `renderQueue.render`, `renderQueue.stop`,
|
||||
//! `renderQueue.item.<n>.{row,twirl,render,renderSettings,outputModule,outputTo,outputPath}`.
|
||||
//! registers an automation id: `renderQueue.render`, `renderQueue.stop`, `renderQueue.notify`,
|
||||
//! `renderQueue.item.<n>.{row,twirl,render,renderSettings,outputModule,outputTo,outputPath,log}`.
|
||||
//!
|
||||
//! The Render Settings and Output Module menus list the templates first (Edit ▸ Templates), then
|
||||
//! every setting; entries needing numbers (custom time span and frame rate, crop, resize, bitrate,
|
||||
//! key frame interval, Make Template) open a form that runs the same command.
|
||||
|
||||
use effectcraft_engine::project::render_queue::{AudioOutput, Channels, OutputFormat, ProResProfile, RenderQuality, RenderQueueItem, RenderStatus, TimeSpan};
|
||||
use effectcraft_engine::project::render_queue::{
|
||||
AlphaMode, AudioFormat, AudioOutput, Channels, ColorDepth, CurrentOrOff, EffectsMode, FieldRender, OutputFormat, PostRenderAction, ProResProfile, Pulldown,
|
||||
RESIZE_PRESETS, RenderLog, RenderQuality, RenderQueueItem, RenderStatus, ResizeQuality, SwitchOverride, TimeSpan,
|
||||
};
|
||||
use effectcraft_engine::project::render_templates::RenderTemplates;
|
||||
use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -53,42 +61,82 @@ fn status_color(t: &Tokens, s: &RenderStatus) -> Color32 {
|
||||
}
|
||||
}
|
||||
|
||||
/// Render Settings menu: (label, params).
|
||||
fn settings_menu(it: &RenderQueueItem) -> Vec<(String, Value)> {
|
||||
fn mark(on: bool, l: &str) -> String {
|
||||
if on { format!("✓ {l}") } else { format!(" {l}") }
|
||||
}
|
||||
|
||||
/// Render Settings menu: (label, params). `{"form": …}` entries open a form.
|
||||
fn settings_menu(it: &RenderQueueItem, templates: &RenderTemplates) -> Vec<(String, Value)> {
|
||||
let s = &it.settings;
|
||||
let mark = |on: bool, l: &str| if on { format!("✓ {l}") } else { format!(" {l}") };
|
||||
let mut v = vec![
|
||||
(mark(s.quality == RenderQuality::Best, "Best Settings"), json!({"quality": "best"})),
|
||||
(mark(s.quality == RenderQuality::Draft, "Draft Settings"), json!({"quality": "draft"})),
|
||||
("-".into(), Value::Null),
|
||||
];
|
||||
let mut v = vec![];
|
||||
for t in templates.render_settings_list() {
|
||||
v.push((mark(s.name == t.name, &t.name), json!({"template": t.name})));
|
||||
}
|
||||
v.push((" Make Template…".into(), json!({"form": "makeRenderTemplate"})));
|
||||
v.push(("-".into(), Value::Null));
|
||||
v.push((mark(s.quality == RenderQuality::Best, "Quality: Best"), json!({"quality": "best"})));
|
||||
v.push((mark(s.quality == RenderQuality::Draft, "Quality: Draft"), json!({"quality": "draft"})));
|
||||
for (l, k) in [("Full", "full"), ("Half", "half"), ("Third", "third"), ("Quarter", "quarter")] {
|
||||
v.push((mark(s.resolution_label() == l, &format!("Resolution: {l}")), json!({"resolution": k})));
|
||||
}
|
||||
v.push(("-".into(), Value::Null));
|
||||
for (u, k) in effectcraft_engine::project::render_queue::ProxyUse::ALL.iter().zip(["current", "all", "comp", "none"]) {
|
||||
v.push((mark(s.proxy_use == *u, &format!("Proxy Use: {}", u.label())), json!({"proxyUse": k})));
|
||||
}
|
||||
for (e, k) in EffectsMode::ALL.iter().zip(["current", "allOn", "allOff"]) {
|
||||
v.push((mark(s.effects == *e, &format!("Effects: {}", e.label())), json!({"effects": k})));
|
||||
}
|
||||
for (e, k) in [(CurrentOrOff::Current, "current"), (CurrentOrOff::AllOff, "allOff")] {
|
||||
v.push((mark(s.solo == e, &format!("Solo Switches: {}", e.label())), json!({"solo": k})));
|
||||
}
|
||||
for (e, k) in [(CurrentOrOff::Current, "current"), (CurrentOrOff::AllOff, "allOff")] {
|
||||
v.push((mark(s.guide_layers == e, &format!("Guide Layers: {}", e.label())), json!({"guideLayers": k})));
|
||||
}
|
||||
for (d, k) in ColorDepth::ALL.iter().zip(["current", "8", "16", "32"]) {
|
||||
v.push((mark(s.color_depth == *d, &format!("Color Depth: {}", d.label())), json!({"colorDepth": k})));
|
||||
}
|
||||
v.push(("-".into(), Value::Null));
|
||||
for (m, k) in SwitchOverride::ALL.iter().zip(["current", "onForChecked", "offForAll"]) {
|
||||
v.push((mark(s.frame_blending == *m, &format!("Frame Blending: {}", m.label())), json!({"frameBlending": k})));
|
||||
}
|
||||
for (m, k) in SwitchOverride::ALL.iter().zip(["current", "onForChecked", "offForAll"]) {
|
||||
v.push((mark(s.motion_blur_override() == *m, &format!("Motion Blur: {}", m.label())), json!({"motionBlur": k})));
|
||||
}
|
||||
for (f, k) in FieldRender::ALL.iter().zip(["off", "upper", "lower"]) {
|
||||
v.push((mark(s.field_render == *f, &format!("Field Render: {}", f.label())), json!({"fieldRender": k})));
|
||||
}
|
||||
if s.field_render != FieldRender::Off {
|
||||
for pd in Pulldown::ALL {
|
||||
v.push((mark(s.pulldown == pd, &format!("3:2 Pulldown: {}", pd.label())), json!({"pulldown": pd.label()})));
|
||||
}
|
||||
}
|
||||
v.push(("-".into(), Value::Null));
|
||||
v.push((mark(s.time_span == TimeSpan::WorkArea, "Time Span: Work Area Only"), json!({"timeSpan": "workArea"})));
|
||||
v.push((mark(s.time_span == TimeSpan::LengthOfComp, "Time Span: Length of Comp"), json!({"timeSpan": "comp"})));
|
||||
v.push(("-".into(), Value::Null));
|
||||
v.push((mark(matches!(s.time_span, TimeSpan::Custom { .. }), "Time Span: Custom…"), json!({"form": "timeSpan"})));
|
||||
v.push((mark(s.frame_rate.is_none(), "Frame Rate: Use comp's frame rate"), json!({"frameRate": null})));
|
||||
for fps in [12.0, 15.0, 23.976, 24.0, 25.0, 29.97, 30.0, 50.0, 60.0] {
|
||||
let on = s.frame_rate.is_some_and(|r| (r.as_f64() - fps).abs() < 0.01);
|
||||
v.push((mark(on, &format!("Frame Rate: {fps}")), json!({"frameRate": fps})));
|
||||
}
|
||||
v.push((" Frame Rate: Custom…".into(), json!({"form": "frameRate"})));
|
||||
v.push(("-".into(), Value::Null));
|
||||
v.push((mark(s.motion_blur, "Motion Blur: On for Checked Layers"), json!({"motionBlur": !s.motion_blur})));
|
||||
v.push((mark(s.skip_existing, "Skip Existing Files"), json!({"skipExisting": !s.skip_existing})));
|
||||
v.push(("-".into(), Value::Null));
|
||||
for (u, k) in effectcraft_engine::project::render_queue::ProxyUse::ALL.iter().zip(["current", "all", "comp", "none"]) {
|
||||
v.push((mark(s.proxy_use == *u, &format!("Proxy Use: {}", u.label())), json!({"proxyUse": k})));
|
||||
}
|
||||
v.push((mark(s.storage_overflow, "Use Storage Overflow"), json!({"storageOverflow": !s.storage_overflow})));
|
||||
v
|
||||
}
|
||||
|
||||
/// Output Module menu: (label, params).
|
||||
fn output_menu(it: &RenderQueueItem, available: &[OutputFormat]) -> Vec<(String, Value)> {
|
||||
/// Output Module menu: (label, params). `{"form": …}` entries open a form.
|
||||
fn output_menu(it: &RenderQueueItem, available: &[OutputFormat], templates: &RenderTemplates) -> Vec<(String, Value)> {
|
||||
let o = &it.output;
|
||||
let mark = |on: bool, l: &str| if on { format!("✓ {l}") } else { format!(" {l}") };
|
||||
let mut v = Vec::new();
|
||||
for t in templates.output_module_list() {
|
||||
if available.contains(&t.format) {
|
||||
v.push((mark(o.name == t.name, &t.name), json!({"template": t.name})));
|
||||
}
|
||||
}
|
||||
v.push((" Make Template…".into(), json!({"form": "makeOutputTemplate"})));
|
||||
v.push(("-".into(), Value::Null));
|
||||
for f in OutputFormat::ALL {
|
||||
if available.contains(&f) {
|
||||
v.push((mark(o.format == f, &format!("Format: {}", f.label())), json!({"format": format!("{f:?}")})));
|
||||
@@ -99,6 +147,13 @@ fn output_menu(it: &RenderQueueItem, available: &[OutputFormat]) -> Vec<(String,
|
||||
if o.format.supports_alpha() {
|
||||
v.push((mark(o.channels == Channels::Rgba, "Channels: RGB + Alpha"), json!({"channels": "rgba"})));
|
||||
}
|
||||
if !o.format.is_audio_only() {
|
||||
v.push((mark(o.channels == Channels::Alpha, "Channels: Alpha"), json!({"channels": "alpha"})));
|
||||
}
|
||||
if o.channels == Channels::Rgba {
|
||||
v.push((mark(o.alpha_mode == AlphaMode::Straight, "Color: Straight (Unmatted)"), json!({"color": "straight"})));
|
||||
v.push((mark(o.alpha_mode == AlphaMode::Premultiplied, "Color: Premultiplied (Matted)"), json!({"color": "premultiplied"})));
|
||||
}
|
||||
match o.format {
|
||||
OutputFormat::ProRes => {
|
||||
v.push(("-".into(), Value::Null));
|
||||
@@ -108,14 +163,18 @@ fn output_menu(it: &RenderQueueItem, available: &[OutputFormat]) -> Vec<(String,
|
||||
}
|
||||
OutputFormat::H264 => {
|
||||
v.push(("-".into(), Value::Null));
|
||||
for kbps in [2_000, 5_000, 10_000, 20_000, 40_000] {
|
||||
for kbps in [2_000, 5_000, 10_000, 15_000, 20_000, 40_000] {
|
||||
v.push((mark(o.bitrate_kbps == kbps, &format!("Bitrate: {} Mbps", kbps / 1000)), json!({"bitrate": kbps})));
|
||||
}
|
||||
}
|
||||
OutputFormat::JpegSequence | OutputFormat::WebM => {
|
||||
v.push(("-".into(), Value::Null));
|
||||
for q in [60u8, 80, 90, 100] {
|
||||
v.push((mark(o.quality == q, &format!("Quality: {q}")), json!({"quality": q})));
|
||||
v.push((mark(o.quality == q && !o.webm_bitrate, &format!("Quality: {q}")), json!({"quality": q, "webmBitrate": false})));
|
||||
}
|
||||
if o.format == OutputFormat::WebM {
|
||||
v.push((mark(o.webm_bitrate, &format!("Target Bitrate ({} kbps)…", o.bitrate_kbps)), json!({"form": "bitrate"})));
|
||||
v.push((mark(o.keyframe_interval > 0, "Key Frame Interval…"), json!({"form": "keyframes"})));
|
||||
}
|
||||
}
|
||||
OutputFormat::Gif => {
|
||||
@@ -124,20 +183,131 @@ fn output_menu(it: &RenderQueueItem, available: &[OutputFormat]) -> Vec<(String,
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if !o.format.is_audio_only() {
|
||||
v.push(("-".into(), Value::Null));
|
||||
v.push((mark(!o.crop.enabled, "Crop: Off"), json!({"crop": false})));
|
||||
v.push((mark(o.crop.enabled && o.crop.use_roi, "Crop: Use Region of Interest"), json!({"crop": {"useRoi": true}})));
|
||||
v.push((mark(o.crop.enabled && !o.crop.use_roi, "Crop: Edges…"), json!({"form": "crop"})));
|
||||
v.push((mark(!o.resize.enabled, "Resize: Off"), json!({"resize": false})));
|
||||
for (n, w, h) in RESIZE_PRESETS {
|
||||
let on = o.resize.enabled && o.resize.width == w && o.resize.height == h && !o.resize.lock_aspect;
|
||||
v.push((mark(on, &format!("Resize to: {n} ({w}×{h})")), json!({"resize": {"preset": n}})));
|
||||
}
|
||||
v.push((mark(o.resize.enabled, "Resize to: Custom…"), json!({"form": "resize"})));
|
||||
v.push((
|
||||
mark(o.resize.lock_aspect, "Resize: Lock Aspect Ratio"),
|
||||
json!({"resize": {"enabled": o.resize.enabled, "lockAspect": !o.resize.lock_aspect}}),
|
||||
));
|
||||
for (q, k) in [(ResizeQuality::Low, "low"), (ResizeQuality::High, "high")] {
|
||||
v.push((mark(o.resize.quality == q, &format!("Resize Quality: {q:?}")), json!({"resize": {"enabled": o.resize.enabled, "quality": k}})));
|
||||
}
|
||||
}
|
||||
v.push(("-".into(), Value::Null));
|
||||
for (a, k) in [
|
||||
(PostRenderAction::None, "none"),
|
||||
(PostRenderAction::Import, "import"),
|
||||
(PostRenderAction::ImportAndReplace, "importAndReplace"),
|
||||
(PostRenderAction::SetProxy, "setProxy"),
|
||||
] {
|
||||
v.push((mark(it.post_render == a, &format!("Post-Render Action: {}", a.label())), json!({"postRenderAction": k})));
|
||||
}
|
||||
v.push((mark(o.include_project_link, "Include Project Link"), json!({"includeProjectLink": !o.include_project_link})));
|
||||
if o.format.supports_audio() {
|
||||
v.push(("-".into(), Value::Null));
|
||||
for (a, l) in [(AudioOutput::Auto, "auto"), (AudioOutput::On, "on"), (AudioOutput::Off, "off")] {
|
||||
let label = match a {
|
||||
AudioOutput::Auto => "Audio Output: Auto",
|
||||
AudioOutput::On => "Audio Output: On (48 kHz stereo)",
|
||||
AudioOutput::On => "Audio Output: On",
|
||||
AudioOutput::Off => "Audio Output: Off",
|
||||
};
|
||||
v.push((mark(o.audio == a, label), json!({"audio": l})));
|
||||
}
|
||||
for sr in [44_100u32, 48_000, 96_000] {
|
||||
v.push((mark(o.audio_sample_rate == sr, &format!("Sample Rate: {:.1} kHz", sr as f64 / 1000.0)), json!({"sampleRate": sr})));
|
||||
}
|
||||
v.push((mark(o.audio_channels == 1, "Audio Channels: Mono"), json!({"audioChannels": "mono"})));
|
||||
v.push((mark(o.audio_channels != 1, "Audio Channels: Stereo"), json!({"audioChannels": "stereo"})));
|
||||
if matches!(o.format, OutputFormat::ProRes | OutputFormat::Wav | OutputFormat::Aiff) {
|
||||
for f in [AudioFormat::S16, AudioFormat::S24, AudioFormat::F32] {
|
||||
v.push((mark(o.audio_format == f, &format!("Sample Format: {}", f.label())), json!({"audioFormat": f.label()})));
|
||||
}
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// Open the form behind a `{"form": …}` menu entry.
|
||||
fn open_menu_form(app: &mut EffectcraftApp, it: &RenderQueueItem, form: &str) {
|
||||
use crate::panels::forms::{Field, form as open};
|
||||
let id = it.id;
|
||||
let o = &it.output;
|
||||
match form {
|
||||
"makeRenderTemplate" => open(
|
||||
app,
|
||||
"Make Render Settings Template",
|
||||
"renderQueue.saveTemplate",
|
||||
json!({"kind": "renderSettings", "item": id}),
|
||||
vec![Field::text("name", "Template name", "")],
|
||||
),
|
||||
"makeOutputTemplate" => open(
|
||||
app,
|
||||
"Make Output Module Template",
|
||||
"renderQueue.saveTemplate",
|
||||
json!({"kind": "outputModule", "item": id}),
|
||||
vec![Field::text("name", "Template name", "")],
|
||||
),
|
||||
"timeSpan" => {
|
||||
let comp = app.session.project.comp(it.comp);
|
||||
let (a, b) = comp.map(|c| it.settings.span(c)).unwrap_or_default();
|
||||
open(
|
||||
app,
|
||||
"Custom Time Span",
|
||||
"renderQueue.setRenderSettings",
|
||||
json!({"item": id, "timeSpan": "custom"}),
|
||||
vec![Field::num("start", "Start (s)", a.seconds()), Field::num("duration", "Duration (s)", (b.seconds() - a.seconds()).max(0.04))],
|
||||
)
|
||||
}
|
||||
"frameRate" => {
|
||||
let r = app.session.project.comp(it.comp).map(|c| it.settings.rate(c).as_f64()).unwrap_or(30.0);
|
||||
open(app, "Use This Frame Rate", "renderQueue.setRenderSettings", json!({"item": id}), vec![Field::num("frameRate", "Frames per second", r)])
|
||||
}
|
||||
"crop" => open(
|
||||
app,
|
||||
"Crop",
|
||||
"renderQueue.setOutputModule",
|
||||
json!({"item": id}),
|
||||
vec![
|
||||
Field::num("cropTop", "Top", o.crop.top as f64),
|
||||
Field::num("cropLeft", "Left", o.crop.left as f64),
|
||||
Field::num("cropBottom", "Bottom", o.crop.bottom as f64),
|
||||
Field::num("cropRight", "Right", o.crop.right as f64),
|
||||
],
|
||||
),
|
||||
"resize" => open(
|
||||
app,
|
||||
"Resize to",
|
||||
"renderQueue.setOutputModule",
|
||||
json!({"item": id}),
|
||||
vec![Field::num("resizeWidth", "Width", o.resize.width as f64), Field::num("resizeHeight", "Height", o.resize.height as f64)],
|
||||
),
|
||||
"bitrate" => open(
|
||||
app,
|
||||
"WebM Target Bitrate",
|
||||
"renderQueue.setOutputModule",
|
||||
json!({"item": id, "webmBitrate": true}),
|
||||
vec![Field::num("bitrate", "Bitrate (kbps)", o.bitrate_kbps as f64)],
|
||||
),
|
||||
"keyframes" => open(
|
||||
app,
|
||||
"Key Frame Interval",
|
||||
"renderQueue.setOutputModule",
|
||||
json!({"item": id}),
|
||||
vec![Field::num("keyframeInterval", "Frames between key frames (0 = every 2 s)", o.keyframe_interval as f64)],
|
||||
),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
const OUTPUT_TEMPLATES: [&str; 4] = [
|
||||
"[compName].[fileExtension]",
|
||||
"[compName]_[width]x[height].[fileExtension]",
|
||||
@@ -166,6 +336,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let rendering = app.session.is_rendering();
|
||||
let available = app.session.exporter.as_ref().map(|e| e.formats()).unwrap_or_default();
|
||||
let mut actions: Vec<(&'static str, Value)> = Vec::new();
|
||||
let mut forms: Vec<(RenderQueueItem, String)> = Vec::new();
|
||||
let state_id = egui::Id::new("rq-ui");
|
||||
let (mut open, mut selected): (Vec<u64>, Option<u64>) = ui.data(|d| d.get_temp(state_id).unwrap_or_default());
|
||||
|
||||
@@ -194,6 +365,14 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
actions.push(("renderQueue.render", json!({"wait": false})));
|
||||
}
|
||||
app.auto.add("renderQueue.render", render_b, "Render");
|
||||
// Notify (sound + notification when the queue finishes).
|
||||
let notify = app.session.project.render_prefs.notify;
|
||||
let nb = Rect::from_min_size(pos2(render_b.min.x - 8.0 - 2.0 * bw - 8.0, render_b.min.y + 4.0), vec2(18.0, 18.0));
|
||||
if widgets::checkbox(ui, nb, notify, &t, egui::Id::new("rq-notify")).clicked() {
|
||||
actions.push(("renderQueue.setNotify", json!({"notify": !notify})));
|
||||
}
|
||||
p.text(pos2(nb.max.x + 5.0, nb.center().y), Align2::LEFT_CENTER, "Notify", Tokens::ui(11.5), t.text_dim);
|
||||
app.auto.add("renderQueue.notify", nb, if notify { "Notify: on" } else { "Notify: off" });
|
||||
if rendering {
|
||||
if widgets::text_button(ui, stop_b, "Stop", false, &t, egui::Id::new("rq-stop")).clicked() {
|
||||
actions.push(("renderQueue.stop", json!({})));
|
||||
@@ -201,8 +380,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
app.auto.add("renderQueue.stop", stop_b, "Stop");
|
||||
}
|
||||
// Progress bar.
|
||||
let bar =
|
||||
Rect::from_min_max(pos2(x0, render_b.min.y + 4.0), pos2(if rendering { stop_b.min.x - 16.0 } else { render_b.min.x - 16.0 }, render_b.max.y - 4.0));
|
||||
let bar = Rect::from_min_max(pos2(x0, render_b.min.y + 4.0), pos2(nb.min.x - 16.0, render_b.max.y - 4.0));
|
||||
p.rect_filled(bar, 3.0, t.field_bg);
|
||||
p.rect_stroke(bar, 3.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside);
|
||||
let frac = progress.as_ref().filter(|p| p.total > 0).map(|p| (p.done as f32 / p.total as f32).clamp(0.0, 1.0)).unwrap_or(0.0);
|
||||
@@ -355,22 +533,28 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let r = Rect::from_min_size(pos2(list.min.x, y), vec2(list.width(), ROW_H));
|
||||
y += ROW_H;
|
||||
lp.rect_filled(r, 0.0, t.panel_bg);
|
||||
let templates = &app.session.project.render_templates;
|
||||
let (label, summary, key, menu): (&str, String, &str, Vec<(String, Value)>) = if sub == 0 {
|
||||
("Render Settings:", it.settings.summary(), "renderSettings", settings_menu(it))
|
||||
("Render Settings:", it.settings.summary(), "renderSettings", settings_menu(it, templates))
|
||||
} else {
|
||||
("Output Module:", it.output.summary(), "outputModule", output_menu(it, &available))
|
||||
("Output Module:", it.output.summary(), "outputModule", output_menu(it, &available, templates))
|
||||
};
|
||||
lp.text(pos2(cx_num - 6.0, r.center().y), Align2::LEFT_CENTER, label, Tokens::ui(11.5), t.text_dim);
|
||||
let dd = Rect::from_min_size(pos2(ix + 70.0, r.min.y + 3.0), vec2(220.0, ROW_H - 6.0));
|
||||
let did = egui::Id::new(("rq-dd", it.id, sub));
|
||||
if widgets::dropdown(ui, dd, &summary, &t, did).clicked() && !rendering {
|
||||
let shown: String = if summary.chars().count() > 31 { summary.chars().take(30).chain(std::iter::once('…')).collect() } else { summary.clone() };
|
||||
if widgets::dropdown(ui, dd, &shown, &t, did).clicked() && !rendering {
|
||||
widgets::open_popup(ui, did);
|
||||
}
|
||||
let labels: Vec<String> = menu.iter().map(|(l, _)| l.clone()).collect();
|
||||
if let Some(i) = widgets::popup_menu(ui, did, dd.left_bottom(), &labels, None) {
|
||||
let mut params = menu[i].1.clone();
|
||||
params["item"] = json!(it.id);
|
||||
actions.push((if sub == 0 { "renderQueue.setRenderSettings" } else { "renderQueue.setOutputModule" }, params));
|
||||
if let Some(f) = params.get("form").and_then(Value::as_str) {
|
||||
forms.push((it.clone(), f.to_string()));
|
||||
} else {
|
||||
params["item"] = json!(it.id);
|
||||
actions.push((if sub == 0 { "renderQueue.setRenderSettings" } else { "renderQueue.setOutputModule" }, params));
|
||||
}
|
||||
}
|
||||
app.auto.add(&aid(key), dd, &summary);
|
||||
if sub == 0 {
|
||||
@@ -387,6 +571,18 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
);
|
||||
lp.text(pos2(dd.max.x + 14.0, r.center().y), Align2::LEFT_CENTER, info, Tokens::ui(11.0), t.text_faint);
|
||||
}
|
||||
// Log: Errors Only / Plus Settings / Plus Per Frame Info.
|
||||
let ld = Rect::from_min_size(pos2(r.max.x - 190.0, r.min.y + 3.0), vec2(150.0, ROW_H - 6.0));
|
||||
lp.text(pos2(ld.min.x - 6.0, r.center().y), Align2::RIGHT_CENTER, "Log:", Tokens::ui(11.5), t.text_dim);
|
||||
let lid = egui::Id::new(("rq-log", it.id));
|
||||
if widgets::dropdown(ui, ld, it.log.label(), &t, lid).clicked() && !rendering {
|
||||
widgets::open_popup(ui, lid);
|
||||
}
|
||||
let llabels: Vec<String> = RenderLog::ALL.iter().map(|l| mark(it.log == *l, l.label())).collect();
|
||||
if let Some(i) = widgets::popup_menu(ui, lid, ld.left_bottom(), &llabels, None) {
|
||||
actions.push(("renderQueue.setLog", json!({"item": it.id, "log": RenderLog::ALL[i].label()})));
|
||||
}
|
||||
app.auto.add(&aid("log"), ld, it.log.label());
|
||||
continue;
|
||||
}
|
||||
// Output To.
|
||||
@@ -490,6 +686,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
open.retain(|id| queue.iter().any(|i| i.id == *id));
|
||||
ui.data_mut(|d| d.insert_temp(state_id, (open, selected)));
|
||||
let ctx = ui.ctx().clone();
|
||||
for (it, f) in forms {
|
||||
open_menu_form(app, &it, &f);
|
||||
}
|
||||
for (id, params) in actions {
|
||||
if let Err(e) = crate::menus::invoke(app, &ctx, id, params) {
|
||||
app.ui.status = e;
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
//! Edit ▸ Templates ▸ Render Settings… / Output Module…: the Defaults (which template new
|
||||
//! Render Queue items, frames, pre-renders and proxies start from) and the template list
|
||||
//! (settings of the selected template; Duplicate, Delete, New from a Render Queue item).
|
||||
//!
|
||||
//! Every action runs a `renderQueue.*Template*` engine command. Automation ids:
|
||||
//! `templates.default.<Slot>`, `templates.select`, `templates.duplicate`, `templates.delete`,
|
||||
//! `templates.newName`, `templates.saveFromItem`, `templates.ok`.
|
||||
|
||||
use effectcraft_engine::project::render_templates::{RenderTemplates, TemplateKind, TemplateSlot};
|
||||
use egui::{Color32, vec2};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::theme::Tokens;
|
||||
use crate::{Dialog, EffectcraftApp};
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct State {
|
||||
output: bool,
|
||||
selected: String,
|
||||
new_name: String,
|
||||
}
|
||||
|
||||
fn state_id() -> egui::Id {
|
||||
egui::Id::new("rq-templates-dialog")
|
||||
}
|
||||
|
||||
/// Open the dialog for `kind` (`renderSettings` | `outputModule`).
|
||||
pub fn open(app: &mut EffectcraftApp, ctx: &egui::Context, kind: &str) {
|
||||
let output = TemplateKind::parse(kind) == Some(TemplateKind::OutputModule);
|
||||
let t = &app.session.project.render_templates;
|
||||
let k = if output { TemplateKind::OutputModule } else { TemplateKind::RenderSettings };
|
||||
let selected = t.default_name(k, TemplateSlot::Movie);
|
||||
ctx.data_mut(|d| d.insert_temp(state_id(), State { output, selected, new_name: String::new() }));
|
||||
app.dialog = Some(Dialog::RenderTemplates);
|
||||
}
|
||||
|
||||
fn names(t: &RenderTemplates, output: bool) -> Vec<String> {
|
||||
if output { t.output_module_list().into_iter().map(|m| m.name).collect() } else { t.render_settings_list().into_iter().map(|r| r.name).collect() }
|
||||
}
|
||||
|
||||
pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
|
||||
let mut st: State = ctx.data(|d| d.get_temp(state_id())).unwrap_or_default();
|
||||
let kind = if st.output { "outputModule" } else { "renderSettings" };
|
||||
let k = if st.output { TemplateKind::OutputModule } else { TemplateKind::RenderSettings };
|
||||
let templates = app.session.project.render_templates.clone();
|
||||
let list = names(&templates, st.output);
|
||||
if !list.contains(&st.selected) {
|
||||
st.selected = list.first().cloned().unwrap_or_default();
|
||||
}
|
||||
let mut actions: Vec<(&'static str, Value)> = vec![];
|
||||
let mut close = false;
|
||||
let title = if st.output { "Output Module Templates" } else { "Render Settings Templates" };
|
||||
super::dialogs::modal(ctx, title, vec2(600.0, 560.0), t, |ui| {
|
||||
ui.label(egui::RichText::new("Defaults").font(Tokens::semibold(12.5)).color(t.text));
|
||||
egui::Grid::new("tpl-defaults").num_columns(2).spacing(vec2(14.0, 6.0)).show(ui, |ui| {
|
||||
for slot in TemplateSlot::ALL {
|
||||
ui.label(slot.label());
|
||||
let cur = templates.default_name(k, slot);
|
||||
let mut pick = cur.clone();
|
||||
let r = egui::ComboBox::from_id_salt(("tpl-def", slot.label())).selected_text(&cur).width(300.0).show_ui(ui, |ui| {
|
||||
for n in &list {
|
||||
ui.selectable_value(&mut pick, n.clone(), n);
|
||||
}
|
||||
});
|
||||
app.auto.add(&format!("templates.default.{slot:?}"), r.response.rect, &cur);
|
||||
if pick != cur {
|
||||
actions.push(("renderQueue.setTemplateDefault", json!({"kind": kind, "slot": format!("{slot:?}"), "name": pick})));
|
||||
}
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
ui.add_space(12.0);
|
||||
ui.separator();
|
||||
ui.label(egui::RichText::new("Settings").font(Tokens::semibold(12.5)).color(t.text));
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Settings Name:");
|
||||
let r = egui::ComboBox::from_id_salt("tpl-select").selected_text(&st.selected).width(300.0).show_ui(ui, |ui| {
|
||||
for n in &list {
|
||||
ui.selectable_value(&mut st.selected, n.clone(), n);
|
||||
}
|
||||
});
|
||||
app.auto.add("templates.select", r.response.rect, &st.selected);
|
||||
});
|
||||
let builtin = RenderTemplates::is_builtin(k, &st.selected)
|
||||
&& !match k {
|
||||
TemplateKind::RenderSettings => templates.render_settings.iter().any(|r| r.name.eq_ignore_ascii_case(&st.selected)),
|
||||
TemplateKind::OutputModule => templates.output_modules.iter().any(|m| m.name.eq_ignore_ascii_case(&st.selected)),
|
||||
};
|
||||
// The selected template's settings.
|
||||
let lines: Vec<(&'static str, String)> = if st.output {
|
||||
templates.output_module(&st.selected).map(|m| m.describe()).unwrap_or_default()
|
||||
} else {
|
||||
templates.render_settings(&st.selected).map(|r| r.describe(None)).unwrap_or_default()
|
||||
};
|
||||
egui::Frame::new().fill(t.field_bg).inner_margin(egui::Margin::same(8)).show(ui, |ui| {
|
||||
ui.set_min_height(180.0);
|
||||
ui.set_width(ui.available_width());
|
||||
egui::ScrollArea::vertical().max_height(200.0).show(ui, |ui| {
|
||||
egui::Grid::new("tpl-lines").num_columns(2).spacing(vec2(12.0, 2.0)).show(ui, |ui| {
|
||||
for (k, v) in &lines {
|
||||
ui.label(egui::RichText::new(*k).color(t.text_dim).size(11.5));
|
||||
ui.label(egui::RichText::new(v).size(11.5));
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
let r = ui.button("Duplicate");
|
||||
app.auto.add("templates.duplicate", r.rect, "Duplicate");
|
||||
if r.clicked() {
|
||||
let mut n = format!("{} copy", st.selected);
|
||||
let mut i = 2;
|
||||
while list.iter().any(|x| x.eq_ignore_ascii_case(&n)) {
|
||||
n = format!("{} copy {i}", st.selected);
|
||||
i += 1;
|
||||
}
|
||||
actions.push(("renderQueue.saveTemplate", json!({"kind": kind, "name": n, "from": st.selected})));
|
||||
st.selected = n;
|
||||
}
|
||||
let r = ui.add_enabled(!builtin, egui::Button::new("Delete"));
|
||||
app.auto.add("templates.delete", r.rect, "Delete");
|
||||
if r.clicked() {
|
||||
actions.push(("renderQueue.deleteTemplate", json!({"kind": kind, "name": st.selected})));
|
||||
}
|
||||
if builtin {
|
||||
ui.label(egui::RichText::new("built-in").color(t.text_faint).size(11.0));
|
||||
}
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
ui.separator();
|
||||
// New template from the selected (or only) Render Queue item.
|
||||
let queue = &app.session.project.render_queue;
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("New from Render Queue item:");
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut st.new_name).hint_text("template name").desired_width(180.0));
|
||||
app.auto.add("templates.newName", r.rect, &st.new_name);
|
||||
let item = queue.last().map(|i| i.id);
|
||||
let ok = item.is_some() && !st.new_name.trim().is_empty();
|
||||
let r = ui.add_enabled(ok, egui::Button::new(format!("Save from #{}", queue.len().max(1))));
|
||||
app.auto.add("templates.saveFromItem", r.rect, "Save from item");
|
||||
if r.clicked()
|
||||
&& let Some(id) = item
|
||||
{
|
||||
actions.push(("renderQueue.saveTemplate", json!({"kind": kind, "name": st.new_name.trim(), "item": id})));
|
||||
st.selected = st.new_name.trim().to_string();
|
||||
st.new_name.clear();
|
||||
}
|
||||
});
|
||||
ui.add_space(12.0);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let r = ui.add(egui::Button::new(egui::RichText::new(" OK ").color(Color32::WHITE)).fill(t.accent));
|
||||
app.auto.add("templates.ok", r.rect, "OK");
|
||||
close = r.clicked();
|
||||
});
|
||||
});
|
||||
for (id, p) in actions {
|
||||
if let Err(e) = app.session.execute(id, p) {
|
||||
app.ui.status = e.to_string();
|
||||
}
|
||||
}
|
||||
ctx.data_mut(|d| d.insert_temp(state_id(), st));
|
||||
if close || ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
app.dialog = None;
|
||||
}
|
||||
}
|
||||
@@ -41,3 +41,26 @@ fn render_queue_panel_snapshot() {
|
||||
}
|
||||
img.save(&out).unwrap();
|
||||
}
|
||||
|
||||
/// Edit ▸ Templates ▸ Output Module… (`RQ_TEMPLATES_SNAPSHOT=/abs/out.png`).
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn templates_dialog_snapshot() {
|
||||
let mut s = Session::default();
|
||||
s.execute("file.openDemoProject", json!({})).unwrap();
|
||||
s.execute("renderQueue.add", json!({"output": "/renders/[compName].[fileExtension]"})).unwrap();
|
||||
s.execute("renderQueue.saveTemplate", json!({"kind": "outputModule", "name": "Web Delivery", "item": s.project.render_queue[0].id, "params": {"format": "webm", "resize": {"preset": "HDTV 720"}}}))
|
||||
.unwrap();
|
||||
let kind = std::env::var("RQ_TEMPLATES_KIND").unwrap_or_else(|_| "outputModule".into());
|
||||
s.execute("app.templates", json!({"kind": kind})).unwrap();
|
||||
let mut app = Some(EffectcraftApp::new(s));
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_| app.take().expect("app"));
|
||||
h.run_steps(4);
|
||||
assert!(h.state().dialog.is_some(), "the Templates dialog is open");
|
||||
let img = h.render().expect("render");
|
||||
let out = std::env::var("RQ_TEMPLATES_SNAPSHOT").unwrap_or_else(|_| concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/test-out/rq_templates.png").into());
|
||||
if let Some(d) = std::path::Path::new(&out).parent() {
|
||||
std::fs::create_dir_all(d).unwrap();
|
||||
}
|
||||
img.save(&out).unwrap();
|
||||
}
|
||||
|
||||
+2
-2
@@ -21,7 +21,7 @@ Tracking 88%, Effects 85%, Web 65%.
|
||||
|
||||
What is left, in priority order: the web app's depth (threads, storage, audio, non-blocking
|
||||
renders); stroke taper/wave and multi-segment dashes; variable mask feather points; camera iris/bokeh and focus-link commands; GPU
|
||||
rendering of 3D runs and adjustment layers; Render Queue field render, crop/resize and templates;
|
||||
rendering of 3D runs and adjustment layers;
|
||||
approximated effects (Subspace Warp, Key Cleaner) and Liquify's viewer brush; OpenType features and variable
|
||||
font axes; AI/EPS/PDF import; more codecs; 59 preferences not yet wired; ScriptUI;
|
||||
Lumetri Scopes, Footage, Media Browser and Metadata panels; Content-Aware Fill; face tracking; and
|
||||
@@ -110,7 +110,7 @@ the disk cache in the formats wave; the 3D Camera Tracker in M12.6.)
|
||||
| Area | Weighted parity | Remaining (agent-hours) | Biggest gaps |
|
||||
|---|---|---|---|
|
||||
| Layers | 88% | 4.8 | frame blending, collapse transformations, slip edit |
|
||||
| Output | 90% | 3.0 | multiple output modules, pre-render (WebM with VP9 alpha + Opus and WAV/AIFF audio-only landed: VP9 key + inter frames with motion search, loop filter and rate control; Opus CELT-only) |
|
||||
| Output | 97% | 0.8 | Render Settings complete (field render + 3:2 pulldown, effects/solo/guide/depth/blending/blur overrides, time sampling, storage overflow), Output Module crop/ROI/resize, alpha modes, post-render actions, PCM formats, templates with defaults, render logs, Notify (M10.2); WebM VP9 key + inter frames with motion search, loop filter and rate control. Left: Opus is CELT-only, Photoshop sequence output, overflow for movies only checks at file creation |
|
||||
| Audio | 85% | 0.5 | audio to keyframes |
|
||||
| Import | 88% | 2.0 | AI/EPS/PDF vector footage, PSD smart objects and 3D layers (PSD as footage/composition/retain layer sizes, SVG footage and Create Shapes from Vector Layer landed) |
|
||||
| Automation | ≈ 90% | 0.8 | scripting covers the documented core object model (AUT-2, M14.4: `app`, project items, comps, layers, properties and keyframes, text documents, markers, render queue, Script Console, `effectcraft-cli script`, MCP `run_script`); still missing: ScriptUI panels/dialogs, `.jsxbin`, sockets |
|
||||
|
||||
@@ -207,5 +207,3 @@ every request and reply, a render through an in-process offload).
|
||||
- Cancelling an analysis drops its partial result (the worker is terminated).
|
||||
- Stored media count against the origin's storage quota; `effectcraft.listStored()` shows usage,
|
||||
`removeStored` frees it (there is no storage manager in the UI yet).
|
||||
- `engine.execute` rejects the spread Render Settings / Output Module keys of `renderQueue.add`
|
||||
(`resolution`, `timeSpan`…) through its strict parameter check; use `ui.menu.invoke` as above.
|
||||
|
||||
Reference in New Issue
Block a user