Merge branch 'worktree-agent-a12b9f39d35fe5457'

# Conflicts:
#	docs/parity.md
This commit is contained in:
Brandon Thomas committed 2026-10-03 05:25:26 -04:00
commit 978e8d7f94
39 files changed
+5600 -958

No files matched your search

Generated
+1
View File
@@ -1238,6 +1238,7 @@ name = "effectcraft-export"
version = "0.1.0"
dependencies = [
"effectcraft-color",
"effectcraft-keyframe",
"effectcraft-media",
"effectcraft-opusenc",
"effectcraft-project",
+5 -2
View File
@@ -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,
+39 -1
View File
@@ -82,7 +82,37 @@ pub(crate) fn encode_png(rgba: &[u8], w: u32, h: u32) -> std::result::Result<Vec
Ok(out)
}
/// Save Frame As ▸ File with `queue`: a Render Queue item for the current frame, from the Frame
/// Default templates (Edit ▸ Templates).
fn queue_frame(s: &mut Session, p: &Value) -> Result<Value> {
use effectcraft_project::render_queue::{RenderQueueItem, TimeSpan};
use effectcraft_project::render_templates::TemplateSlot;
let cid = comp_id(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let t = f_p(p, "time").map(Tick::from_seconds_f64).unwrap_or(s.time());
let mut it = RenderQueueItem::new(0, cid);
it.settings = s.project.render_templates.default_render_settings(TemplateSlot::Still);
it.settings.time_span = TimeSpan::Custom { start: t, end: t + comp.frame_duration() };
it.output = s.project.render_templates.default_output_module(TemplateSlot::Still);
if let Some(path) = str_p(p, "path") {
it.output.output = path.to_string();
} else if !it.output.format.is_sequence() {
it.output.output = "[compName]_frame.[fileExtension]".into();
}
let id = s.edit("Save Frame As", None, |proj, _| {
it.id = proj.render_queue.iter().map(|i| i.id).max().unwrap_or(0) + 1;
let id = it.id;
proj.render_queue.push(it);
Ok(id)
})?;
let path = s.project.render_queue.last().and_then(|i| s.resolve_output(i));
Ok(json!({"item": id, "queued": true, "output": path, "time": t.seconds()}))
}
fn save_frame(s: &mut Session, p: &Value) -> Result<Value> {
if p.get("queue").and_then(Value::as_bool) == Some(true) {
return queue_frame(s, p);
}
let path = str_p(p, "path").ok_or_else(|| bad("comp.saveFrameAs", "missing `path` (.png)"))?.to_string();
let cid = comp_id(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
@@ -124,7 +154,15 @@ pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("comp.cropToRegionOfInterest", "Crop Comp to Region of Interest", ["Composition"], None, "{comp?}", has_roi, crop_to_roi),
cmd!("comp.cropToLayerBounds", "Crop Comp to Selected Layer(s) Bounds", ["Composition"], None, "{layers?}", has_layers, crop_to_layers),
cmd!("comp.saveFrameAs", "File...", ["Composition", "Save Frame As"], Some("Cmd+Alt+S"), "{path (.png), time?, scale?}", has_comp, save_frame),
cmd!(
"comp.saveFrameAs",
"File...",
["Composition", "Save Frame As"],
Some("Cmd+Alt+S"),
"{path (.png; with queue: an output path or template), time?, scale?, queue?: bool (add a Render Queue item from the Frame Default templates instead of writing now)}",
has_comp,
save_frame
),
cmd!("comp.responsiveTime", "Responsive Design — Time", [], None, "{op: intro|outro|workArea}", has_comp, responsive_time),
]
}
+15 -4
View File
@@ -4,6 +4,7 @@
//! item's switch).
use effectcraft_project::render_queue::{Channels, OutputFormat, OutputModule, PostRenderAction, ProResProfile, RenderQuality, RenderQueueItem, TimeSpan};
use effectcraft_project::render_templates::TemplateSlot;
use effectcraft_project::{Footage, ItemId, ItemKind, Proxy};
use effectcraft_time::Tick;
use serde_json::{Value, json};
@@ -159,18 +160,28 @@ fn create_proxy(s: &mut Session, p: &Value) -> Result<Value> {
k => return Err(bad(C, format!("kind: still|movie, not `{k}`"))),
};
let mut it = RenderQueueItem::new(0, cid);
// The Movie / Still Proxy Default templates, at draft quality and the proxy resolution.
let templates = &s.project.render_templates;
let slot = if still { TemplateSlot::StillProxy } else { TemplateSlot::MovieProxy };
it.settings = templates.default_render_settings(slot);
it.settings.quality = RenderQuality::Draft;
it.settings.resolution = p.get("resolution").and_then(Value::as_f64).unwrap_or(0.5).clamp(0.05, 1.0);
if still {
// One frame at the current time (the comp's poster frame when not the active comp).
let t = if s.active_comp_id() == Some(cid) { s.time() } else { comp.poster_time };
it.settings.time_span = TimeSpan::Custom { start: t, end: t + comp.frame_duration() };
it.output = OutputModule::for_format(OutputFormat::PngSequence);
it.output.output = "[compName]_proxy_[#####].png".into();
it.output = templates.default_output_module(slot);
if !it.output.format.is_sequence() {
it.output = OutputModule::for_format(OutputFormat::PngSequence);
}
it.output.output = "[compName]_proxy_[#####].[fileExtension]".into();
} else {
it.settings.time_span = TimeSpan::LengthOfComp;
it.output = OutputModule::for_format(OutputFormat::ProRes);
it.output.prores_profile = ProResProfile::P4444;
it.output = templates.default_output_module(slot);
if !it.output.format.is_movie() {
it.output = OutputModule::for_format(OutputFormat::ProRes);
it.output.prores_profile = ProResProfile::P4444;
}
it.output.output = "[compName]_proxy.[fileExtension]".into();
}
it.output.channels = Channels::Rgba;
+466 -44
View File
@@ -1,16 +1,20 @@
//! Render Queue: Composition ▸ Add to Render Queue, the queue's Render Settings / Output Module /
//! Output To, Render and Stop.
//! Output To / Log, Render and Stop, Notify, storage overflow folders, and the Render Settings /
//! Output Module templates (Edit ▸ Templates).
//!
//! Items are addressed by `item` (the stable id from `renderQueue.add` / `renderQueue.list`) or
//! `index` (1-based, the # column).
use effectcraft_project::render_queue::{
AudioOutput, Channels, OutputFormat, OutputModule, PostRenderAction, ProResProfile, RenderQuality, RenderQueueItem, RenderSettings, RenderStatus, TimeSpan,
AlphaMode, AudioFormat, AudioOutput, Channels, ColorDepth, CurrentOrOff, DEFAULT_SEQUENCE_TEMPLATE, DEFAULT_TEMPLATE, EffectsMode, FieldRender,
OutputFormat, OutputModule, PostRenderAction, ProResProfile, Pulldown, RESIZE_PRESETS, RenderLog, RenderQuality, RenderQueueItem, RenderSettings,
RenderStatus, ResizeQuality, SwitchOverride, TimeSpan, post_render_parse,
};
use effectcraft_project::render_templates::{RenderTemplates, TemplateKind, TemplateSlot};
use effectcraft_time::{FrameRate, Tick};
use serde_json::{Value, json};
use super::{CommandSpec, b_p, bad, comp_id, f_p, has_comp, str_p};
use super::{CommandSpec, always, b_p, bad, comp_id, f_p, has_comp, str_p};
use crate::{EngineError, Result, Session, cmd, query};
fn can_add(s: &Session) -> std::result::Result<(), String> {
@@ -54,16 +58,32 @@ fn time_p(p: &Value, k: &str) -> Option<Tick> {
f_p(p, k).map(Tick::from_seconds_f64)
}
/// Apply Render Settings parameters.
fn apply_settings(rs: &mut RenderSettings, p: &Value, cmd: &str) -> Result<bool> {
/// Parse an enum parameter.
fn enum_p<T>(p: &Value, k: &str, cmd: &str, parse: fn(&str) -> Option<T>, help: &str) -> Result<Option<T>> {
match p.get(k) {
None | Some(Value::Null) => Ok(None),
Some(Value::String(v)) => parse(v).map(Some).ok_or_else(|| bad(cmd, format!("{k}: {help}"))),
Some(Value::Bool(b)) => parse(if *b { "on" } else { "off" }).map(Some).ok_or_else(|| bad(cmd, format!("{k}: {help}"))),
Some(Value::Number(n)) => parse(&n.to_string()).map(Some).ok_or_else(|| bad(cmd, format!("{k}: {help}"))),
Some(_) => Err(bad(cmd, format!("{k}: {help}"))),
}
}
/// Apply Render Settings parameters (`template` first, then the individual settings). Any
/// individual change makes the settings "Custom".
fn apply_settings(rs: &mut RenderSettings, templates: &RenderTemplates, p: &Value, cmd: &str) -> Result<bool> {
let mut any = false;
if let Some(t) = str_p(p, "template") {
*rs = templates.render_settings(t).ok_or_else(|| bad(cmd, format!("no Render Settings template “{t}”")))?;
any = true;
}
let before = rs.clone();
if let Some(q) = str_p(p, "quality") {
rs.quality = match q.to_ascii_lowercase().as_str() {
"best" => RenderQuality::Best,
"draft" => RenderQuality::Draft,
_ => return Err(bad(cmd, "quality: best|draft")),
};
any = true;
}
match p.get("resolution") {
Some(Value::String(r)) => {
@@ -74,15 +94,21 @@ fn apply_settings(rs: &mut RenderSettings, p: &Value, cmd: &str) -> Result<bool>
"quarter" => 0.25,
_ => return Err(bad(cmd, "resolution: full|half|third|quarter|<scale>")),
};
any = true;
}
Some(Value::Number(n)) => {
rs.resolution = n.as_f64().unwrap_or(1.0).clamp(0.01, 4.0);
any = true;
}
_ => {}
}
let (start, end) = (time_p(p, "start"), time_p(p, "end"));
let (start, mut end) = (time_p(p, "start"), time_p(p, "end"));
if let Some(d) = time_p(p, "duration") {
// Time Span ▸ Custom ▸ Duration: from the given (or current custom) start.
let a = start.unwrap_or(match rs.time_span {
TimeSpan::Custom { start, .. } => start,
_ => Tick::ZERO,
});
end = Some(a + d);
}
if let Some(ts) = str_p(p, "timeSpan") {
rs.time_span = match ts.to_ascii_lowercase().replace([' ', '_', '-'], "").as_str() {
"workarea" | "workareaonly" => TimeSpan::WorkArea,
@@ -90,14 +116,12 @@ fn apply_settings(rs: &mut RenderSettings, p: &Value, cmd: &str) -> Result<bool>
"custom" => TimeSpan::Custom { start: start.unwrap_or(Tick::ZERO), end: end.unwrap_or(Tick::from_seconds_f64(1.0)) },
_ => return Err(bad(cmd, "timeSpan: workArea|comp|custom")),
};
any = true;
} else if start.is_some() || end.is_some() {
let (a, b) = match rs.time_span {
TimeSpan::Custom { start, end } => (start, end),
_ => (Tick::ZERO, Tick(i64::MAX / 4)),
};
rs.time_span = TimeSpan::Custom { start: start.unwrap_or(a), end: end.unwrap_or(b) };
any = true;
}
if let TimeSpan::Custom { start, end } = rs.time_span
&& end <= start
@@ -106,61 +130,193 @@ fn apply_settings(rs: &mut RenderSettings, p: &Value, cmd: &str) -> Result<bool>
}
if let Some(u) = str_p(p, "proxyUse") {
rs.proxy_use = effectcraft_project::render_queue::ProxyUse::parse(u).ok_or_else(|| bad(cmd, "proxyUse: current|all|comp|none"))?;
any = true;
}
match p.get("frameRate") {
Some(Value::Null) => {
rs.frame_rate = None;
any = true;
}
Some(v) => {
let f = v.as_f64().filter(|f| *f > 0.0 && *f <= 1000.0).ok_or_else(|| bad(cmd, "frameRate: fps > 0 or null (comp rate)"))?;
rs.frame_rate = Some(FrameRate::from_f64(f));
any = true;
}
None => {}
}
if let Some(b) = b_p(p, "motionBlur") {
rs.motion_blur = b;
any = true;
match p.get("motionBlur") {
Some(Value::Bool(b)) => {
rs.motion_blur = *b;
if *b && rs.motion_blur_mode == SwitchOverride::OffForAll {
rs.motion_blur_mode = SwitchOverride::Current;
}
}
Some(_) => {
let m = enum_p(p, "motionBlur", cmd, SwitchOverride::parse, "current|onForChecked|offForAll")?.unwrap_or_default();
rs.motion_blur = m != SwitchOverride::OffForAll;
rs.motion_blur_mode = m;
}
None => {}
}
if let Some(m) = enum_p(p, "frameBlending", cmd, SwitchOverride::parse, "current|onForChecked|offForAll")? {
rs.frame_blending = m;
}
if let Some(f) = enum_p(p, "fieldRender", cmd, FieldRender::parse, "off|upper|lower")? {
rs.field_render = f;
}
if let Some(f) = enum_p(p, "pulldown", cmd, Pulldown::parse, "off|WSSWW|SSWWW|SWWWS|WWWSS|WWSSW")? {
rs.pulldown = f;
}
if let Some(e) = enum_p(p, "effects", cmd, EffectsMode::parse, "current|allOn|allOff")? {
rs.effects = e;
}
if let Some(e) = enum_p(p, "solo", cmd, CurrentOrOff::parse, "current|allOff")? {
rs.solo = e;
}
if let Some(e) = enum_p(p, "guideLayers", cmd, CurrentOrOff::parse, "current|allOff")? {
rs.guide_layers = e;
}
if let Some(e) = enum_p(p, "colorDepth", cmd, ColorDepth::parse, "current|8|16|32")? {
rs.color_depth = e;
}
if let Some(b) = b_p(p, "skipExisting") {
rs.skip_existing = b;
}
if let Some(b) = b_p(p, "storageOverflow") {
rs.storage_overflow = b;
}
if *rs != before {
rs.name = "Custom".into();
any = true;
}
Ok(any)
}
/// Apply Output Module parameters.
fn apply_output(om: &mut OutputModule, p: &Value, cmd: &str) -> Result<bool> {
/// Apply a template to a module, keeping its output path (with the extension fixed).
fn apply_om_template(om: &mut OutputModule, t: &OutputModule) {
let keep = om.output.clone();
let old = om.format;
*om = t.clone();
om.output = keep;
let f = om.format;
om.format = old;
om.set_format(f);
om.channels = t.channels;
om.name = t.name.clone();
}
/// Apply Output Module parameters (`template` first). `roi`: the viewer's region of interest
/// (captured by `crop.useRoi`).
fn apply_output(om: &mut OutputModule, templates: &RenderTemplates, roi: Option<[f64; 4]>, p: &Value, cmd: &str) -> Result<bool> {
let mut any = false;
if let Some(t) = str_p(p, "template") {
let t = templates.output_module(t).ok_or_else(|| bad(cmd, format!("no Output Module template “{t}”")))?;
apply_om_template(om, &t);
any = true;
}
let before = om.clone();
if let Some(f) = str_p(p, "format") {
let f = OutputFormat::from_name(f).ok_or_else(|| bad(cmd, "format: h264|prores|webm|png|jpeg|tiff|exr|gif|wav|aiff"))?;
om.set_format(f);
any = true;
}
if let Some(c) = str_p(p, "channels") {
om.channels = match c.to_ascii_lowercase().replace([' ', '+'], "").as_str() {
"rgb" => Channels::Rgb,
"rgba" | "rgbalpha" => Channels::Rgba,
_ => return Err(bad(cmd, "channels: rgb|rgba")),
"alpha" | "alphaonly" => Channels::Alpha,
_ => return Err(bad(cmd, "channels: rgb|rgba|alpha")),
};
if om.channels == Channels::Rgba && !om.format.supports_alpha() {
return Err(bad(cmd, format!("{} has no alpha channel", om.format.label())));
}
any = true;
}
if let Some(c) = str_p(p, "color").or(str_p(p, "alphaMode")) {
om.alpha_mode = match c.to_ascii_lowercase().replace([' ', '(', ')'], "").as_str() {
"straight" | "straightunmatted" => AlphaMode::Straight,
"premultiplied" | "premultipliedmatted" | "premul" => AlphaMode::Premultiplied,
_ => return Err(bad(cmd, "color: straight|premultiplied")),
};
}
if let Some(q) = f_p(p, "quality") {
om.quality = q.clamp(1.0, 100.0) as u8;
any = true;
}
if let Some(b) = f_p(p, "bitrate") {
om.bitrate_kbps = b.clamp(100.0, 500_000.0) as u32;
any = true;
om.bitrate_kbps = b.clamp(50.0, 500_000.0) as u32;
}
if let Some(b) = b_p(p, "webmBitrate") {
om.webm_bitrate = b;
}
if let Some(k) = p.get("keyframeInterval").and_then(Value::as_u64) {
om.keyframe_interval = k.min(100_000) as u32;
}
if let Some(pp) = str_p(p, "proresProfile") {
om.prores_profile = ProResProfile::from_name(pp).ok_or_else(|| bad(cmd, "proresProfile: proxy|lt|standard|hq|4444|4444xq"))?;
any = true;
}
match p.get("crop") {
Some(Value::Bool(b)) => om.crop.enabled = *b,
Some(Value::Object(c)) => {
om.crop.enabled = c.get("enabled").and_then(Value::as_bool).unwrap_or(true);
for (k, v) in [("top", &mut om.crop.top), ("left", &mut om.crop.left), ("bottom", &mut om.crop.bottom), ("right", &mut om.crop.right)] {
if let Some(n) = c.get(k).and_then(Value::as_i64) {
*v = n.clamp(-20_000, 20_000) as i32;
}
}
if let Some(u) = c.get("useRoi").and_then(Value::as_bool) {
om.crop.use_roi = u;
if u {
om.crop.roi = c.get("roi").and_then(|r| serde_json::from_value::<[f64; 4]>(r.clone()).ok()).or(roi);
if om.crop.roi.is_none() {
return Err(bad(
cmd,
"Use Region of Interest: the composition has no region of interest (View ▸ Region of Interest) — pass crop.roi [x, y, w, h]",
));
}
}
}
}
Some(Value::Null) | None => {}
Some(_) => return Err(bad(cmd, "crop: bool or {enabled?, useRoi?, roi?: [x,y,w,h], top?, left?, bottom?, right?}")),
}
// Flat keys (dialog forms): cropTop/cropLeft/cropBottom/cropRight, resizeWidth/resizeHeight.
for (k, v) in [("cropTop", &mut om.crop.top), ("cropLeft", &mut om.crop.left), ("cropBottom", &mut om.crop.bottom), ("cropRight", &mut om.crop.right)] {
if let Some(n) = p.get(k).and_then(Value::as_f64) {
*v = (n.round() as i64).clamp(-20_000, 20_000) as i32;
om.crop.enabled = true;
om.crop.use_roi = false;
}
}
for (k, v) in [("resizeWidth", &mut om.resize.width), ("resizeHeight", &mut om.resize.height)] {
if let Some(n) = p.get(k).and_then(Value::as_f64) {
*v = (n.round() as u32).clamp(1, 30_000);
om.resize.enabled = true;
}
}
match p.get("resize") {
Some(Value::Bool(b)) => om.resize.enabled = *b,
Some(Value::Object(r)) => {
om.resize.enabled = r.get("enabled").and_then(Value::as_bool).unwrap_or(true);
if let Some(name) = r.get("preset").and_then(Value::as_str) {
let (_, w, h) =
RESIZE_PRESETS.iter().find(|(n, _, _)| n.eq_ignore_ascii_case(name)).ok_or_else(|| bad(cmd, format!("no resize preset “{name}”")))?;
(om.resize.width, om.resize.height) = (*w, *h);
om.resize.lock_aspect = false;
}
if let Some(l) = r.get("lockAspect").and_then(Value::as_bool) {
om.resize.lock_aspect = l;
}
if let Some(w) = r.get("width").and_then(Value::as_u64) {
om.resize.width = (w as u32).clamp(1, 30_000);
}
if let Some(h) = r.get("height").and_then(Value::as_u64) {
om.resize.height = (h as u32).clamp(1, 30_000);
}
if let Some(q) = r.get("quality").and_then(Value::as_str) {
om.resize.quality = match q.to_ascii_lowercase().as_str() {
"low" | "bilinear" => ResizeQuality::Low,
"high" | "bicubic" => ResizeQuality::High,
_ => return Err(bad(cmd, "resize.quality: low|high")),
};
}
}
Some(Value::Null) | None => {}
Some(_) => return Err(bad(cmd, "resize: bool or {enabled?, preset?, width?, height?, lockAspect?, quality?: low|high}")),
}
if let Some(a) = p.get("audio") {
om.audio = match a {
@@ -174,15 +330,29 @@ fn apply_output(om: &mut OutputModule, p: &Value, cmd: &str) -> Result<bool> {
},
_ => return Err(bad(cmd, "audio: auto|on|off")),
};
any = true;
}
if let Some(r) = p.get("sampleRate").and_then(Value::as_u64) {
om.audio_sample_rate = (r as u32).clamp(8_000, 192_000);
any = true;
}
match p.get("audioChannels") {
Some(Value::Number(n)) => om.audio_channels = if n.as_u64() == Some(1) { 1 } else { 2 },
Some(Value::String(c)) => {
om.audio_channels = match c.to_ascii_lowercase().as_str() {
"mono" | "1" => 1,
"stereo" | "2" => 2,
_ => return Err(bad(cmd, "audioChannels: mono|stereo")),
}
}
_ => {}
}
if let Some(f) = enum_p(p, "audioFormat", cmd, AudioFormat::parse, "16|24|32 (float)")? {
om.audio_format = f;
}
if let Some(l) = b_p(p, "loop") {
om.gif_loop = l;
any = true;
}
if let Some(l) = b_p(p, "includeProjectLink") {
om.include_project_link = l;
}
if let Some(o) = str_p(p, "output").or(str_p(p, "path")) {
om.output = o.to_string();
@@ -194,6 +364,14 @@ fn apply_output(om: &mut OutputModule, p: &Value, cmd: &str) -> Result<bool> {
om.set_format(f);
om.output = keep;
}
}
if *om != before {
// Output To alone doesn't make the module custom.
let mut o = om.clone();
o.output = before.output.clone();
if o != before {
om.name = "Custom".into();
}
any = true;
}
Ok(any)
@@ -227,7 +405,7 @@ fn requeue(it: &mut RenderQueueItem) {
fn item_json(s: &Session, it: &RenderQueueItem, index: usize) -> Value {
let comp = s.project.comp(it.comp);
let name = s.project.item(it.comp).map(|i| i.name.clone());
let (w, h) = comp.map(|c| it.settings.output_size(c)).map(|(w, h)| it.output.format.coded_size(w, h)).unwrap_or((0, 0));
let (w, h) = comp.map(|c| it.output.output_size(c, &it.settings)).unwrap_or((0, 0));
let frames = comp.map(|c| it.settings.frame_count(c)).unwrap_or(0);
let mut v = serde_json::to_value(it).unwrap_or(Value::Null);
if let Some(o) = v.as_object_mut() {
@@ -249,6 +427,10 @@ fn item_json(s: &Session, it: &RenderQueueItem, index: usize) -> Value {
o.insert("outputModules".into(), json!(it.extra_outputs.len() + 1));
o.insert("extraOutputs".into(), json!(extra));
o.insert("postRenderAction".into(), json!(it.post_render.label()));
o.insert("logLabel".into(), json!(it.log.label()));
if let Some(p) = s.resolve_output(it) {
o.insert("logPath".into(), json!(effectcraft_project::render_queue::log_path(&p)));
}
o.insert("width".into(), json!(w));
o.insert("height".into(), json!(h));
o.insert("frames".into(), json!(frames));
@@ -259,16 +441,25 @@ fn item_json(s: &Session, it: &RenderQueueItem, index: usize) -> Value {
fn add(s: &mut Session, p: &Value) -> Result<Value> {
let cid = s.comp_for_queue(p).ok_or(EngineError::NoComp)?;
let mut it = RenderQueueItem::new(0, cid);
// New items start from the Movie Default templates (Edit ▸ Templates).
let templates = s.project.render_templates.clone();
it.settings = templates.default_render_settings(TemplateSlot::Movie);
it.output = templates.default_output_module(TemplateSlot::Movie);
if let Some(f) = str_p(p, "format") {
let f = OutputFormat::from_name(f).ok_or_else(|| bad("renderQueue.add", "format: h264|prores|webm|png|jpeg|tiff|exr|gif|wav|aiff"))?;
it.output = OutputModule::for_format(f);
if f != it.output.format {
it.output = OutputModule::for_format(f);
}
}
apply_settings(&mut it.settings, p, "renderQueue.add")?;
apply_settings(&mut it.settings, &templates, p, "renderQueue.add")?;
let mut op = p.clone();
if let Some(o) = op.as_object_mut() {
o.remove("format");
}
apply_output(&mut it.output, &op, "renderQueue.add")?;
apply_output(&mut it.output, &templates, s.state.region_of_interest, &op, "renderQueue.add")?;
if let Some(l) = enum_p(p, "log", "renderQueue.add", RenderLog::parse, "errorsOnly|plusSettings|plusPerFrameInfo")? {
it.log = l;
}
let id = s.edit("Add to Render Queue", None, |proj, _| {
it.id = proj.render_queue.iter().map(|i| i.id).max().unwrap_or(0) + 1;
let id = it.id;
@@ -305,7 +496,7 @@ fn set_render(s: &mut Session, p: &Value) -> Result<Value> {
fn set_render_settings(s: &mut Session, p: &Value) -> Result<Value> {
let i = item_index(s, p, "renderQueue.setRenderSettings")?;
let mut rs = s.project.render_queue[i].settings.clone();
if !apply_settings(&mut rs, p, "renderQueue.setRenderSettings")? {
if !apply_settings(&mut rs, &s.project.render_templates, p, "renderQueue.setRenderSettings")? {
return Err(bad("renderQueue.setRenderSettings", "nothing to change"));
}
s.edit("Render Settings", None, |proj, _| {
@@ -321,12 +512,20 @@ fn set_output_module(s: &mut Session, p: &Value) -> Result<Value> {
let i = item_index(s, p, "renderQueue.setOutputModule")?;
let m = module_p(s, i, p, "renderQueue.setOutputModule")?;
let mut om = module_ref(&s.project.render_queue[i], m).clone();
if !apply_output(&mut om, p, "renderQueue.setOutputModule")? {
let changed = apply_output(&mut om, &s.project.render_templates, s.state.region_of_interest, p, "renderQueue.setOutputModule")?;
let post = match str_p(p, "postRenderAction") {
Some(a) => Some(post_render_parse(a).ok_or_else(|| bad("renderQueue.setOutputModule", "postRenderAction: none|import|importAndReplace|setProxy"))?),
None => None,
};
if !changed && post.is_none() {
return Err(bad("renderQueue.setOutputModule", "nothing to change"));
}
s.edit("Output Module Settings", None, |proj, _| {
let it = &mut proj.render_queue[i];
*module_mut(it, m) = om;
if let Some(a) = post {
it.post_render = a;
}
requeue(it);
Ok(())
})?;
@@ -338,7 +537,7 @@ fn set_output(s: &mut Session, p: &Value) -> Result<Value> {
let path = str_p(p, "path").or(str_p(p, "output")).ok_or_else(|| bad("renderQueue.setOutput", "pass `path` (file path or template)"))?;
let m = module_p(s, i, p, "renderQueue.setOutput")?;
let mut om = module_ref(&s.project.render_queue[i], m).clone();
apply_output(&mut om, &json!({"output": path}), "renderQueue.setOutput")?;
apply_output(&mut om, &s.project.render_templates, None, &json!({"output": path}), "renderQueue.setOutput")?;
s.edit("Output To", None, |proj, _| {
let it = &mut proj.render_queue[i];
*module_mut(it, m) = om;
@@ -452,7 +651,7 @@ fn add_output_module(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(o) = op.as_object_mut() {
o.remove("format");
}
apply_output(&mut om, &op, "render.addOutputModule")?;
apply_output(&mut om, &s.project.render_templates, s.state.region_of_interest, &op, "render.addOutputModule")?;
s.edit("Add Output Module", None, |proj, _| {
let it = &mut proj.render_queue[i];
it.extra_outputs.push(om);
@@ -469,11 +668,11 @@ fn add_output_module(s: &mut Session, p: &Value) -> Result<Value> {
fn pre_render(s: &mut Session, p: &Value) -> Result<Value> {
let cid = s.comp_for_queue(p).ok_or(EngineError::NoComp)?;
let mut it = RenderQueueItem::new(0, cid);
it.output = OutputModule::for_format(OutputFormat::ProRes);
it.output.prores_profile = ProResProfile::P4444;
it.output.channels = Channels::Rgba;
let templates = s.project.render_templates.clone();
it.settings = templates.default_render_settings(TemplateSlot::PreRender);
it.output = templates.default_output_module(TemplateSlot::PreRender);
it.output.output = "[compName]_prerender.[fileExtension]".into();
apply_output(&mut it.output, p, "render.preRender")?;
apply_output(&mut it.output, &templates, s.state.region_of_interest, p, "render.preRender")?;
it.post_render = PostRenderAction::ImportAndReplace;
let id = s.edit("Pre-render", None, |proj, _| {
it.id = proj.render_queue.iter().map(|i| i.id).max().unwrap_or(0) + 1;
@@ -506,6 +705,8 @@ fn save_current_preview(s: &mut Session, p: &Value) -> Result<Value> {
accel: s.accel.as_deref(),
item: &item,
path: &path,
storage: s.storage_quota.as_deref(),
label: "Save Current Preview".into(),
nested_switches: s.prefs.general.switches_affect_nested_comps,
};
let r = exporter.export(&job, &mut |_, _| true).map_err(EngineError::Other)?;
@@ -513,6 +714,187 @@ fn save_current_preview(s: &mut Session, p: &Value) -> Result<Value> {
Ok(serde_json::to_value(&r).unwrap_or_default())
}
// ---------------------------------------------------------------- templates (Edit ▸ Templates)
fn kind_p(p: &Value, cmd: &str) -> Result<TemplateKind> {
str_p(p, "kind").and_then(TemplateKind::parse).ok_or_else(|| bad(cmd, "kind: renderSettings|outputModule"))
}
fn name_p<'a>(p: &'a Value, cmd: &str) -> Result<&'a str> {
str_p(p, "name").map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad(cmd, "pass `name`"))
}
/// Templates as JSON (lists, built-in flags, defaults per slot).
fn templates_json(t: &RenderTemplates) -> Value {
let rs: Vec<Value> = t
.render_settings_list()
.iter()
.map(|r| json!({"name": r.name, "builtin": RenderTemplates::is_builtin(TemplateKind::RenderSettings, &r.name), "settings": r}))
.collect();
let om: Vec<Value> = t
.output_module_list()
.iter()
.map(|m| json!({"name": m.name, "builtin": RenderTemplates::is_builtin(TemplateKind::OutputModule, &m.name), "summary": format!("{} · {}", m.format.label(), m.channels.label()), "module": m}))
.collect();
let slots = |k: TemplateKind| -> Value {
TemplateSlot::ALL.iter().map(|sl| (format!("{sl:?}"), json!(t.default_name(k, *sl)))).collect::<serde_json::Map<String, Value>>().into()
};
json!({
"renderSettings": rs,
"outputModules": om,
"defaults": {"renderSettings": slots(TemplateKind::RenderSettings), "outputModules": slots(TemplateKind::OutputModule)},
"slots": TemplateSlot::ALL.iter().map(|sl| json!({"id": format!("{sl:?}"), "label": sl.label()})).collect::<Vec<_>>(),
"resizePresets": RESIZE_PRESETS.iter().map(|(n, w, h)| json!({"name": n, "width": w, "height": h})).collect::<Vec<_>>(),
})
}
fn templates(s: &mut Session, _: &Value) -> Result<Value> {
Ok(templates_json(&s.project.render_templates))
}
/// Save a template: from an item's settings (`item`/`index`, `module`), or the slot default,
/// adjusted by `params` (the same keys as setRenderSettings / setOutputModule).
fn save_template(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "renderQueue.saveTemplate";
let kind = kind_p(p, C)?;
let name = name_p(p, C)?.to_string();
if name.eq_ignore_ascii_case("custom") {
return Err(bad(C, "“Custom” is reserved"));
}
let item = if p.get("item").is_some() || p.get("index").is_some() { Some(item_index(s, p, C)?) } else { None };
let params = p.get("params").cloned().unwrap_or(Value::Null);
let from = str_p(p, "from");
let mut t = s.project.render_templates.clone();
match kind {
TemplateKind::RenderSettings => {
let mut rs = match (item, from) {
(Some(i), _) => s.project.render_queue[i].settings.clone(),
(None, Some(f)) => t.render_settings(f).ok_or_else(|| bad(C, format!("no Render Settings template “{f}”")))?,
(None, None) => t.default_render_settings(TemplateSlot::Movie),
};
if params.is_object() {
apply_settings(&mut rs, &t, &params, C)?;
}
t.save_render_settings(&name, rs);
}
TemplateKind::OutputModule => {
let mut om = match (item, from) {
(Some(i), _) => module_ref(&s.project.render_queue[i], module_p(s, i, p, C)?).clone(),
(None, Some(f)) => t.output_module(f).ok_or_else(|| bad(C, format!("no Output Module template “{f}”")))?,
(None, None) => t.default_output_module(TemplateSlot::Movie),
};
if params.is_object() {
apply_output(&mut om, &t, s.state.region_of_interest, &params, C)?;
}
// Templates name files after the composition, not a particular path.
om.output = if om.format.is_sequence() { DEFAULT_SEQUENCE_TEMPLATE } else { DEFAULT_TEMPLATE }.into();
t.save_output_module(&name, om);
}
}
s.edit("Save Template", None, |proj, _| {
proj.render_templates = t;
Ok(())
})?;
Ok(json!({"saved": name, "templates": templates_json(&s.project.render_templates)}))
}
fn delete_template(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "renderQueue.deleteTemplate";
let kind = kind_p(p, C)?;
let name = name_p(p, C)?.to_string();
let mut t = s.project.render_templates.clone();
t.delete(kind, &name).map_err(|e| bad(C, e))?;
s.edit("Delete Template", None, |proj, _| {
proj.render_templates = t;
Ok(())
})?;
Ok(json!({"deleted": name}))
}
fn set_template_default(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "renderQueue.setTemplateDefault";
let kind = kind_p(p, C)?;
let slot = str_p(p, "slot").and_then(TemplateSlot::parse).ok_or_else(|| bad(C, "slot: movie|still|preRender|movieProxy|stillProxy"))?;
let name = name_p(p, C)?.to_string();
let mut t = s.project.render_templates.clone();
t.set_default(kind, slot, &name).map_err(|e| bad(C, e))?;
s.edit("Template Default", None, |proj, _| {
proj.render_templates = t;
Ok(())
})?;
Ok(json!({"slot": format!("{slot:?}"), "name": name}))
}
/// Apply templates to an item: `renderSettings` and/or `outputModule` (to `module`).
fn apply_template(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "renderQueue.applyTemplate";
let i = item_index(s, p, C)?;
let m = module_p(s, i, p, C)?;
let t = s.project.render_templates.clone();
let rs = match str_p(p, "renderSettings") {
Some(n) => Some(t.render_settings(n).ok_or_else(|| bad(C, format!("no Render Settings template “{n}”")))?),
None => None,
};
let om = match str_p(p, "outputModule") {
Some(n) => {
let tpl = t.output_module(n).ok_or_else(|| bad(C, format!("no Output Module template “{n}”")))?;
let mut om = module_ref(&s.project.render_queue[i], m).clone();
apply_om_template(&mut om, &tpl);
Some(om)
}
None => None,
};
if rs.is_none() && om.is_none() {
return Err(bad(C, "pass `renderSettings` and/or `outputModule` (template names)"));
}
s.edit("Apply Template", None, |proj, _| {
let it = &mut proj.render_queue[i];
if let Some(rs) = rs {
it.settings = rs;
}
if let Some(om) = om {
*module_mut(it, m) = om;
}
requeue(it);
Ok(())
})?;
Ok(item_json(s, &s.project.render_queue[i], i))
}
fn set_log(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "renderQueue.setLog";
let i = item_index(s, p, C)?;
let log = enum_p(p, "log", C, RenderLog::parse, "errorsOnly|plusSettings|plusPerFrameInfo")?.ok_or_else(|| bad(C, "pass `log`"))?;
s.edit("Render Queue: Log", None, |proj, _| {
proj.render_queue[i].log = log;
Ok(())
})?;
Ok(item_json(s, &s.project.render_queue[i], i))
}
fn set_notify(s: &mut Session, p: &Value) -> Result<Value> {
let on = b_p(p, "notify").or(b_p(p, "value")).unwrap_or(!s.project.render_prefs.notify);
s.edit("Render Queue: Notify", None, |proj, _| {
proj.render_prefs.notify = on;
Ok(())
})?;
Ok(json!({"notify": on}))
}
fn set_overflow(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "renderQueue.setOverflowFolders";
let folders: Vec<String> = match p.get("folders") {
Some(Value::Array(a)) => a.iter().filter_map(Value::as_str).map(str::trim).filter(|f| !f.is_empty()).map(String::from).collect(),
Some(Value::Null) | None => vec![],
_ => return Err(bad(C, "folders: [path, …]")),
};
s.edit("Storage Overflow Folders", None, |proj, _| {
proj.render_prefs.overflow_folders = folders.clone();
Ok(())
})?;
Ok(json!({"folders": s.project.render_prefs.overflow_folders}))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
@@ -520,7 +902,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Add to Render Queue",
["Composition"],
Some("Cmd+M"),
"{comp?: id|name, format?: h264|prores|webm|png|jpeg|tiff|exr|gif|wav|aiff, output?: path|template, quality?: best|draft|1-100 (jpeg, webm), resolution?: full|half|third|quarter|scale, timeSpan?: workArea|comp|custom, start?: s, end?: s, frameRate?: fps|null, motionBlur?: bool, skipExisting?: bool, proxyUse?: current|all|comp|none, channels?: rgb|rgba, bitrate?: kbps, proresProfile?: proxy|lt|standard|hq|4444|4444xq, audio?: auto|on|off, sampleRate?, loop?: bool}",
"{comp?: id|name, template?: Render Settings template, format?: h264|prores|webm|png|jpeg|tiff|exr|gif|wav|aiff, output?: path|template, log?: errorsOnly|plusSettings|plusPerFrameInfo, quality?: best|draft|1-100 (jpeg, webm), resolution?: full|half|third|quarter|scale, proxyUse?, effects?, solo?, guideLayers?, colorDepth?, frameBlending?, fieldRender?, pulldown?, motionBlur?, timeSpan?: workArea|comp|custom, start?: s, end?: s, duration?: s, frameRate?: fps|null, skipExisting?: bool, storageOverflow?: bool, channels?: rgb|rgba|alpha, color?, bitrate?: kbps, webmBitrate?, keyframeInterval?, proresProfile?, crop?, resize?, includeProjectLink?, audio?: auto|on|off, sampleRate?, audioChannels?, audioFormat?, loop?: bool (values as in setRenderSettings / setOutputModule)}",
can_add,
add
),
@@ -531,7 +913,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Render Settings...",
[],
None,
"{item?|index?, quality?: best|draft, resolution?: full|half|third|quarter|scale, timeSpan?: workArea|comp|custom, start?: s, end?: s, frameRate?: fps|null, motionBlur?: bool, skipExisting?: bool, proxyUse?: current|all|comp|none}",
"{item?|index?, template?: name, quality?: best|draft, resolution?: full|half|third|quarter|scale, proxyUse?: current|all|comp|none, effects?: current|allOn|allOff, solo?: current|allOff, guideLayers?: current|allOff, colorDepth?: current|8|16|32, frameBlending?: current|onForChecked|offForAll, fieldRender?: off|upper|lower, pulldown?: off|WSSWW|SSWWW|SWWWS|WWWSS|WWSSW, motionBlur?: bool|current|onForChecked|offForAll, timeSpan?: workArea|comp|custom, start?: s, end?: s, duration?: s, frameRate?: fps|null, skipExisting?: bool, storageOverflow?: bool}",
has_items,
set_render_settings
),
@@ -540,7 +922,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Output Module Settings...",
[],
None,
"{item?|index?, module?: n (1 = first), format?, channels?: rgb|rgba, quality?: 1-100, bitrate?: kbps, proresProfile?: proxy|lt|standard|hq|4444|4444xq, audio?: auto|on|off, sampleRate?, loop?: bool, output?}",
"{item?|index?, module?: n (1 = first), template?: name, format?, channels?: rgb|rgba|alpha, color?: straight|premultiplied, quality?: 1-100, bitrate?: kbps, webmBitrate?: bool, keyframeInterval?: frames, proresProfile?: proxy|lt|standard|hq|4444|4444xq, crop?: bool|{useRoi?, roi?, top?, left?, bottom?, right?}, cropTop?, cropLeft?, cropBottom?, cropRight?, resize?: bool|{preset?, width?, height?, lockAspect?, quality?: low|high}, resizeWidth?, resizeHeight?, postRenderAction?: none|import|importAndReplace|setProxy, includeProjectLink?: bool, audio?: auto|on|off, sampleRate?, audioChannels?: mono|stereo, audioFormat?: 16|24|32, loop?: bool, output?}",
has_items,
set_output_module
),
@@ -569,6 +951,46 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!("renderQueue.render", "Render", [], None, "{wait?: bool (default true; the UI renders in the background)}", has_queued, render),
cmd!("renderQueue.stop", "Stop Rendering", [], None, "{}", rendering, stop),
query!("renderQueue.list", "Render Queue Items", "{}", list),
query!("renderQueue.templates", "Render Templates", "{}", templates),
cmd!(
"renderQueue.saveTemplate",
"Save Template...",
[],
None,
"{kind: renderSettings|outputModule, name, item?|index? (save from this item), from?: template to copy, module?, params?: {setRenderSettings / setOutputModule keys}}",
always,
save_template
),
cmd!("renderQueue.deleteTemplate", "Delete Template", [], None, "{kind: renderSettings|outputModule, name}", always, delete_template),
cmd!(
"renderQueue.setTemplateDefault",
"Set Template Default",
[],
None,
"{kind: renderSettings|outputModule, slot: movie|still|preRender|movieProxy|stillProxy, name}",
always,
set_template_default
),
cmd!(
"renderQueue.applyTemplate",
"Apply Template",
[],
None,
"{item?|index?, renderSettings?: template name, outputModule?: template name, module?: n}",
has_items,
apply_template
),
cmd!("renderQueue.setLog", "Render Queue: Log", [], None, "{item?|index?, log: errorsOnly|plusSettings|plusPerFrameInfo}", has_items, set_log),
cmd!("renderQueue.setNotify", "Notify When Done", [], None, "{notify?: bool (toggles)}", always, set_notify),
cmd!(
"renderQueue.setOverflowFolders",
"Storage Overflow Folders",
[],
None,
"{folders: [path, …] (used in order when the output volume is full)}",
always,
set_overflow
),
query!("renderQueue.formats", "Output Formats", "{}", formats),
]
}
+3
View File
@@ -285,6 +285,8 @@ pub struct Session {
pub importer: Option<Arc<dyn Importer>>,
/// Render Queue encoder (the export layer); `None` = export unavailable.
pub exporter: Option<Arc<dyn Exporter>>,
/// Free-space hook for Render Settings ▸ Use Storage Overflow (`None`: never full).
pub storage_quota: Option<Arc<dyn effectcraft_project::render_queue::StorageQuota>>,
/// The running (or finished, not yet polled) render.
pub render_job: Option<render_queue::RenderJob>,
/// The running (or finished, not yet polled) track analysis.
@@ -386,6 +388,7 @@ impl Default for Session {
expr_check: None,
importer: None,
exporter: None,
storage_quota: None,
render_job: None,
track_job: None,
mask_job: None,
+22 -2
View File
@@ -27,6 +27,10 @@ pub struct ExportJob<'a> {
pub accel: Option<&'a dyn effectcraft_render::Accelerator>,
/// Resolved output path (templates expanded except the `#` frame-number run).
pub path: &'a str,
/// Free-space hook (Use Storage Overflow).
pub storage: Option<&'a dyn effectcraft_project::render_queue::StorageQuota>,
/// "#n Comp Name", for the render log.
pub label: String,
/// Settings ▸ General ▸ Switches Affect Nested Comps.
pub nested_switches: bool,
}
@@ -41,6 +45,12 @@ pub struct ExportResult {
pub bytes: u64,
pub seconds: f64,
pub audio: bool,
/// The render log written next to the output, if any.
#[serde(skip_serializing_if = "Option::is_none")]
pub log: Option<String>,
/// Files that went to storage overflow folders.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub overflow: Vec<String>,
}
/// Encodes render queue items (implemented by the export layer).
@@ -129,6 +139,7 @@ struct Work {
expr: Option<Arc<dyn ExprHost>>,
accel: Option<Arc<dyn effectcraft_render::Accelerator>>,
exporter: Arc<dyn Exporter>,
storage: Option<Arc<dyn effectcraft_project::render_queue::StorageQuota>>,
/// Per queue item: one (item with that output module, resolved path) per output module
/// (and per segment, see [`Session::segments`]).
items: Vec<Vec<(RenderQueueItem, String)>>,
@@ -199,6 +210,7 @@ pub(crate) fn run_items(
expr: s.expr.clone(),
accel: s.accel.clone(),
exporter,
storage: s.storage_quota.clone(),
items,
nested_switches: s.prefs.general.switches_affect_nested_comps,
};
@@ -232,6 +244,8 @@ fn run_work(w: Work, shared: &JobShared, notify: &mut dyn FnMut(&JobShared)) {
// first module's file is the item's output.
let mut r: Result<ExportResult, String> = Err("no output module".into());
for (mi, (mitem, path)) in modules.iter().enumerate() {
let name = w.project.item(mitem.comp).map(|i| i.name.clone()).unwrap_or_default();
let index = w.project.render_queue.iter().position(|i| i.id == mitem.id).map_or(0, |i| i + 1);
let job = ExportJob {
project: &w.project,
footage: w.footage.as_ref(),
@@ -239,6 +253,8 @@ fn run_work(w: Work, shared: &JobShared, notify: &mut dyn FnMut(&JobShared)) {
accel: w.accel.as_deref(),
item: mitem,
path,
storage: w.storage.as_deref(),
label: format!("#{index} {name} (output module {})", mi + 1),
nested_switches: w.nested_switches,
};
let rr = w.exporter.export(&job, &mut |done, total| {
@@ -301,8 +317,7 @@ impl Session {
if tpl.is_empty() {
return None;
}
let (w, h) = item.settings.output_size(comp);
let (w, h) = item.output.format.coded_size(w, h);
let (w, h) = item.output.output_size(comp, &item.settings);
let rate = item.settings.rate(comp);
let n = item.settings.frame_count(comp) as i64;
let f0 = item.settings.first_frame(comp);
@@ -453,6 +468,7 @@ impl Session {
expr: self.expr.clone(),
accel: self.accel.clone(),
exporter,
storage: self.storage_quota.clone(),
items,
nested_switches: self.prefs.general.switches_affect_nested_comps,
};
@@ -552,6 +568,10 @@ impl Session {
} else {
format!("Rendered {} item(s) in {:.1} s", st.items_total, st.elapsed)
};
if self.project.render_prefs.notify {
// Notify: the frontend plays a sound and shows a system notification.
self.events.push(Event::Frontend { command: "renderQueue.notify".into(), params: serde_json::json!({"message": msg, "error": failed > 0}) });
}
self.events.push(Event::Toast { message: msg, error: failed > 0 });
}
for (id, path) in post {
+117
View File
@@ -312,6 +312,123 @@ fn pre_render_imports_and_replaces() {
assert!(matches!(s.project.comp(outer).unwrap().layers[0].source, LayerSource::Comp { .. }));
}
#[test]
fn post_render_actions_import_and_set_proxy() {
let (mut s, _) = rq();
let cid = s.active_comp_id().unwrap();
let items_before = s.project.items.len();
// Import: the rendered file becomes a footage item.
let a = s.execute("renderQueue.add", json!({"output": "/tmp/post/a.mov"})).unwrap();
s.execute("renderQueue.setOutputModule", json!({"item": a["item"], "postRenderAction": "import"})).unwrap();
assert_eq!(s.project.render_queue[0].post_render, PostRenderAction::Import);
s.execute("renderQueue.render", json!({})).unwrap();
assert_eq!(s.project.items.len(), items_before + 1);
assert!(s.project.items.values().any(|i| matches!(&i.kind, effectcraft_project::ItemKind::Footage(f) if f.path == "/tmp/post/a.mov")));
// Set Proxy: the comp gets the render as its proxy.
let b = s.execute("renderQueue.add", json!({"output": "/tmp/post/b.mov"})).unwrap();
s.execute("renderQueue.setOutputModule", json!({"item": b["item"], "postRenderAction": "Set Proxy"})).unwrap();
s.execute("renderQueue.render", json!({})).unwrap();
let px = s.project.item(cid).unwrap().proxy.clone().expect("proxy set");
assert_eq!(px.footage.path, "/tmp/post/b.mov");
// None: nothing happens.
let n = s.project.items.len();
let c = s.execute("renderQueue.add", json!({"output": "/tmp/post/c.mov"})).unwrap();
s.execute("renderQueue.setOutputModule", json!({"item": c["item"], "postRenderAction": "none"})).unwrap();
s.execute("renderQueue.render", json!({})).unwrap();
assert_eq!(s.project.items.len(), n);
assert!(s.execute("renderQueue.setOutputModule", json!({"item": c["item"], "postRenderAction": "explode"})).is_err());
}
#[test]
fn templates_save_apply_default_and_persist() {
let (mut s, _) = rq();
let t = s.execute("renderQueue.templates", json!({})).unwrap();
let names: Vec<&str> = t["renderSettings"].as_array().unwrap().iter().map(|r| r["name"].as_str().unwrap()).collect();
assert_eq!(names, ["Best Settings", "Draft Settings", "DV Settings", "Multi-Machine Settings", "Current Settings"]);
assert!(t["outputModules"].as_array().unwrap().iter().any(|m| m["name"] == "High Quality with Alpha"));
assert_eq!(t["defaults"]["renderSettings"]["Movie"], "Best Settings");
// A new item starts from the Movie defaults.
let a = s.execute("renderQueue.add", json!({"output": "/tmp/t/a.mp4"})).unwrap();
assert_eq!(a["renderSettingsSummary"], "Best Settings");
assert_eq!(a["outputModuleSummary"], "H.264 - Match Render Settings - 15 Mbps");
// Edit it: Custom; save templates from it; make them the defaults.
let r = s
.execute(
"renderQueue.setRenderSettings",
json!({"item": a["item"], "fieldRender": "upper", "pulldown": "WSSWW", "colorDepth": 16, "effects": "allOff", "solo": "allOff", "guideLayers": "current", "frameBlending": "offForAll", "motionBlur": "onForChecked", "duration": 0.5, "storageOverflow": false}),
)
.unwrap();
assert_eq!(r["renderSettingsSummary"], "Custom");
assert_eq!(r["settings"]["field_render"], "UpperFirst");
s.execute(
"renderQueue.setOutputModule",
json!({"item": a["item"], "format": "png", "channels": "rgba", "color": "premultiplied", "crop": {"top": 2, "bottom": 2}, "resize": {"width": 320, "height": 200, "lockAspect": false, "quality": "low"}}),
)
.unwrap();
s.execute("renderQueue.saveTemplate", json!({"kind": "renderSettings", "name": "Interlaced 16", "item": a["item"]})).unwrap();
s.execute("renderQueue.saveTemplate", json!({"kind": "outputModule", "name": "Small PNG", "item": a["item"], "params": {"quality": 50}})).unwrap();
s.execute("renderQueue.setTemplateDefault", json!({"kind": "renderSettings", "slot": "movie", "name": "Interlaced 16"})).unwrap();
s.execute("renderQueue.setTemplateDefault", json!({"kind": "outputModule", "slot": "movie", "name": "Small PNG"})).unwrap();
assert!(s.execute("renderQueue.setTemplateDefault", json!({"kind": "outputModule", "slot": "movie", "name": "Nope"})).is_err());
let b = s.execute("renderQueue.add", json!({"output": "/tmp/t/b_[####].png"})).unwrap();
assert_eq!(b["renderSettingsSummary"], "Interlaced 16");
assert_eq!(b["outputModuleSummary"], "Small PNG");
let it = s.project.render_queue.last().unwrap().clone();
assert_eq!(it.output.resize.width, 320);
assert_eq!(it.output.output, "/tmp/t/b_[####].png");
assert_eq!((b["width"].as_u64(), b["height"].as_u64()), (Some(320), Some(200)));
// Apply built-ins to an item; the output path keeps its name with the new extension.
let r = s.execute("renderQueue.applyTemplate", json!({"item": a["item"], "renderSettings": "DV Settings", "outputModule": "High Quality"})).unwrap();
assert_eq!(r["renderSettingsSummary"], "DV Settings");
assert_eq!(r["outputModuleSummary"], "High Quality");
assert!(r["outputPath"].as_str().unwrap().ends_with("a.mov"), "{r}");
// Persisted with the project.
let p = effectcraft_project::Project::from_json(&s.project.to_json()).unwrap();
assert_eq!(p.render_templates, s.project.render_templates);
assert_eq!(p.render_queue, s.project.render_queue);
// Delete: saved ones go; built-ins stay. Undo restores.
s.execute("renderQueue.deleteTemplate", json!({"kind": "outputModule", "name": "Small PNG"})).unwrap();
assert!(s.project.render_templates.output_module("Small PNG").is_none());
assert!(s.execute("renderQueue.deleteTemplate", json!({"kind": "renderSettings", "name": "Best Settings"})).is_err());
s.execute("edit.undo", json!({})).unwrap();
assert!(s.project.render_templates.output_module("Small PNG").is_some());
}
#[test]
fn save_frame_as_queues_with_the_frame_defaults() {
let (mut s, _) = rq();
s.execute("renderQueue.setTemplateDefault", json!({"kind": "outputModule", "slot": "still", "name": "TIFF Sequence with Alpha"})).unwrap();
let r = s.execute("comp.saveFrameAs", json!({"queue": true, "time": 1.0})).unwrap();
assert_eq!(r["queued"], true);
let it = s.project.render_queue.last().unwrap();
assert_eq!(it.output.format, OutputFormat::TiffSequence);
assert_eq!(it.settings.name, "Current Settings");
let comp = s.project.comp(it.comp).unwrap();
assert_eq!(it.settings.frame_count(comp), 1);
assert_eq!(it.settings.frame_time(comp, 0), Tick::from_seconds_f64(1.0));
}
#[test]
fn log_notify_and_overflow_settings() {
let (mut s, _) = rq();
let a = s.execute("renderQueue.add", json!({"output": "/tmp/n/a.mp4", "log": "plusPerFrameInfo"})).unwrap();
assert_eq!(a["logLabel"], "Plus Per Frame Info");
assert_eq!(a["logPath"], "/tmp/n/a_RenderLog.txt");
s.execute("renderQueue.setLog", json!({"item": a["item"], "log": "plusSettings"})).unwrap();
assert_eq!(s.project.render_queue[0].log, effectcraft_project::render_queue::RenderLog::PlusSettings);
s.execute("renderQueue.setOverflowFolders", json!({"folders": ["/tmp/o1", " ", "/tmp/o2"]})).unwrap();
assert_eq!(s.project.render_prefs.overflow_folders, ["/tmp/o1", "/tmp/o2"]);
assert_eq!(s.execute("renderQueue.setNotify", json!({})).unwrap()["notify"], true);
s.drain_events();
s.execute("renderQueue.render", json!({})).unwrap();
let ev = s.drain_events();
assert!(ev.iter().any(|e| matches!(e, Event::Frontend { command, .. } if command == "renderQueue.notify")), "{ev:?}");
s.execute("renderQueue.setNotify", json!({"notify": false})).unwrap();
s.execute("renderQueue.setRender", json!({"index": 1, "render": true})).unwrap();
s.execute("renderQueue.render", json!({})).unwrap();
assert!(!s.drain_events().iter().any(|e| matches!(e, Event::Frontend { command, .. } if command == "renderQueue.notify")));
}
#[test]
fn save_current_preview_writes_a_movie() {
let (mut s, ex) = rq();
+1
View File
@@ -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
View File
@@ -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)]
+103 -71
View File
@@ -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 intra frames (`effectcraft-vp9enc`), 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) |
//! | 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 | — | — | 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 {
@@ -71,6 +81,21 @@ pub struct Job<'a> {
pub sink: Option<&'a Sink>,
/// Settings ▸ General ▸ Switches Affect Nested Comps (see `RenderOpts::nested_switches`).
pub nested_switches: bool,
/// 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.
@@ -102,6 +127,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).
@@ -109,10 +138,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.
@@ -127,19 +155,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();
@@ -172,39 +211,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,
nested_switches: job.nested_switches,
draft_shadows: true,
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;
}
@@ -221,10 +229,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;
@@ -233,13 +244,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();
@@ -249,11 +265,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 => {
@@ -264,6 +290,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 };
@@ -282,16 +312,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 {
@@ -305,11 +336,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);
@@ -324,8 +356,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();
@@ -344,7 +376,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)]
+329
View File
@@ -0,0 +1,329 @@
//! 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,
nested_switches: self.job.nested_switches,
draft_shadows: true,
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);
}
}
+96 -49
View File
@@ -5,19 +5,22 @@
//! (size patched at the end), Info, Tracks and one Cluster per render batch. Video frames are
//! SimpleBlocks; with alpha (Channels: RGB + Alpha) each frame is a BlockGroup whose
//! BlockAdditional (BlockAddID 1) carries a second VP9 frame coding the alpha channel as luma,
//! with `AlphaMode` 1 on the track, as WebM defines. VP9 comes from `effectcraft-vp9enc` (every
//! frame a key frame), Opus from `effectcraft-opusenc` (CELT, 48 kHz stereo, 20 ms packets,
//! with `AlphaMode` 1 on the track, as WebM defines. VP9 comes from `effectcraft-vp9enc` (key
//! frames every two seconds, inter frames between them: SimpleBlocks carry the key flag, inter
//! BlockGroups a ReferenceBlock; the alpha stream's key frames line up with the colour stream's),
//! Opus from `effectcraft-opusenc` (CELT, 48 kHz stereo, 20 ms packets,
//! `CodecDelay` = pre-skip). WAV (RIFF, 16-bit PCM) and AIFF (big-endian 16-bit, 80-bit
//! extended sample rate) follow their published file layouts.
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;
@@ -82,6 +85,10 @@ fn block(track: u64, rel: i16, flags: u8, data: &[u8]) -> Vec<u8> {
struct Frame {
ms: i64,
track: u64,
/// A key frame (VP9) or any audio packet.
key: bool,
/// Timestamp of the previous video frame (ReferenceBlock of inter frames).
prev_ms: Option<i64>,
data: Vec<u8>,
alpha: Option<Vec<u8>>,
}
@@ -94,10 +101,15 @@ fn cluster(frames: &mut [Frame]) -> Vec<u8> {
for f in frames.iter() {
let rel = (f.ms - base).clamp(i16::MIN as i64, i16::MAX as i64) as i16;
match &f.alpha {
None => el(&mut body, 0xA3, &block(f.track, rel, 0x80, &f.data)),
None => el(&mut body, 0xA3, &block(f.track, rel, if f.key { 0x80 } else { 0x00 }, &f.data)),
Some(a) => {
let mut g = vec![];
el(&mut g, 0xA1, &block(f.track, rel, 0x00, &f.data));
if let (false, Some(p)) = (f.key, f.prev_ms) {
// ReferenceBlock: a signed timestamp relative to this block (an inter frame).
let d = (p - f.ms).clamp(i16::MIN as i64, -1) as i16;
el(&mut g, 0xFB, &d.to_be_bytes());
}
let mut more = vec![];
el_uint(&mut more, 0xEE, 1);
el(&mut more, 0xA5, a);
@@ -113,18 +125,33 @@ 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 cfg = effectcraft_vp9enc::EncoderConfig { width: w, height: h, quality, full_range: false };
let fps = rate.as_f64().max(1.0);
let cfg = effectcraft_vp9enc::EncoderConfig {
width: w,
height: h,
quality,
full_range: false,
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![];
@@ -178,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);
}
@@ -197,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;
@@ -209,7 +236,7 @@ pub(crate) fn webm(job: &Job, comp: &Comp, w: u32, h: u32, st: &mut State) -> Re
while pcm_left.len() >= fs {
let pkt = enc.encode_float(&pcm_left[..fs]);
pcm_left.drain(..fs);
frames.push(Frame { ms: packets as i64 * 20, track: 2, data: pkt, alpha: None });
frames.push(Frame { ms: packets as i64 * 20, track: 2, key: true, prev_ms: None, data: pkt, alpha: None });
packets += 1;
}
};
@@ -218,23 +245,28 @@ pub(crate) fn webm(job: &Job, comp: &Comp, w: u32, h: u32, st: &mut State) -> Re
let mut i = 0;
while i < total {
let end = (i + batch).min(total);
let encoded: Vec<(Vec<u8>, Option<Vec<u8>>)> = (i..end)
.into_par_iter()
.map(|k| {
let px = rgba8(&render_frame(job, comp, k), comp, if alpha { Channels::Rgba } else { Channels::Rgb }, w, h);
let mut enc = effectcraft_vp9enc::Vp9Encoder::new(cfg.clone());
let (y, u, vv) = effectcraft_vp9enc::rgba_to_yuv420(&px, w, h, false);
let color = enc.encode_yuv420(&y, &u, &vv);
let a = alpha.then(|| {
let mut ea = effectcraft_vp9enc::Vp9Encoder::new(cfg.clone());
let (y, u, vv) = effectcraft_vp9enc::alpha_to_yuv420(&px, w, h);
ea.encode_yuv420(&y, &u, &vv)
});
(color, a)
})
.collect();
let mut frames: Vec<Frame> =
encoded.into_iter().enumerate().map(|(j, (data, alpha))| Frame { ms: frame_ms(i + j as u64), track: 1, data, alpha }).collect();
// 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| 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(
|| {
let (y, u, vv) = effectcraft_vp9enc::rgba_to_yuv420(px, w, h, false);
color_enc.encode_frame(&y, &u, &vv, false)
},
|| {
alpha_enc.as_mut().map(|ea| {
let (y, u, vv) = effectcraft_vp9enc::alpha_to_yuv420(px, w, h);
ea.encode_frame(&y, &u, &vv, false).data
})
},
);
let k = i + j as u64;
frames.push(Frame { ms: frame_ms(k), track: 1, key: color.key, prev_ms: (k > 0).then(|| frame_ms(k - 1)), data: color.data, alpha: a });
}
drop(pixels);
let t_end = if end >= total { span_end } else { job.settings.frame_time(comp, end) };
audio_until(t_end, &mut frames, end >= total);
let c = cluster(&mut frames);
@@ -260,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
@@ -278,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;
@@ -294,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 {
@@ -308,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");
@@ -331,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)]
+322
View File
@@ -0,0 +1,322 @@
//! 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,
nested_switches: true,
};
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");
}
+39 -5
View File
@@ -55,7 +55,19 @@ 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, nested_switches: true };
let job = Job {
project: p,
footage,
expr: None,
accel: None,
comp: cid,
settings: &s,
output: om,
path: &path,
sink: None,
options: Default::default(),
nested_switches: true,
};
let mut last = Progress::default();
let mut calls = 0;
let r = export(&job, &mut |pr| {
@@ -167,8 +179,19 @@ 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, nested_switches: true };
let job = Job {
project: &p,
footage: &NoFootage,
expr: None,
accel: None,
comp: cid,
settings: &s,
output: &om,
path: &path,
sink: None,
options: Default::default(),
nested_switches: true,
};
let r = export(&job, &mut |_| true).expect("export");
assert_eq!((r.width % 2, r.height % 2), (0, 0));
}
@@ -288,8 +311,19 @@ 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, nested_switches: true };
let job = Job {
project: &p,
footage: &NoFootage,
expr: None,
accel: None,
comp: cid,
settings: &s,
output: &om,
path: &path,
sink: None,
options: Default::default(),
nested_switches: true,
};
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);
+177 -4
View File
@@ -61,7 +61,7 @@ fn lossless_is_exact_for_odd_sizes() {
for (w, h) in [(1, 1), (33, 17), (64, 64), (200, 120), (7, 70)] {
for kind in 0..3 {
let (y, u, v) = source(w, h, kind);
let mut e = Vp9Encoder::new(EncoderConfig { width: w as u32, height: h as u32, quality: 100, full_range: false });
let mut e = Vp9Encoder::new(EncoderConfig { width: w as u32, height: h as u32, quality: 100, full_range: false, ..Default::default() });
let f = e.encode_yuv420(&y, &u, &v);
let p = decode(&f);
assert_eq!((p.width, p.height), (w as u32, h as u32));
@@ -80,7 +80,7 @@ fn lossy_quality_and_size() {
let (y, u, v) = source(w, h, kind);
let mut last = 0usize;
for q in [30u8, 60, 80, 95] {
let mut e = Vp9Encoder::new(EncoderConfig { width: w as u32, height: h as u32, quality: q, full_range: false });
let mut e = Vp9Encoder::new(EncoderConfig { width: w as u32, height: h as u32, quality: q, full_range: false, ..Default::default() });
let f = e.encode_yuv420(&y, &u, &v);
let p = decode(&f);
let got = crop(&plane_u8(&p.y), p.y_stride, w, h);
@@ -103,7 +103,7 @@ fn wide_frames_use_tile_columns() {
// 4160 px > 64 superblocks: two tile columns are required.
let (w, h) = (4160, 16);
let (y, u, v) = source(w, h, 0);
let mut e = Vp9Encoder::new(EncoderConfig { width: w as u32, height: h as u32, quality: 90, full_range: false });
let mut e = Vp9Encoder::new(EncoderConfig { width: w as u32, height: h as u32, quality: 90, full_range: false, ..Default::default() });
let f = e.encode_yuv420(&y, &u, &v);
let p = decode(&f);
assert!(psnr(&crop(&plane_u8(&p.y), p.y_stride, w, h), &y) > 35.0);
@@ -113,7 +113,7 @@ fn wide_frames_use_tile_columns() {
fn ffmpeg_decodes_our_stream() {
let (w, h) = (96, 64);
let (y, u, v) = source(w, h, 0);
let mut e = Vp9Encoder::new(EncoderConfig { width: w as u32, height: h as u32, quality: 100, full_range: false });
let mut e = Vp9Encoder::new(EncoderConfig { width: w as u32, height: h as u32, quality: 100, full_range: false, ..Default::default() });
let frame = e.encode_yuv420(&y, &u, &v);
// IVF container (one frame).
let mut ivf = Vec::new();
@@ -147,3 +147,176 @@ fn ffmpeg_decodes_our_stream() {
assert_eq!(&out.stdout[..w * h], &y[..], "ffmpeg luma matches (lossless)");
assert_eq!(&out.stdout[w * h..w * h + u.len()], &u[..]);
}
// ---------------------------------------------------------------- inter frames
/// Frame `t` of a moving sequence: a diagonal gradient with a sinusoidal texture and bright
/// squares, all translating by a fractional amount per frame (`kind` 1 adds noise that changes
/// every frame).
fn moving(w: usize, h: usize, t: usize, kind: u32) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
let (cw, ch) = (w.div_ceil(2), h.div_ceil(2));
let (dx, dy) = (1.75 * t as f64, 0.6 * t as f64);
let mut s = 1234u32 + t as u32 * 77;
let mut rnd = || {
s = s.wrapping_mul(1664525).wrapping_add(1013904223);
(s >> 28) as f64
};
let lum = |x: f64, y: f64| -> f64 {
let g = 40.0 + 120.0 * (x / w as f64) + 50.0 * (y / h as f64);
let tex = 18.0 * ((x * 0.31).sin() * (y * 0.23).cos());
let sq = if (x - 20.0).rem_euclid(64.0) < 18.0 && (y - 10.0).rem_euclid(48.0) < 14.0 { 45.0 } else { 0.0 };
g + tex + sq
};
let mut y = vec![0u8; w * h];
for yy in 0..h {
for x in 0..w {
let n = if kind == 1 { rnd() - 7.5 } else { 0.0 };
y[yy * w + x] = (lum(x as f64 - dx, yy as f64 - dy) + n).round().clamp(0.0, 255.0) as u8;
}
}
let u: Vec<u8> = (0..cw * ch).map(|i| (100.0 + 40.0 * (((i % cw) as f64 * 2.0 - dx) / w as f64)).round() as u8).collect();
let v: Vec<u8> = (0..cw * ch).map(|i| (150.0 - 30.0 * (((i / cw) as f64 * 2.0 - dy) / h as f64)).round() as u8).collect();
(y, u, v)
}
fn planes_of(p: &filmcraft_vp9::Picture, w: usize, h: usize) -> [Vec<u8>; 3] {
let (cw, ch) = (w.div_ceil(2), h.div_ceil(2));
[crop(&plane_u8(&p.y), p.y_stride, w, h), crop(&plane_u8(&p.u), p.uv_stride, cw, ch), crop(&plane_u8(&p.v), p.uv_stride, cw, ch)]
}
/// Encode `n` frames and decode them with one decoder: every decoded frame must equal the
/// encoder's reconstruction. Returns (total bytes, mean luma PSNR vs the source).
fn run_sequence(cfg: EncoderConfig, n: usize, kind: u32) -> (usize, f64) {
let (w, h) = (cfg.width as usize, cfg.height as usize);
let mut e = Vp9Encoder::new(cfg);
let mut d = Decoder::new();
let (mut bytes, mut db) = (0usize, 0.0);
for t in 0..n {
let (y, u, v) = moving(w, h, t, kind);
let f = e.encode_frame(&y, &u, &v, false);
bytes += f.data.len();
let kind_s = if f.key { "key" } else { "inter" };
let mut pics = d.decode(&f.data, t as i64).unwrap_or_else(|err| panic!("{w}x{h} frame {t} ({kind_s}): {err:?}"));
assert_eq!(pics.len(), 1);
let got = planes_of(&pics.remove(0), w, h);
let rec = e.reconstruction().expect("reconstruction");
for k in 0..3 {
if got[k] != rec[k] {
let first = got[k].iter().zip(&rec[k]).position(|(a, b)| a != b).unwrap_or(0);
let pw = if k == 0 { w } else { w.div_ceil(2) };
panic!("{w}x{h} frame {t} ({kind_s}, q{} lf{}): plane {k} differs first at ({}, {})", f.q_idx, f.filter_level, first % pw, first / pw);
}
}
db += psnr(&got[0], &y);
}
(bytes, db / n as f64)
}
#[test]
fn inter_frames_decode_exactly() {
for (w, h) in [(64, 64), (96, 72), (33, 17), (130, 66)] {
for (q, kind) in [(30u8, 0u32), (70, 1), (92, 0), (100, 0)] {
let cfg = EncoderConfig { width: w as u32, height: h as u32, quality: q, keyframe_interval: 6, ..Default::default() };
run_sequence(cfg, 8, kind);
}
}
}
#[test]
fn inter_frames_with_tile_columns_decode_exactly() {
let cfg = EncoderConfig { width: 4160, height: 24, quality: 80, keyframe_interval: 0, ..Default::default() };
run_sequence(cfg, 3, 0);
}
#[test]
fn inter_psnr_rises_with_bitrate() {
let (w, h) = (160, 96);
let mut last = (0usize, 0.0f64);
let mut rows = vec![];
for kbps in [60u32, 150, 400, 1200] {
let cfg = EncoderConfig { width: w, height: h, quality: 60, target_kbps: Some(kbps), frame_rate: 30.0, keyframe_interval: 30, ..Default::default() };
let (bytes, db) = run_sequence(cfg, 20, 0);
rows.push(format!("{kbps} kbps target: {:.0} kbps actual, {db:.2} dB", bytes as f64 * 8.0 * 30.0 / 20.0 / 1000.0));
assert!(bytes > last.0 && db > last.1, "{rows:#?}");
last = (bytes, db);
}
eprintln!("{rows:#?}");
}
#[test]
fn inter_frames_are_much_smaller_than_intra() {
let (w, h) = (192u32, 108u32);
let mut report = vec![];
for q in [50u8, 75, 90] {
let intra = EncoderConfig { width: w, height: h, quality: q, keyframe_interval: 1, ..Default::default() };
let inter = EncoderConfig { keyframe_interval: 0, ..intra.clone() };
let (bi, pi) = run_sequence(intra, 24, 0);
let (bp, pp) = run_sequence(inter, 24, 0);
report.push(format!("q{q}: intra {bi} B {pi:.2} dB, inter {bp} B {pp:.2} dB, ratio {:.2}", bi as f64 / bp as f64));
assert!(pp > pi - 1.0, "{report:#?}");
assert!(bi as f64 >= 3.0 * bp as f64, "{report:#?}");
}
eprintln!("{report:#?}");
}
#[test]
fn ffmpeg_decodes_inter_frames_like_the_encoder() {
let (w, h) = (96usize, 64usize);
let mut e = Vp9Encoder::new(EncoderConfig { width: w as u32, height: h as u32, quality: 75, keyframe_interval: 5, ..Default::default() });
let n = 7;
let mut ivf = Vec::new();
ivf.extend_from_slice(b"DKIF");
ivf.extend_from_slice(&0u16.to_le_bytes());
ivf.extend_from_slice(&32u16.to_le_bytes());
ivf.extend_from_slice(b"VP90");
ivf.extend_from_slice(&(w as u16).to_le_bytes());
ivf.extend_from_slice(&(h as u16).to_le_bytes());
ivf.extend_from_slice(&30u32.to_le_bytes());
ivf.extend_from_slice(&1u32.to_le_bytes());
ivf.extend_from_slice(&(n as u32).to_le_bytes());
ivf.extend_from_slice(&0u32.to_le_bytes());
let mut recon = vec![];
for t in 0..n {
let (y, u, v) = moving(w, h, t, 0);
let f = e.encode_frame(&y, &u, &v, false);
ivf.extend_from_slice(&(f.data.len() as u32).to_le_bytes());
ivf.extend_from_slice(&(t as u64).to_le_bytes());
ivf.extend_from_slice(&f.data);
recon.push(e.reconstruction().expect("recon"));
}
let dir = std::env::temp_dir().join(format!("effectcraft-vp9-inter-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("a.ivf");
std::fs::write(&path, &ivf).unwrap();
let out = std::process::Command::new("ffmpeg")
.args(["-hide_banner", "-loglevel", "error", "-i"])
.arg(&path)
.args(["-f", "rawvideo", "-pix_fmt", "yuv420p", "-"])
.output();
let Ok(out) = out else {
eprintln!("ffmpeg not found: skipping oracle");
return;
};
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
let fsz = w * h + 2 * w.div_ceil(2) * h.div_ceil(2);
assert_eq!(out.stdout.len(), fsz * n);
for (t, r) in recon.iter().enumerate() {
let frame = &out.stdout[t * fsz..(t + 1) * fsz];
assert_eq!(&frame[..w * h], &r[0][..], "ffmpeg luma of frame {t}");
assert_eq!(&frame[w * h..w * h + r[1].len()], &r[1][..], "ffmpeg U of frame {t}");
}
}
/// Encoder speed on 720p (run with `--ignored --nocapture`).
#[test]
#[ignore]
fn bench_720p() {
let (w, h) = (1280usize, 720usize);
let mut e = Vp9Encoder::new(EncoderConfig { width: w as u32, height: h as u32, quality: 80, keyframe_interval: 0, ..Default::default() });
for t in 0..6 {
let (y, u, v) = moving(w, h, t, 0);
let t0 = std::time::Instant::now();
let f = e.encode_frame(&y, &u, &v, false);
eprintln!("frame {t} ({}): {} B in {:.0} ms", if f.key { "key" } else { "inter" }, f.data.len(), t0.elapsed().as_secs_f64() * 1000.0);
}
}
+17 -1
View File
@@ -40,10 +40,26 @@ 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(),
},
nested_switches: job.nested_switches,
};
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()),
}
+16 -1
View File
@@ -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;
@@ -940,11 +941,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(),
}
}
}
+732 -11
View File
@@ -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));
+323
View File
@@ -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);
}
}
+2
View File
@@ -74,6 +74,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.).
+12 -2
View File
@@ -512,8 +512,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" => {
+1
View File
@@ -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),
}
}
+1
View File
@@ -33,6 +33,7 @@ pub mod properties;
pub mod puppet_tool;
pub mod render_queue;
pub mod roto_tool;
pub mod rq_templates;
pub mod scopes_panel;
pub mod script_console;
pub mod scriptui_view;
+229 -30
View File
@@ -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;
+166
View File
@@ -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();
}
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "effectcraft-vp9enc"
description = "VP9 intra-frame encoder (profile 0, 8-bit 4:2:0) for WebM export, from the VP9 Bitstream & Decoding Process Specification"
description = "VP9 encoder (profile 0, 8-bit 4:2:0; intra + inter frames, loop filter, rate control) for WebM export, from the VP9 Bitstream & Decoding Process Specification"
version.workspace = true
edition.workspace = true
license.workspace = true
+42 -10
View File
@@ -1,15 +1,47 @@
# effectcraft-vp9enc
A small VP9 encoder for WebM export: every frame is a key frame (profile 0, 8-bit 4:2:0,
BT.709), coded with 8×8 intra blocks (DC / V / H / TM prediction chosen per block), 4×4
DCT/ADST transforms with default probabilities, or the Walsh–Hadamard transform at quality 100
(mathematically lossless).
A small VP9 encoder for WebM export (profile 0, 8-bit 4:2:0, BT.709).
* **Key frames**: 8×8 intra blocks (DC / V / H / TM prediction chosen per block), 4×4 DCT/ADST
transforms, or the Walsh–Hadamard transform at quality 100 (mathematically lossless).
* **Inter frames**: one reference (the previous frame, LAST), 16×16 or 8×8 blocks chosen by a
cost comparison; motion search from the predicted vectors (NearestMv / NearMv / last frame's
co-located vector) with a whole-sample cross/diamond search refined to half and quarter
samples with the regular 8-tap interpolation filters; NEARESTMV / NEARMV / ZEROMV / NEWMV
chosen by bit cost; blocks with no coefficients worth coding are skipped; 4×4 DCT residual
with a rate–distortion check per transform block. Inter frames are coded error resilient
(`error_resilient_mode` = 1: no dependence on earlier probability contexts or motion vectors),
so `UsePrevFrameMvs` is never needed. No high-precision (1/8) vectors, no compound prediction,
no intra blocks inside inter frames.
* **Loop filter**: the in-loop deblocking filter (§8.8) on every lossy frame; the level is the
best of three trials against the source (sharpness 0, no mode/reference deltas).
* **Probabilities**: per-frame forward updates in the compressed header (`diff_update_prob` /
`update_mv_prob`) of the coefficient, skip, inter-mode, is-inter, reference, partition and
motion-vector probabilities. Tiles are recorded as symbols first, the frame's own counts pick
the updates that pay for their signalling, then the symbols are entropy coded with the updated
probabilities. There is no backward adaptation (`refresh_frame_context` = 0).
* **Rate control**: constant quality (quality → `base_q_idx`) or a target bitrate: a per-frame
q index from a bits ≈ complexity / step model per frame type, key frames getting a larger share
of each key-frame interval, with the running over/undershoot paid back over about a second.
* **Key frame interval** is configurable (1 = every frame a key frame, the old intra-only mode).
The encoder reconstructs exactly what a decoder reconstructs (inter prediction with the
decoder's vector clamping and edge-clamped reference fetches, integer inverse transforms, the
loop filter) and exposes it (`Vp9Encoder::reconstruction`); the tests require FilmCraft's VP9
decoder (and ffmpeg, when installed) to output exactly that reconstruction, frame by frame, for
moving synthetic sequences at several sizes and qualities, with tile columns, and lossless.
On a moving-gradient sequence (192×108, 24 frames) inter coding is 3.9–7× smaller than all-intra
at the same quality setting with equal or better PSNR.
Written from the **VP9 Bitstream & Decoding Process Specification, version 0.6 (31 March
2016)**: the encoder emits the uncompressed and compressed headers (§6.2, §6.3), partition, mode
info and token syntax (§6.4) and reconstructs with the specification's intra prediction (§8.5.1)
and integer inverse transforms (§8.7) so it stays in step with any conformant decoder. The
boolean encoder is the inverse of the §9.2 decoder in the form given in RFC 6386 §7.3. Spec
tables (§10) were transcribed from FilmCraft's first-party decoder `filmcraft-vp9` (MIT OR
Apache-2.0), which is also the decoder the tests round-trip through; ffmpeg is used only as an
external test oracle. No libvpx or other third-party encoder/decoder source was consulted.
info, motion vector and token syntax (§6.4), derives motion vector candidates with the §6.4
`find_mv_refs` / `find_best_ref_mvs` processes, and reconstructs with the specification's intra
and inter prediction (§8.5), integer inverse transforms (§8.7) and loop filter (§8.8) so it stays
in step with any conformant decoder. The boolean encoder is the inverse of the §9.2 decoder in
the form given in RFC 6386 §7.3. Spec tables (§10) are the specification's; the key-frame tables
were transcribed from FilmCraft's first-party decoder `filmcraft-vp9` (MIT OR Apache-2.0), which
is also the decoder the tests round-trip through and whose behaviour (loop filter edge rules,
vector candidate order) the encoder was checked against; ffmpeg is used only as an external test
oracle. No libvpx or other third-party encoder/decoder source was consulted.
-25
View File
@@ -64,31 +64,6 @@ impl BoolEncoder {
}
}
/// Write `value` with a tree (leaves stored negated, as in the decoder's tables).
pub fn tree(&mut self, tree: &[i8], probs: &[u8], value: u8) {
// Find the path to the leaf.
fn path(tree: &[i8], node: usize, value: u8, out: &mut Vec<(usize, bool)>) -> bool {
for b in 0..2 {
let t = tree[node + b];
out.push((node >> 1, b == 1));
if t <= 0 {
if (-t) as u8 == value {
return true;
}
} else if path(tree, t as usize, value, out) {
return true;
}
out.pop();
}
false
}
let mut p = Vec::with_capacity(8);
path(tree, 0, value, &mut p);
for (i, bit) in p {
self.write(bit, probs[i]);
}
}
/// Flush (RFC 6386 §7.3) and return the bytes.
pub fn finish(mut self) -> Vec<u8> {
let mut c = self.bit_count;
+124
View File
@@ -0,0 +1,124 @@
//! The in-loop deblocking filter (spec 8.8), as the decoder applies it to a frame coded with 4×4
//! transforms, 8×8 / 16×16 blocks, no segmentation and no reference/mode deltas: superblocks in
//! raster order, for each plane the vertical edges (pass 0) then the horizontal edges (pass 1).
//! Transform edges inside a skipped inter block are left alone; every 4×4 edge uses the 4-tap
//! filter except edges on a 32-sample boundary, which get the 8-tap (flat) filter.
use crate::{BLOCK_16X16, Mi, Plane};
#[derive(Clone, Copy)]
struct Limits {
limit: i32,
blimit: i32,
thresh: i32,
}
/// Filter the reconstructed planes (Y, U, V, or a prefix of them) in place at `level` (0 = off;
/// sharpness 0).
pub fn filter_frame(planes: &mut [Plane], mi: &[Mi], mi_rows: usize, mi_cols: usize, level: u8) {
if level == 0 {
return;
}
let l = level as i32;
let limit = l.max(1);
let lim = Limits { limit, blimit: 2 * (l + 2) + limit, thresh: l >> 4 };
for sbr in 0..mi_rows.div_ceil(8) {
for sbc in 0..mi_cols.div_ceil(8) {
for (plane, p) in planes.iter_mut().enumerate() {
for pass in 0..2 {
sb_plane(p, mi, mi_rows, mi_cols, plane, pass, sbr * 8, sbc * 8, lim);
}
}
}
}
}
#[allow(clippy::too_many_arguments)]
fn sb_plane(p: &mut Plane, mi: &[Mi], mi_rows: usize, mi_cols: usize, plane: usize, pass: usize, row: usize, col: usize, lim: Limits) {
let (sx, sy) = if plane > 0 { (1, 1) } else { (0, 0) };
let (sub, edge_len) = if pass == 0 { (sx, 64 >> sy) } else { (sy, 64 >> sx) };
let stride = p.w;
for edge in 0..(16usize >> sub) {
let mut i = 0;
while i < edge_len {
let (x, y) = if pass == 0 { (col * 8 + edge * (4 << sx), row * 8 + (i << sy)) } else { (col * 8 + (i << sx), row * 8 + edge * (4 << sy)) };
if x >= 8 * mi_cols || y >= 8 * mi_rows || (pass == 0 && x == 0) || (pass == 1 && y == 0) {
i += 8;
continue;
}
let lc = ((x >> 3) >> sx) << sx;
let lr = ((y >> 3) >> sy) << sy;
let m = mi[lr * mi_cols + lc];
let size = if sub == 0 { m.size } else { m.size.max(BLOCK_16X16) };
let n8 = if size >= BLOCK_16X16 { 2 } else { 1 };
let is_block_edge = if pass == 0 { x & (8 * n8 - 1) == 0 } else { y & (8 * n8 - 1) == 0 };
let tx_ok = !m.inter || !m.skip;
if !is_block_edge && !tx_ok {
i += 8;
continue;
}
let wide = edge % 8 == 0;
let n = if pass == 0 { (8 * mi_rows - y).div_ceil(1 << sy) } else { (8 * mi_cols - x).div_ceil(1 << sx) }.min(8).min(edge_len - i);
// A transform edge is not filtered in the last odd chroma column.
let odd_last = pass == 1 && sx == 1 && mi_cols & 1 == 1 && edge & 1 == 1;
let (px, py) = (x >> sx, y >> sy);
for l in 0..n {
let is_tx_edge = tx_ok && !(odd_last && x + (l << sx) + 8 >= mi_cols * 8);
if !(is_block_edge || is_tx_edge) {
continue;
}
let (pos, step) = if pass == 0 { ((py + l) * stride + px, 1) } else { (py * stride + px + l, stride) };
filter_lane(&mut p.rec, pos, step, wide, lim);
}
i += 8;
}
}
}
/// Filter mask, narrow (4-tap) and 8-tap flat filters of spec 8.8.5 across one edge position;
/// `pos` is q0, samples across the edge are `step` apart.
fn filter_lane(d: &mut [u8], pos: usize, step: usize, wide: bool, lim: Limits) {
let at = |k: isize| d[(pos as isize + k * step as isize) as usize] as i32;
let (p3, p2, p1, p0) = (at(-4), at(-3), at(-2), at(-1));
let (q0, q1, q2, q3) = (at(0), at(1), at(2), at(3));
let Limits { limit, blimit, thresh } = lim;
let mask = (p3 - p2).abs() <= limit
&& (p2 - p1).abs() <= limit
&& (p1 - p0).abs() <= limit
&& (q1 - q0).abs() <= limit
&& (q2 - q1).abs() <= limit
&& (q3 - q2).abs() <= limit
&& (p0 - q0).abs() * 2 + (p1 - q1).abs() / 2 <= blimit;
if !mask {
return;
}
let flat =
wide && (p1 - p0).abs() <= 1 && (q1 - q0).abs() <= 1 && (p2 - p0).abs() <= 1 && (q2 - q0).abs() <= 1 && (p3 - p0).abs() <= 1 && (q3 - q0).abs() <= 1;
let mut set = |k: isize, v: i32| d[(pos as isize + k * step as isize) as usize] = v as u8;
if flat {
// 8-tap: positions p2..q2, each the rounded mean of a 7-wide clamped window plus itself.
let s = [p3, p2, p1, p0, q0, q1, q2, q3];
let px = |k: isize| s[(k.clamp(-4, 3) + 4) as usize];
for k in -3isize..3 {
let mut sum = px(k);
for j in -3..=3 {
sum += px(k + j);
}
set(k, (sum + 4) >> 3);
}
return;
}
let c = |v: i32| v.clamp(-128, 127);
let hev = (p1 - p0).abs() > thresh || (q1 - q0).abs() > thresh;
let (ps1, ps0, qs0, qs1) = (p1 - 128, p0 - 128, q0 - 128, q1 - 128);
let filter = c(if hev { c(ps1 - qs1) } else { 0 } + 3 * (qs0 - ps0));
let f1 = c(filter + 4) >> 3;
let f2 = c(filter + 3) >> 3;
set(0, c(qs0 - f1) + 128);
set(-1, c(ps0 + f2) + 128);
if !hev {
let f = (f1 + 1) >> 1;
set(1, c(qs1 - f) + 128);
set(-2, c(ps1 + f) + 128);
}
}
+266 -668
View File
File diff suppressed because it is too large. Load diff
+207
View File
@@ -0,0 +1,207 @@
//! Inter prediction exactly as the decoder forms it: motion vector clamping (spec 8.5.2.2) and
//! the unscaled block inter prediction process (8.5.2.4) with the regular 8-tap filters and
//! reference samples clamped to the reference frame's visible area.
use crate::tables::SUBPEL_REGULAR;
/// A motion vector in 1/8 luma samples.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct Mv {
pub row: i32,
pub col: i32,
}
impl Mv {
pub const ZERO: Mv = Mv { row: 0, col: 0 };
pub fn new(row: i32, col: i32) -> Mv {
Mv { row, col }
}
pub fn minus(self, o: Mv) -> Mv {
Mv::new(self.row - o.row, self.col - o.col)
}
}
/// A reference plane: `w`×`h` visible samples at `stride`.
pub struct RefPlane<'a> {
pub data: &'a [u8],
pub stride: usize,
pub w: usize,
pub h: usize,
}
/// The block's motion vector for `plane` in 1/16 samples of that plane, clamped as the
/// decoder clamps it. `n8`: block size in 8×8 units.
pub fn clamp_for_plane(mv: Mv, r: usize, c: usize, n8: usize, mi_rows: usize, mi_cols: usize, chroma: bool) -> (i32, i32) {
let s = chroma as i32;
let (r, c, b) = (r as i32, c as i32, n8 as i32);
let to_top = (-(r * 8 * 16)) >> s;
let to_bottom = (((mi_rows as i32 - b - r) * 8) * 16) >> s;
let to_left = (-(c * 8 * 16)) >> s;
let to_right = (((mi_cols as i32 - b - c) * 8) * 16) >> s;
let spel = (4 + ((b * 8) >> s)) << 4;
let row = ((2 * mv.row) >> s).clamp(to_top - spel, (to_bottom + spel - 16).max(to_top - spel));
let col = ((2 * mv.col) >> s).clamp(to_left - spel, (to_right + spel - 16).max(to_left - spel));
(row, col)
}
/// Predict a `w`×`h` block whose top-left sample is at (`x16`, `y16`) (1/16 samples) into `out`
/// (stride `w`).
pub fn predict(r: &RefPlane, x16: i32, y16: i32, w: usize, h: usize, out: &mut [u8]) {
let (x0, y0) = (x16 >> 4, y16 >> 4);
let (fx, fy) = ((x16 & 15) as usize, (y16 & 15) as usize);
let (lx, ly) = (r.w as i32 - 1, r.h as i32 - 1);
let inside = x0 - 3 >= 0 && y0 - 3 >= 0 && x0 + w as i32 + 4 <= lx && y0 + h as i32 + 4 <= ly;
let at = |x: i32, y: i32| -> i32 {
if inside { r.data[y as usize * r.stride + x as usize] as i32 } else { r.data[y.clamp(0, ly) as usize * r.stride + x.clamp(0, lx) as usize] as i32 }
};
let fh = &SUBPEL_REGULAR[fx];
let fv = &SUBPEL_REGULAR[fy];
if fx == 0 && fy == 0 {
for i in 0..h {
for j in 0..w {
out[i * w + j] = at(x0 + j as i32, y0 + i as i32) as u8;
}
}
return;
}
// Horizontal pass over h + 7 rows (from y0 - 3), then vertical.
let mut tmp = [0u8; 23 * 16];
for i in 0..h + 7 {
let y = y0 - 3 + i as i32;
for j in 0..w {
let x = x0 + j as i32 - 3;
let mut acc = 64;
for t in 0..8 {
acc += fh[t] * at(x + t as i32, y);
}
tmp[i * w + j] = (acc >> 7).clamp(0, 255) as u8;
}
}
for i in 0..h {
for j in 0..w {
let mut acc = 64;
for t in 0..8 {
acc += fv[t] * tmp[(i + t) * w + j] as i32;
}
out[i * w + j] = (acc >> 7).clamp(0, 255) as u8;
}
}
}
/// The reference luma pre-interpolated at the 16 quarter-sample phases the encoder uses (motion
/// vectors are even in 1/8 units: fractions 0, 4, 8, 12 in 1/16), over the visible area plus a
/// border. Each phase plane holds exactly what [`predict`] returns at that phase (the decoder's
/// separable filter with edge clamping), so motion search reads predictions directly.
pub struct PhasePlanes {
border: usize,
stride: usize,
rows: usize,
planes: Vec<Vec<u8>>,
}
impl PhasePlanes {
pub fn new(r: &RefPlane, border: usize) -> PhasePlanes {
const M: usize = 4;
let (pw, ph) = (r.w + 2 * border, r.h + 2 * border);
// Edge-replicated source with M more samples on every side for the filter taps.
let rs = pw + 2 * M;
let rh = ph + 2 * M;
let off = (border + M) as i32;
let mut raw = vec![0u8; rs * rh];
for y in 0..rh {
let sy = (y as i32 - off).clamp(0, r.h as i32 - 1) as usize;
let src = &r.data[sy * r.stride..sy * r.stride + r.w];
for x in 0..rs {
raw[y * rs + x] = src[(x as i32 - off).clamp(0, r.w as i32 - 1) as usize];
}
}
// Horizontal phases over every raw row (inner columns).
let hpass = |fx: usize| -> Vec<u8> {
let f = &SUBPEL_REGULAR[fx];
let mut out = vec![0u8; pw * rh];
for y in 0..rh {
let row = &raw[y * rs..y * rs + rs];
for x in 0..pw {
let s = &row[x + M - 3..x + M + 5];
let acc: i32 = 64 + (0..8).map(|t| f[t] * s[t] as i32).sum::<i32>();
out[y * pw + x] = (acc >> 7).clamp(0, 255) as u8;
}
}
out
};
let unfiltered: Vec<u8> = (0..rh).flat_map(|y| raw[y * rs + M..y * rs + M + pw].iter().copied()).collect();
let h: Vec<Vec<u8>> = (0..4).map(|k| if k == 0 { unfiltered.clone() } else { hpass(k * 4) }).collect();
let mut planes = Vec::with_capacity(16);
for fy in 0..4 {
for hp in &h {
if fy == 0 {
planes.push(hp[M * pw..(M + ph) * pw].to_vec());
continue;
}
let f = &SUBPEL_REGULAR[fy * 4];
let mut out = vec![0u8; pw * ph];
for y in 0..ph {
for x in 0..pw {
let mut acc = 64;
for t in 0..8 {
acc += f[t] * hp[(y + M + t - 3) * pw + x] as i32;
}
out[y * pw + x] = (acc >> 7).clamp(0, 255) as u8;
}
}
planes.push(out);
}
}
PhasePlanes { border, stride: pw, rows: ph, planes }
}
/// The prediction of the `n`×`n` block at (`x16`, `y16`) (fractions multiples of 4) as rows of
/// a phase plane: (plane, offset of the first sample, stride). `None` beyond the border.
pub fn block(&self, x16: i32, y16: i32, n: usize) -> Option<(&[u8], usize, usize)> {
let (x0, y0) = ((x16 >> 4) + self.border as i32, (y16 >> 4) + self.border as i32);
let (fx, fy) = ((x16 & 15) as usize, (y16 & 15) as usize);
if fx % 4 != 0 || fy % 4 != 0 || x0 < 0 || y0 < 0 || x0 as usize + n > self.stride || y0 as usize + n > self.rows {
return None;
}
Some((&self.planes[(fy / 4) * 4 + fx / 4], y0 as usize * self.stride + x0 as usize, self.stride))
}
}
#[cfg(test)]
mod tests {
#[test]
fn phase_planes_match_predict() {
let (w, h) = (37usize, 29usize);
let data: Vec<u8> = (0..w * h).map(|i| ((i * 7919) % 251) as u8).collect();
let r = RefPlane { data: &data, stride: w, w, h };
let pp = PhasePlanes::new(&r, 24);
let mut out = [0u8; 64];
for y16 in (-20 * 16..(h as i32 + 10) * 16).step_by(4 * 7) {
for x16 in (-20 * 16..(w as i32 + 10) * 16).step_by(4 * 5) {
let Some((p, o, s)) = pp.block(x16, y16, 8) else { continue };
predict(&r, x16, y16, 8, 8, &mut out);
for i in 0..8 {
assert_eq!(&p[o + i * s..o + i * s + 8], &out[i * 8..i * 8 + 8], "({x16}, {y16}) row {i}");
}
}
}
}
use super::*;
#[test]
fn whole_sample_and_linear_ramps() {
let (w, h) = (40usize, 30usize);
let data: Vec<u8> = (0..w * h).map(|i| ((i % w) * 4 + (i / w)) as u8).collect();
let r = RefPlane { data: &data, stride: w, w, h };
let mut out = [0u8; 64];
predict(&r, 10 << 4, 8 << 4, 8, 8, &mut out);
assert_eq!(out[0], data[8 * w + 10]);
// Half a sample right on a linear ramp: exact midpoint.
predict(&r, (10 << 4) + 8, 8 << 4, 8, 8, &mut out);
assert_eq!(out[0], data[8 * w + 10] + 2);
// Off the left edge: clamped to column 0.
predict(&r, -(20 << 4), 0, 4, 4, &mut out);
assert_eq!(out[0], data[0]);
}
}
+426
View File
@@ -0,0 +1,426 @@
//! Probabilities: the frame's adaptable probability table (one flat array laid out in the order
//! the compressed header updates them), a symbol recorder that codes every boolean decision
//! against either a fixed probability or an entry of that table, bit-cost estimates, and the
//! per-frame forward updates (spec 6.3: `diff_update_prob` with `decode_term_subexp` /
//! `inv_remap_prob`, and `update_mv_prob`).
//!
//! Tiles are first recorded, then the encoder counts how often each table entry coded a 0 or a
//! 1, picks updates that pay for themselves, writes them in the compressed header and finally
//! entropy codes the recorded symbols with the updated probabilities.
use crate::bool::BoolEncoder;
use crate::tables::*;
/// Coefficient probabilities `[ptype][ref][band][ctx][3]` for 4×4 transforms (band 0 uses
/// contexts 0..3 only).
pub const COEF: usize = 0;
pub const SKIP: usize = 432;
pub const INTER_MODE: usize = SKIP + 3;
pub const IS_INTER: usize = INTER_MODE + 21;
pub const SINGLE_REF: usize = IS_INTER + 4;
pub const PARTITION: usize = SINGLE_REF + 10;
pub const MV_JOINT: usize = PARTITION + 48;
pub const MV_SIGN: usize = MV_JOINT + 3;
pub const MV_CLASS: usize = MV_SIGN + 2;
pub const MV_CLASS0_BIT: usize = MV_CLASS + 20;
pub const MV_BITS: usize = MV_CLASS0_BIT + 2;
pub const MV_CLASS0_FR: usize = MV_BITS + 20;
pub const MV_FR: usize = MV_CLASS0_FR + 12;
pub const NPROBS: usize = MV_FR + 6;
/// Index of the first of the three coefficient probabilities of a context.
pub fn coef_index(ptype: usize, inter: bool, band: usize, ctx: usize) -> usize {
COEF + (((ptype * 2 + inter as usize) * 6 + band) * 6 + ctx) * 3
}
#[derive(Clone)]
pub struct Probs(pub [u8; NPROBS]);
impl Probs {
/// The default probabilities (setup_past_independence / key frames).
pub fn defaults() -> Probs {
let mut p = [128u8; NPROBS];
p[COEF..COEF + 432].copy_from_slice(&DEFAULT_COEF_PROBS);
p[SKIP..SKIP + 3].copy_from_slice(&DEFAULT_SKIP_PROB);
p[INTER_MODE..INTER_MODE + 21].copy_from_slice(&DEFAULT_INTER_MODE_PROBS);
p[IS_INTER..IS_INTER + 4].copy_from_slice(&DEFAULT_IS_INTER_PROB);
p[SINGLE_REF..SINGLE_REF + 10].copy_from_slice(&DEFAULT_SINGLE_REF_PROB);
p[PARTITION..PARTITION + 48].copy_from_slice(&DEFAULT_PARTITION_PROBS);
p[MV_JOINT..MV_JOINT + 3].copy_from_slice(&DEFAULT_MV_JOINT_PROBS);
p[MV_SIGN..MV_SIGN + 2].copy_from_slice(&DEFAULT_MV_SIGN_PROB);
p[MV_CLASS..MV_CLASS + 20].copy_from_slice(&DEFAULT_MV_CLASS_PROBS);
p[MV_CLASS0_BIT..MV_CLASS0_BIT + 2].copy_from_slice(&DEFAULT_MV_CLASS0_BIT_PROB);
p[MV_BITS..MV_BITS + 20].copy_from_slice(&DEFAULT_MV_BITS_PROB);
p[MV_CLASS0_FR..MV_CLASS0_FR + 12].copy_from_slice(&DEFAULT_MV_CLASS0_FR_PROBS);
p[MV_FR..MV_FR + 6].copy_from_slice(&DEFAULT_MV_FR_PROBS);
Probs(p)
}
}
/// Probabilities of the full token tree (9.3.2) for model probability `p`.
pub fn pareto(p: u8) -> [u8; 8] {
let p = p.max(1) as usize;
let x = (p - 1) / 2;
let mut t = [0u8; 8];
for n in 0..8 {
t[n] = if p & 1 == 1 { PARETO_TABLE[x * 8 + n] } else { ((PARETO_TABLE[x * 8 + n] as u32 + PARETO_TABLE[(x + 1) * 8 + n] as u32) >> 1) as u8 };
}
t
}
/// The probability a recorded symbol is coded with.
#[derive(Clone, Copy, Debug)]
pub enum P {
Fixed(u8),
/// An entry of the frame's [`Probs`].
Ctx(u16),
/// Node `n` of the Pareto token tree derived from the coefficient probability at the index.
Pareto(u16, u8),
}
#[derive(Clone, Copy)]
struct Sym {
bit: bool,
p: P,
}
/// Records the boolean decisions of a tile.
#[derive(Default)]
pub struct Writer {
syms: Vec<Sym>,
}
impl Writer {
#[inline]
pub fn put(&mut self, bit: bool, p: P) {
self.syms.push(Sym { bit, p });
}
#[inline]
pub fn fixed(&mut self, bit: bool, prob: u8) {
self.put(bit, P::Fixed(prob));
}
#[inline]
pub fn ctx(&mut self, bit: bool, idx: usize) {
self.put(bit, P::Ctx(idx as u16));
}
/// Write `value` with `tree` (leaves stored negated); node `i` uses `prob(i)`.
pub fn tree(&mut self, tree: &[i8], value: u8, prob: impl Fn(usize) -> P) {
for (node, bit) in tree_path(tree, value) {
self.put(bit, prob(node));
}
}
/// Add the 0/1 counts of every table entry this tile used.
pub fn count(&self, counts: &mut [[u32; 2]]) {
for s in &self.syms {
if let P::Ctx(i) = s.p {
counts[i as usize][s.bit as usize] += 1;
}
}
}
/// Entropy code the recorded symbols.
pub fn emit(&self, probs: &Probs) -> Vec<u8> {
let mut bc = BoolEncoder::new();
bc.write(false, 128); // marker bit
for s in &self.syms {
let p = match s.p {
P::Fixed(p) => p,
P::Ctx(i) => probs.0[i as usize],
P::Pareto(i, n) => pareto(probs.0[i as usize])[n as usize],
};
bc.write(s.bit, p);
}
bc.finish()
}
}
/// The (node, bit) path to `value` in a tree (node = tree index / 2).
pub fn tree_path(tree: &[i8], value: u8) -> impl Iterator<Item = (usize, bool)> {
struct Path {
steps: [(u8, bool); 16],
len: usize,
}
fn walk(tree: &[i8], node: usize, value: u8, out: &mut Path) -> bool {
for b in 0..2 {
let t = tree[node + b];
out.steps[out.len] = ((node >> 1) as u8, b == 1);
out.len += 1;
if t <= 0 {
if (-t) as u8 == value {
return true;
}
} else if walk(tree, t as usize, value, out) {
return true;
}
out.len -= 1;
}
false
}
let mut p = Path { steps: [(0, false); 16], len: 0 };
walk(tree, 0, value, &mut p);
let Path { steps, len } = p;
steps.into_iter().take(len).map(|(n, b)| (n as usize, b))
}
// ---------------------------------------------------------------- costs
/// Cost in 1/256 bit of coding a 0 (`[p][0]`) or a 1 (`[p][1]`) with probability `p`.
pub fn cost_table() -> &'static [[u32; 2]; 256] {
use std::sync::OnceLock;
static T: OnceLock<[[u32; 2]; 256]> = OnceLock::new();
T.get_or_init(|| {
let mut t = [[0u32; 2]; 256];
for (p, e) in t.iter_mut().enumerate() {
let q = (p.max(1) as f64) / 256.0;
e[0] = (-q.log2() * 256.0).round() as u32;
e[1] = (-(1.0 - q).log2() * 256.0).round() as u32;
}
t
})
}
#[inline]
pub fn cost(bit: bool, p: u8) -> u32 {
cost_table()[p as usize][bit as usize]
}
/// Cost of `value` in a tree with probabilities `probs` (node i → probs[i]).
pub fn tree_cost(tree: &[i8], probs: &[u8], value: u8) -> u32 {
tree_path(tree, value).map(|(n, b)| cost(b, probs[n])).sum()
}
// ---------------------------------------------------------------- forward updates
fn inv_recenter_nonneg(v: i32, m: i32) -> i32 {
if v > 2 * m {
v
} else if v & 1 != 0 {
m - ((v + 1) >> 1)
} else {
m + (v >> 1)
}
}
/// `inv_remap_prob` (6.3.5).
fn inv_remap_prob(delta: usize, prob: u8) -> u8 {
let v = inv_map_table()[delta.min(254)] as i32;
let m = prob as i32 - 1;
let r = if (m << 1) <= 255 { 1 + inv_recenter_nonneg(v, m) } else { 255 - inv_recenter_nonneg(v, 255 - 1 - m) };
r.clamp(1, 255) as u8
}
/// The `decode_term_subexp` value that moves `old` to `new`.
fn delta_for(old: u8, new: u8) -> Option<usize> {
(0..255).find(|&d| inv_remap_prob(d, old) == new)
}
/// Bits of `decode_term_subexp` for `d`.
fn subexp_bits(d: usize) -> u32 {
match d {
0..16 => 5,
16..32 => 6,
32..64 => 8,
64..129 => 10,
_ => 11,
}
}
fn write_subexp(bc: &mut BoolEncoder, d: usize) {
let d = d as u32;
if d < 16 {
bc.literal(0, 1);
bc.literal(d, 4);
} else if d < 32 {
bc.literal(2, 2);
bc.literal(d - 16, 4);
} else if d < 64 {
bc.literal(6, 3);
bc.literal(d - 32, 5);
} else {
bc.literal(7, 3);
if d < 129 {
bc.literal(d - 64, 7);
} else {
let v = (d + 1) >> 1;
bc.literal(v, 7);
bc.literal((d + 1) & 1, 1);
}
}
}
fn counts_cost(c: [u32; 2], p: u8) -> u64 {
c[0] as u64 * cost(false, p) as u64 + c[1] as u64 * cost(true, p) as u64
}
/// Whether table entry `i` is an MV probability (7-bit `update_mv_prob` rather than
/// `diff_update_prob`).
fn is_mv(i: usize) -> bool {
i >= MV_JOINT
}
/// Choose updated probabilities: an entry changes when the bits saved on this frame's symbols
/// exceed the cost of signalling the change. `inter`: the inter-frame tables are updatable.
pub fn plan_updates(old: &Probs, counts: &[[u32; 2]], inter: bool) -> Probs {
let mut new = old.clone();
let flag = cost(true, 252) as i64 - cost(false, 252) as i64;
let limit = if inter { NPROBS } else { INTER_MODE };
for i in 0..limit {
let c = counts[i];
let n = c[0] + c[1];
if n == 0 {
continue;
}
let o = old.0[i];
let ideal = ((c[0] as f64 * 256.0 / n as f64).round() as i32).clamp(1, 255) as u8;
let cands: Vec<u8> = if is_mv(i) { vec![ideal | 1, ideal.saturating_sub(1).max(1) | 1] } else { vec![ideal] };
let base = counts_cost(c, o) as i64;
let mut best = (0i64, o);
for p in cands {
if p == o {
continue;
}
let signal = if is_mv(i) {
7 * 256
} else {
match delta_for(o, p) {
Some(d) => subexp_bits(d) as i64 * 256,
None => continue,
}
};
let gain = base - counts_cost(c, p) as i64 - signal - flag;
if gain > best.0 {
best = (gain, p);
}
}
new.0[i] = best.1;
}
new
}
/// `diff_update_prob` for one entry.
fn diff_update(bc: &mut BoolEncoder, old: &Probs, new: &Probs, i: usize) {
let (o, n) = (old.0[i], new.0[i]);
match (o != n).then(|| delta_for(o, n)).flatten() {
Some(d) => {
bc.write(true, 252);
write_subexp(bc, d);
}
None => bc.write(false, 252),
}
}
/// `update_mv_prob` for one entry.
fn mv_update(bc: &mut BoolEncoder, old: &Probs, new: &Probs, i: usize) {
let n = new.0[i];
if n != old.0[i] && n & 1 == 1 {
bc.write(true, 252);
bc.literal(n as u32 >> 1, 7);
} else {
bc.write(false, 252);
}
}
/// The compressed header (6.3) of a frame coded with ONLY_4X4 transforms, single LAST
/// references and a fixed interpolation filter, moving the probabilities from `old` to `new`.
/// Entries `new` cannot signal are left at `old` (and must be coded with `old`): the caller
/// codes the tiles with the probabilities this returns.
pub fn compressed_header(old: &Probs, new: &Probs, lossless: bool, inter: bool) -> (Vec<u8>, Probs) {
let mut bc = BoolEncoder::new();
bc.write(false, 128);
if !lossless {
bc.literal(0, 2); // tx_mode = ONLY_4X4
}
// read_coef_probs: TX_4X4 only.
let coef_changed = (COEF..COEF + 432).any(|i| new.0[i] != old.0[i]);
bc.literal(coef_changed as u32, 1);
if coef_changed {
for i in 0..2 {
for j in 0..2 {
for k in 0..6 {
for l in 0..if k == 0 { 3 } else { 6 } {
for m in 0..3 {
diff_update(&mut bc, old, new, coef_index(i, j == 1, k, l) + m);
}
}
}
}
}
}
for i in 0..3 {
diff_update(&mut bc, old, new, SKIP + i);
}
if inter {
for i in 0..21 {
diff_update(&mut bc, old, new, INTER_MODE + i);
}
for i in 0..4 {
diff_update(&mut bc, old, new, IS_INTER + i);
}
// reference_mode is SINGLE_REFERENCE (no compound: equal sign biases).
for i in 0..10 {
diff_update(&mut bc, old, new, SINGLE_REF + i);
}
for _ in 0..36 {
bc.write(false, 252); // y mode probabilities (no intra blocks in inter frames)
}
for i in 0..48 {
diff_update(&mut bc, old, new, PARTITION + i);
}
for j in 0..3 {
mv_update(&mut bc, old, new, MV_JOINT + j);
}
for i in 0..2 {
mv_update(&mut bc, old, new, MV_SIGN + i);
for j in 0..10 {
mv_update(&mut bc, old, new, MV_CLASS + i * 10 + j);
}
mv_update(&mut bc, old, new, MV_CLASS0_BIT + i);
for j in 0..10 {
mv_update(&mut bc, old, new, MV_BITS + i * 10 + j);
}
}
for i in 0..2 {
for j in 0..6 {
mv_update(&mut bc, old, new, MV_CLASS0_FR + i * 6 + j);
}
for j in 0..3 {
mv_update(&mut bc, old, new, MV_FR + i * 3 + j);
}
}
}
// What the decoder ends up with.
let mut used = old.clone();
for i in 0..NPROBS {
let ok = if is_mv(i) { new.0[i] & 1 == 1 } else { delta_for(old.0[i], new.0[i]).is_some() };
if ok && (inter || i < INTER_MODE) {
used.0[i] = new.0[i];
}
}
(bc.finish(), used)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn remap_reaches_every_probability() {
for old in 1..=255u8 {
for new in 1..=255u8 {
if new != old {
assert!(delta_for(old, new).is_some(), "{old} -> {new}");
}
}
}
}
#[test]
fn inv_map_table_shape() {
let t = inv_map_table();
assert_eq!(&t[..4], &[7, 20, 33, 46]);
assert_eq!(t[19], 254);
assert_eq!(&t[20..27], &[1, 2, 3, 4, 5, 6, 8]);
assert_eq!(&t[252..], &[252, 253, 253]);
}
}
+73
View File
@@ -128,3 +128,76 @@ pub const DEFAULT_COEF_PROBS: [u8; 432] = [
79, 1, 20, 39, 99, 167, 239, 47, 141, 224, 10, 104, 178, 2, 73, 133, 1, 44, 85, 1, 22, 47, 127, 145, 243, 71, 129, 228, 17, 93, 177, 3, 61, 124, 1, 41, 84,
1, 21, 52, 157, 78, 244, 140, 72, 231, 69, 58, 184, 31, 44, 137, 14, 38, 105, 8, 23, 61,
];
// ---------------------------------------------------------------- inter frames (spec section 10.5)
/// `inv_map_table` (used by `inv_remap_prob`): the 20 values 7 + 13k first, then every other value
/// 1..=253 in increasing order, then 253 again.
pub fn inv_map_table() -> &'static [u8; 255] {
use std::sync::OnceLock;
static T: OnceLock<[u8; 255]> = OnceLock::new();
T.get_or_init(|| {
let mut t = [253u8; 255];
let mut n = 0;
for k in 0..20 {
t[n] = 7 + 13 * k;
n += 1;
}
for v in 1..=253u8 {
if v < 7 || (v - 7) % 13 != 0 {
t[n] = v;
n += 1;
}
}
t
})
}
/// The regular 8-tap sub-sample interpolation filters (`subpel_filters[EIGHTTAP]`), 16 positions.
pub const SUBPEL_REGULAR: [[i32; 8]; 16] = [
[0, 0, 0, 128, 0, 0, 0, 0],
[0, 1, -5, 126, 8, -3, 1, 0],
[-1, 3, -10, 122, 18, -6, 2, 0],
[-1, 4, -13, 118, 27, -9, 3, -1],
[-1, 4, -16, 112, 37, -11, 4, -1],
[-1, 5, -18, 105, 48, -14, 4, -1],
[-1, 5, -19, 97, 58, -16, 5, -1],
[-1, 6, -19, 88, 68, -18, 5, -1],
[-1, 6, -19, 78, 78, -19, 6, -1],
[-1, 5, -18, 68, 88, -19, 6, -1],
[-1, 5, -16, 58, 97, -19, 5, -1],
[-1, 4, -14, 48, 105, -18, 5, -1],
[-1, 4, -11, 37, 112, -16, 4, -1],
[-1, 3, -9, 27, 118, -13, 4, -1],
[0, 2, -6, 18, 122, -10, 3, -1],
[0, 1, -3, 8, 126, -5, 1, 0],
];
/// `mv_ref_blocks` (row, column offsets) for BLOCK_8X8 and BLOCK_16X16.
pub const MV_REF_8X8: [(i32, i32); 8] = [(-1, 0), (0, -1), (-1, -1), (-2, 0), (0, -2), (-2, -1), (-1, -2), (-2, -2)];
pub const MV_REF_16X16: [(i32, i32); 8] = [(-1, 0), (0, -1), (-1, 1), (1, -1), (-1, -1), (-3, 0), (0, -3), (-3, -3)];
/// `mode_2_counter` indexed by y mode (intra modes 0..=9, then NEARESTMV, NEARMV, ZEROMV, NEWMV).
pub const MODE_2_COUNTER: [u8; 14] = [9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 0, 0, 3, 1];
/// `counter_to_context`; INVALID_CASE entries are 9.
pub const COUNTER_TO_CONTEXT: [u8; 19] = [2, 3, 4, 1, 3, 9, 0, 9, 9, 5, 5, 9, 5, 9, 9, 9, 9, 9, 6];
pub const DEFAULT_PARTITION_PROBS: [u8; 48] = [
199, 122, 141, 147, 63, 159, 148, 133, 118, 121, 104, 114, 174, 73, 87, 92, 41, 83, 82, 99, 50, 53, 39, 39, 177, 58, 59, 68, 26, 63, 52, 79, 25, 17, 14,
12, 222, 34, 30, 72, 16, 44, 58, 32, 12, 10, 7, 6,
];
pub const DEFAULT_IS_INTER_PROB: [u8; 4] = [9, 102, 187, 225];
pub const DEFAULT_SINGLE_REF_PROB: [u8; 10] = [33, 16, 77, 74, 142, 142, 172, 170, 238, 247];
pub const DEFAULT_INTER_MODE_PROBS: [u8; 21] = [2, 173, 34, 7, 145, 85, 7, 166, 63, 7, 94, 66, 8, 64, 46, 17, 81, 31, 25, 29, 30];
pub const DEFAULT_MV_JOINT_PROBS: [u8; 3] = [32, 64, 96];
pub const DEFAULT_MV_SIGN_PROB: [u8; 2] = [128, 128];
pub const DEFAULT_MV_CLASS_PROBS: [u8; 20] = [224, 144, 192, 168, 192, 176, 192, 198, 198, 245, 216, 128, 176, 160, 176, 176, 192, 198, 198, 208];
pub const DEFAULT_MV_CLASS0_BIT_PROB: [u8; 2] = [216, 208];
pub const DEFAULT_MV_BITS_PROB: [u8; 20] = [136, 140, 148, 160, 176, 192, 224, 234, 234, 240, 136, 140, 148, 160, 176, 192, 224, 234, 234, 240];
pub const DEFAULT_MV_CLASS0_FR_PROBS: [u8; 12] = [128, 128, 64, 96, 112, 64, 128, 128, 64, 96, 112, 64];
pub const DEFAULT_MV_FR_PROBS: [u8; 6] = [64, 96, 64, 64, 96, 64];
+894
View File
@@ -0,0 +1,894 @@
//! Tile coding: partitions, intra blocks (key frames) and inter blocks (motion search, mode
//! decision, motion vector prediction, residual), recorded as symbols (see [`crate::probs`]).
//!
//! Mode info, motion vector candidate lists and contexts follow the decoding process of the
//! VP9 specification (6.4, 6.4.x `find_mv_refs` / `find_best_ref_mvs`) for the subset of the
//! syntax this encoder emits: 8×8 and 16×16 blocks, single LAST references, no
//! `UsePrevFrameMvs` (inter frames are error resilient), no high-precision vectors.
use crate::mc::{Mv, PhasePlanes, RefPlane, clamp_for_plane, predict};
use crate::probs::{self, P, Writer, cost, tree_cost};
use crate::tables::*;
use crate::transform::{ADST_ADST, ADST_DCT, DCT_ADST, DCT_DCT, Quant};
use crate::{BLOCK_8X8, BLOCK_16X16, Mi, Plane};
pub const DC_PRED: u8 = 0;
pub const V_PRED: u8 = 1;
pub const H_PRED: u8 = 2;
pub const TM_PRED: u8 = 9;
const MODES: [u8; 4] = [DC_PRED, V_PRED, H_PRED, TM_PRED];
pub const NEARESTMV: u8 = 10;
pub const NEARMV: u8 = 11;
pub const ZEROMV: u8 = 12;
pub const NEWMV: u8 = 13;
const PARTITION_TREE: [i8; 6] = [0, 2, -1, 4, -2, -3];
const INTRA_MODE_TREE: [i8; 18] = [0, 2, -9, 4, -1, 6, 8, 12, -2, 10, -4, -5, -3, 14, -8, 16, -6, -7];
/// Leaves are `mode - NEARESTMV`.
const INTER_MODE_TREE: [i8; 6] = [-2, 2, 0, 4, -1, -3];
const MV_JOINT_TREE: [i8; 6] = [0, 2, -1, 4, -2, -3];
const MV_CLASS_TREE: [i8; 20] = [0, 2, -1, 4, 6, 8, -2, -3, 10, 12, -4, -5, -6, 14, 16, 18, -7, -8, -9, -10];
const MV_FR_TREE: [i8; 6] = [0, 2, -1, 4, -2, -3];
/// extra_bits[token] = (cat, numExtra, base).
const EXTRA_BITS: [(u8, u8, u16); 11] =
[(0, 0, 0), (0, 0, 1), (0, 0, 2), (0, 0, 3), (0, 0, 4), (1, 1, 5), (2, 2, 7), (3, 3, 11), (4, 4, 19), (5, 5, 35), (6, 14, 67)];
const CAT_PROBS: [&[u8]; 7] = [
&[],
&[159],
&[165, 145],
&[173, 148, 140],
&[176, 155, 140, 135],
&[180, 157, 141, 134, 130],
&[254, 254, 254, 252, 249, 243, 230, 196, 177, 153, 140, 133, 130, 129],
];
/// mode2txfm_map for the intra modes used here (inter blocks use DCT_DCT).
fn mode_tx_type(mode: u8) -> u8 {
match mode {
V_PRED => ADST_DCT,
H_PRED => DCT_ADST,
TM_PRED => ADST_ADST,
_ => DCT_DCT,
}
}
fn scan(tx_type: u8) -> &'static [u16; 16] {
match tx_type {
ADST_DCT => &ROW_SCAN_4X4,
DCT_ADST => &COL_SCAN_4X4,
_ => &DEFAULT_SCAN_4X4,
}
}
/// Coefficient context neighbours (9.3.2) of each scan position.
fn neighbors(tx_type: u8) -> [(usize, usize); 16] {
let sc = scan(tx_type);
let mut nb = [(0, 0); 16];
for (c, &pos) in sc.iter().enumerate().skip(1) {
let pos = pos as usize;
let (i, j) = (pos / 4, pos % 4);
nb[c] = if i > 0 && j > 0 {
let a = (i - 1) * 4 + j;
let b = i * 4 + j - 1;
match tx_type {
DCT_ADST => (a, a),
ADST_DCT => (b, b),
_ => (a, b),
}
} else if i > 0 {
((i - 1) * 4 + j, (i - 1) * 4 + j)
} else {
(j - 1, j - 1)
};
}
nb
}
fn eob_of(levels: &[i32; 16], tx_type: u8) -> usize {
let sc = scan(tx_type);
(0..16).rev().find(|&i| levels[sc[i] as usize] != 0).map_or(0, |i| i + 1)
}
/// Rough coefficient bit cost (1/256 bit) for rate-distortion decisions.
fn levels_cost(levels: &[i32; 16], eob: usize) -> u32 {
if eob == 0 {
return 0;
}
let mut bits = 256;
for &l in levels {
let a = l.unsigned_abs();
bits += if a == 0 { 160 } else { 640 + 384 * (32 - a.leading_zeros()) };
}
bits
}
/// Intra prediction (8.5.1) of a square block for DC / V / H / TM.
fn predict_intra(p: &Plane, x: usize, y: usize, log2: usize, mode: u8, have_left: bool, have_above: bool) -> [i32; 64] {
let size = 1usize << log2;
let base = 128i32;
let buf = &p.rec;
let w = p.w;
let mut above = [0i32; 8];
let mut left = [0i32; 8];
if have_above {
for i in 0..size {
above[i] = buf[(y - 1) * w + (x + i).min(p.max_x)] as i32;
}
} else {
above[..size].fill(base - 1);
}
let top_left = if have_above && have_left {
buf[(y - 1) * w + (x - 1).min(p.max_x)] as i32
} else if have_above {
base + 1
} else {
base - 1
};
if have_left {
for i in 0..size {
left[i] = buf[(y + i).min(p.max_y) * w + x - 1] as i32;
}
} else {
left[..size].fill(base + 1);
}
let mut out = [0i32; 64];
for i in 0..size {
for j in 0..size {
out[i * size + j] = match mode {
V_PRED => above[j],
H_PRED => left[i],
TM_PRED => (above[j] + left[i] - top_left).clamp(0, 255),
_ => 0,
};
}
}
if mode == DC_PRED {
let v = match (have_left, have_above) {
(true, true) => (left[..size].iter().sum::<i32>() + above[..size].iter().sum::<i32>() + size as i32) >> (log2 + 1),
(true, false) => (left[..size].iter().sum::<i32>() + (1 << (log2 - 1))) >> log2,
(false, true) => (above[..size].iter().sum::<i32>() + (1 << (log2 - 1))) >> log2,
(false, false) => base,
};
out[..size * size].fill(v);
}
out
}
// ---------------------------------------------------------------- motion vector coding
/// `mv_class` of an offset (|component| - 1).
fn mv_class(offset: u32) -> u32 {
if offset < 16 { 0 } else { (31 - offset.leading_zeros()) - 3 }
}
fn mv_component_cost(i: usize, v: i32) -> u32 {
let d = &probs_defaults().0;
let offset = v.unsigned_abs() - 1;
let c = mv_class(offset);
let mut bits = cost(v < 0, d[probs::MV_SIGN + i]);
bits += tree_cost(&MV_CLASS_TREE, &d[probs::MV_CLASS + i * 10..], c as u8);
let fr = ((offset >> 1) & 3) as u8;
if c == 0 {
let b = offset >> 3;
bits += cost(b == 1, d[probs::MV_CLASS0_BIT + i]);
bits += tree_cost(&MV_FR_TREE, &d[probs::MV_CLASS0_FR + i * 6 + b as usize * 3..], fr);
} else {
let dd = (offset - (1 << (c + 3))) >> 3;
for b in 0..c {
bits += cost((dd >> b) & 1 == 1, d[probs::MV_BITS + i * 10 + b as usize]);
}
bits += tree_cost(&MV_FR_TREE, &d[probs::MV_FR + i * 3..], fr);
}
bits
}
const MV_TABLE: i32 = 8192;
/// Component costs for |v| ≤ MV_TABLE (index v + MV_TABLE) and the four joint costs.
fn mv_tables() -> &'static ([Vec<u32>; 2], [u32; 4]) {
use std::sync::OnceLock;
static T: OnceLock<([Vec<u32>; 2], [u32; 4])> = OnceLock::new();
T.get_or_init(|| {
let comp = std::array::from_fn(|i| (-MV_TABLE..=MV_TABLE).map(|v| if v == 0 { 0 } else { mv_component_cost(i, v) }).collect());
let d = &probs_defaults().0;
let joints = std::array::from_fn(|j| tree_cost(&MV_JOINT_TREE, &d[probs::MV_JOINT..], j as u8));
(comp, joints)
})
}
/// Bits (1/256) of a NEWMV difference with the default probabilities.
pub fn mv_cost(diff: Mv) -> u32 {
let (comp, joints) = mv_tables();
let joint = (diff.row != 0) as usize * 2 + (diff.col != 0) as usize;
let c = |i: usize, v: i32| if v.abs() <= MV_TABLE { comp[i][(v + MV_TABLE) as usize] } else { mv_component_cost(i, v) };
joints[joint] + if diff.row != 0 { c(0, diff.row) } else { 0 } + if diff.col != 0 { c(1, diff.col) } else { 0 }
}
fn probs_defaults() -> &'static probs::Probs {
use std::sync::OnceLock;
static D: OnceLock<probs::Probs> = OnceLock::new();
D.get_or_init(probs::Probs::defaults)
}
// ---------------------------------------------------------------- the tile
/// Per-frame coding parameters shared by the tiles.
pub struct FrameParams<'a> {
pub key: bool,
pub quant: Quant,
pub mi_cols: usize,
pub mi_rows: usize,
/// Reference planes (inter frames).
pub refs: Option<[RefPlane<'a>; 3]>,
/// The reference luma at every quarter-sample phase (motion search).
pub phases: Option<PhasePlanes>,
/// Encoder-side motion hints from the previous frame (by MI position).
pub prev_mvs: Option<&'a [Mv]>,
/// Lagrangian multipliers: per bit, in SAD and in squared-error units.
pub lambda_sad: f64,
pub lambda_sse: f64,
}
pub struct Tile<'a> {
pub f: &'a FrameParams<'a>,
pub w: Writer,
pub planes: &'a mut [Plane; 3],
pub mi: &'a mut [Mi],
pub col_start: usize,
pub col_end: usize,
above_nz: [Vec<u8>; 3],
left_nz: [Vec<u8>; 3],
above_part: Vec<u8>,
left_part: Vec<u8>,
/// Motion vectors of the four 8×8 blocks evaluated for the current 16×16 area.
pending: [(usize, usize, Mv); 4],
}
impl<'a> Tile<'a> {
pub fn new(f: &'a FrameParams<'a>, planes: &'a mut [Plane; 3], mi: &'a mut [Mi], col_start: usize, col_end: usize) -> Tile<'a> {
let n = f.mi_cols * 2 + 16;
Tile {
f,
w: Writer::default(),
planes,
mi,
col_start,
col_end,
above_nz: [vec![0; n], vec![0; n], vec![0; n]],
left_nz: [vec![0; 16], vec![0; 16], vec![0; 16]],
above_part: vec![0; f.mi_cols + 8],
left_part: vec![0; 8],
pending: [(usize::MAX, 0, Mv::ZERO); 4],
}
}
/// Code the tile's superblocks.
pub fn code(&mut self) {
for r in (0..self.f.mi_rows).step_by(8) {
for p in 0..3 {
self.left_nz[p].fill(0);
}
self.left_part.fill(0);
for c in (self.col_start..self.col_end).step_by(8) {
self.partition(r, c, 12);
}
}
}
fn mi_at(&self, r: usize, c: usize) -> Mi {
self.mi[r * self.f.mi_cols + c]
}
fn set_mi(&mut self, r: usize, c: usize, n8: usize, m: Mi) {
for dr in 0..n8 {
for dc in 0..n8 {
if r + dr < self.f.mi_rows && c + dc < self.f.mi_cols {
self.mi[(r + dr) * self.f.mi_cols + c + dc] = m;
}
}
}
}
fn partition(&mut self, r: usize, c: usize, bsize: u8) {
let (mi_rows, mi_cols) = (self.f.mi_rows, self.f.mi_cols);
if r >= mi_rows || c >= mi_cols {
return;
}
let num8 = [1usize, 1, 1, 1, 1, 2, 2, 2, 4, 4, 4, 8, 8][bsize as usize];
let half = num8 >> 1;
let has_rows = (r + half) < mi_rows;
let has_cols = (c + half) < mi_cols;
let bsl = [0usize, 0, 0, 0, 0, 1, 1, 1, 2, 2, 2, 3, 3][bsize as usize];
let boffset = 3 - bsl;
let mut above = 0u8;
let mut left = 0u8;
for i in 0..num8 {
above |= self.above_part[c + i];
left |= self.left_part[(r & 7) + i];
}
let ctx = bsl * 4 + (((left >> boffset) & 1) as usize) * 2 + ((above >> boffset) & 1) as usize;
let key = self.f.key;
let prob = |i: usize| if key { P::Fixed(KF_PARTITION_PROBS[ctx * 3 + i]) } else { P::Ctx((probs::PARTITION + ctx * 3 + i) as u16) };
if bsize == BLOCK_8X8 {
self.w.put(false, prob(0)); // PARTITION_NONE
if key {
self.intra_block(r, c);
} else {
let mv = self.pending.iter().find(|p| p.0 == r && p.1 == c).map(|p| p.2);
let mv = mv.unwrap_or_else(|| self.search(r, c, BLOCK_8X8).0);
self.inter_block(r, c, BLOCK_8X8, mv);
}
self.above_part[c] = 15 >> 1;
self.left_part[r & 7] = 15 >> 1;
return;
}
if !key && bsize == BLOCK_16X16 && has_rows && has_cols {
if let Some(mv) = self.decide16(r, c) {
self.w.put(false, prob(0)); // PARTITION_NONE
self.inter_block(r, c, BLOCK_16X16, mv);
for i in 0..2 {
self.above_part[c + i] = 15 >> 2;
self.left_part[(r & 7) + i] = 15 >> 2;
}
return;
}
} else if !key && bsize == BLOCK_16X16 {
self.pending = [(usize::MAX, 0, Mv::ZERO); 4];
}
// PARTITION_SPLIT.
if has_rows && has_cols {
self.w.tree(&PARTITION_TREE, 3, prob);
} else if has_cols {
self.w.put(true, prob(1));
} else if has_rows {
self.w.put(true, prob(2));
}
let sub = bsize - 3;
self.partition(r, c, sub);
self.partition(r, c + half, sub);
self.partition(r + half, c, sub);
self.partition(r + half, c + half, sub);
}
// ------------------------------------------------------------ intra (key frames)
fn intra_block(&mut self, r: usize, c: usize) {
let avail_u = r > 0;
let avail_l = c > self.col_start;
let y_mode = self.choose_intra(0, c * 8, r * 8, 3, avail_l, avail_u);
let uv_mode = {
let mut best = (i64::MAX, DC_PRED);
for m in MODES {
let cost: i64 = (1..3).map(|p| self.intra_cost(p, c * 4, r * 4, 2, m, avail_l, avail_u)).sum();
if cost < best.0 {
best = (cost, m);
}
}
best.1
};
// skip = 0 (neighbours never skip in key frames: context 0); tx_size implied (4×4).
self.w.ctx(false, probs::SKIP);
let above_mode = if avail_u { self.mi_at(r - 1, c).mode } else { DC_PRED };
let left_mode = if avail_l { self.mi_at(r, c - 1).mode } else { DC_PRED };
let o = (above_mode as usize * 10 + left_mode as usize) * 9;
self.w.tree(&INTRA_MODE_TREE, y_mode, |i| P::Fixed(KF_Y_MODE_PROBS[o + i]));
let o = y_mode as usize * 9;
self.w.tree(&INTRA_MODE_TREE, uv_mode, |i| P::Fixed(KF_UV_MODE_PROBS[o + i]));
self.set_mi(r, c, 1, Mi { inter: false, mode: y_mode, mv: Mv::ZERO, skip: false, size: BLOCK_8X8 });
// Residual: luma 2×2 transform blocks, then one 4×4 block per chroma plane.
for (dy, dx) in [(0, 0), (0, 1), (1, 0), (1, 1)] {
let tx = if self.f.quant.lossless { DCT_DCT } else { mode_tx_type(y_mode) };
self.intra_tx(0, c * 8 + dx * 4, r * 8 + dy * 4, y_mode, tx, avail_l || dx > 0, avail_u || dy > 0);
}
for p in 1..3 {
self.intra_tx(p, c * 4, r * 4, uv_mode, DCT_DCT, avail_l, avail_u);
}
}
#[allow(clippy::too_many_arguments)]
fn intra_cost(&self, plane: usize, x: usize, y: usize, log2: usize, mode: u8, have_left: bool, have_above: bool) -> i64 {
let p = &self.planes[plane];
let pred = predict_intra(p, x, y, log2, mode, have_left, have_above);
let size = 1 << log2;
let mut s = 0i64;
for i in 0..size {
for j in 0..size {
s += (p.src[(y + i) * p.w + x + j] as i32 - pred[i * size + j]).abs() as i64;
}
}
s
}
fn choose_intra(&self, plane: usize, x: usize, y: usize, log2: usize, have_left: bool, have_above: bool) -> u8 {
let mut best = (i64::MAX, DC_PRED);
for m in MODES {
let cost = self.intra_cost(plane, x, y, log2, m, have_left, have_above);
if cost < best.0 {
best = (cost, m);
}
}
best.1
}
#[allow(clippy::too_many_arguments)]
fn intra_tx(&mut self, plane: usize, x: usize, y: usize, mode: u8, tx_type: u8, have_left: bool, have_above: bool) {
let ss = (plane > 0) as usize;
let max_x = (self.f.mi_cols * 8) >> ss;
let max_y = (self.f.mi_rows * 8) >> ss;
let (x4, y4) = (x >> 2, y >> 2);
let ly = y4 & (15 >> ss);
if x >= max_x || y >= max_y {
self.above_nz[plane][x4] = 0;
self.left_nz[plane][ly] = 0;
return;
}
let p = &self.planes[plane];
let pred = predict_intra(p, x, y, 2, mode, have_left, have_above);
let mut res = [0i32; 16];
for i in 0..4 {
for j in 0..4 {
res[i * 4 + j] = p.src[(y + i) * p.w + x + j] as i32 - pred[i * 4 + j];
}
}
let q = Quant { round_dc: 0.5, round_ac: 0.38, ..self.f.quant };
let levels = q.levels(&res, tx_type);
let eob = eob_of(&levels, tx_type);
let ctx0 = (self.above_nz[plane][x4] + self.left_nz[plane][ly]) as usize;
self.tokens(plane, false, tx_type, &levels, eob, ctx0);
let resid = q.residual(&levels, tx_type, eob);
let p = &mut self.planes[plane];
for i in 0..4 {
for j in 0..4 {
p.rec[(y + i) * p.w + x + j] = (pred[i * 4 + j] + resid[i * 4 + j]).clamp(0, 255) as u8;
}
}
let nz = (eob > 0) as u8;
self.above_nz[plane][x4] = nz;
self.left_nz[plane][ly] = nz;
}
fn tokens(&mut self, plane: usize, inter: bool, tx_type: u8, levels: &[i32; 16], eob: usize, ctx0: usize) {
let sc = scan(tx_type);
let nb = neighbors(tx_type);
let ptype = (plane > 0) as usize;
let mut tc = [0u8; 16];
let mut check_eob = true;
let mut c = 0;
while c < 16 {
let pos = sc[c] as usize;
let band = COEFBAND_4X4[c] as usize;
let ctx = if c == 0 { ctx0 } else { ((1 + tc[nb[c].0] + tc[nb[c].1]) >> 1) as usize };
let o = probs::coef_index(ptype, inter, band, ctx);
if check_eob {
let more = c < eob;
self.w.ctx(more, o);
if !more {
break;
}
}
let v = levels[pos].unsigned_abs();
if v == 0 {
self.w.ctx(false, o + 1);
tc[pos] = 0;
check_eob = false;
c += 1;
continue;
}
self.w.ctx(true, o + 1);
check_eob = true;
let token = match v {
1 => 1,
2 => 2,
3 => 3,
4 => 4,
5..=6 => 5,
7..=10 => 6,
11..=18 => 7,
19..=34 => 8,
35..=66 => 9,
_ => 10,
};
if token == 1 {
self.w.ctx(false, o + 2);
} else {
self.w.ctx(true, o + 2);
let path: &[(u8, bool)] = match token {
2 => &[(0, false), (1, false)],
3 => &[(0, false), (1, true), (2, false)],
4 => &[(0, false), (1, true), (2, true)],
5 => &[(0, true), (3, false), (4, false)],
6 => &[(0, true), (3, false), (4, true)],
7 => &[(0, true), (3, true), (5, false), (6, false)],
8 => &[(0, true), (3, true), (5, false), (6, true)],
9 => &[(0, true), (3, true), (5, true), (7, false)],
_ => &[(0, true), (3, true), (5, true), (7, true)],
};
for &(n, b) in path {
self.w.put(b, P::Pareto((o + 2) as u16, n));
}
let (cat, num_extra, base_v) = EXTRA_BITS[token];
let extra = (v - base_v as u32).min((1 << num_extra) - 1);
let cp = CAT_PROBS[cat as usize];
for e in 0..num_extra as usize {
self.w.fixed((extra >> (num_extra as usize - 1 - e)) & 1 != 0, cp[e]);
}
}
tc[pos] = ENERGY_CLASS[token];
self.w.fixed(levels[pos] < 0, 128);
c += 1;
}
}
// ------------------------------------------------------------ inter
fn is_inside(&self, r: i32, c: i32) -> bool {
r >= 0 && (r as usize) < self.f.mi_rows && c >= self.col_start as i32 && (c as usize) < self.col_end
}
/// `find_mv_refs` + `find_best_ref_mvs` for LAST_FRAME: (NearestMv, NearMv, mode context).
fn mv_refs(&self, r: usize, c: usize, bsize: u8) -> (Mv, Mv, usize) {
let search = if bsize == BLOCK_16X16 { &MV_REF_16X16 } else { &MV_REF_8X8 };
let mut list = [Mv::ZERO; 2];
let mut count = 0;
let add = |mv: Mv, list: &mut [Mv; 2], count: &mut usize| {
if *count >= 2 || (*count > 0 && mv == list[0]) {
return;
}
list[*count] = mv;
*count += 1;
};
let mut counter = 0usize;
for (i, &(dr, dc)) in search.iter().enumerate() {
let (cr, cc) = (r as i32 + dr, c as i32 + dc);
if !self.is_inside(cr, cc) {
continue;
}
let m = self.mi_at(cr as usize, cc as usize);
if i < 2 {
counter += MODE_2_COUNTER[m.mode as usize] as usize;
}
if m.inter {
add(m.mv, &mut list, &mut count);
}
}
let ctx = COUNTER_TO_CONTEXT[counter.min(18)] as usize;
let n8 = if bsize == BLOCK_16X16 { 2 } else { 1 };
let clamp = |mv: Mv, border: i32| -> Mv {
let (r, c, b) = (r as i32, c as i32, n8);
let to_top = -(r * 64);
let to_bottom = (self.f.mi_rows as i32 - b - r) * 64;
let to_left = -(c * 64);
let to_right = (self.f.mi_cols as i32 - b - c) * 64;
Mv::new(
mv.row.clamp(to_top - border, (to_bottom + border).max(to_top - border)),
mv.col.clamp(to_left - border, (to_right + border).max(to_left - border)),
)
};
for mv in list.iter_mut() {
*mv = clamp(*mv, 128);
// No high precision: odd components move toward zero.
let even = |v: i32| if v & 1 != 0 { v + if v > 0 { -1 } else { 1 } } else { v };
*mv = clamp(Mv::new(even(mv.row), even(mv.col)), (160 - 4) << 3);
}
(list[0], list[1], ctx)
}
/// The allowed motion vector range of a block (no decoder clamping inside it).
fn mv_range(&self, r: usize, c: usize, n: i32) -> (i32, i32, i32, i32) {
let n8 = n / 8;
let (r, c) = (r as i32, c as i32);
let lo_c = -(c * 64) - (4 + n) * 8;
let hi_c = (self.f.mi_cols as i32 - n8 - c) * 64 + (4 + n) * 8 - 8;
let lo_r = -(r * 64) - (4 + n) * 8;
let hi_r = (self.f.mi_rows as i32 - n8 - r) * 64 + (4 + n) * 8 - 8;
let lim = 8 * 1000;
(lo_r.max(-lim), hi_r.min(lim), lo_c.max(-lim), hi_c.min(lim))
}
/// SAD of the luma prediction at `mv` against the source.
fn sad(&self, r: usize, c: usize, n: usize, mv: Mv) -> u32 {
let p = &self.planes[0];
let (x16, y16) = (((c * 8) << 4) as i32 + 2 * mv.col, ((r * 8) << 4) as i32 + 2 * mv.row);
let mut s = 0u32;
if let Some((pl, o, st)) = self.f.phases.as_ref().and_then(|ph| ph.block(x16, y16, n)) {
for i in 0..n {
let a = &p.src[(r * 8 + i) * p.w + c * 8..][..n];
let b = &pl[o + i * st..o + i * st + n];
s += a.iter().zip(b).map(|(a, b)| (*a as i32 - *b as i32).unsigned_abs()).sum::<u32>();
}
return s;
}
let refs = self.f.refs.as_ref().expect("reference");
let mut pred = [0u8; 256];
predict(&refs[0], x16, y16, n, n, &mut pred);
for i in 0..n {
let a = &p.src[(r * 8 + i) * p.w + c * 8..][..n];
s += a.iter().zip(&pred[i * n..i * n + n]).map(|(a, b)| (*a as i32 - *b as i32).unsigned_abs()).sum::<u32>();
}
s
}
/// Motion search for the block at (`r`, `c`): the best vector and its cost
/// (SAD + λ·bits).
fn search(&self, r: usize, c: usize, bsize: u8) -> (Mv, f64) {
let n = if bsize == BLOCK_16X16 { 16 } else { 8 };
let (nearest, near, mctx) = self.mv_refs(r, c, bsize);
let (lo_r, hi_r, lo_c, hi_c) = self.mv_range(r, c, n as i32);
let inside = |mv: Mv| mv.row >= lo_r && mv.row <= hi_r && mv.col >= lo_c && mv.col <= hi_c;
let lambda = self.f.lambda_sad / 256.0;
let d = &probs_defaults().0;
let mb: [u32; 4] = std::array::from_fn(|m| tree_cost(&INTER_MODE_TREE, &d[probs::INTER_MODE + mctx * 3..], m as u8));
let mode_bits = |m: u8| mb[(m - NEARESTMV) as usize];
let cost = |mv: Mv| -> f64 {
let bits = if mv == nearest {
mode_bits(NEARESTMV)
} else if mv == Mv::ZERO {
mode_bits(ZEROMV)
} else if mv == near {
mode_bits(NEARMV)
} else {
mode_bits(NEWMV) + mv_cost(mv.minus(nearest))
};
self.sad(r, c, n, mv) as f64 + lambda * bits as f64
};
let mut best = (Mv::ZERO, cost(Mv::ZERO));
let prev = self.f.prev_mvs.map(|p| p[r * self.f.mi_cols + c]);
for cand in [Some(nearest), Some(near), prev].into_iter().flatten() {
if cand != Mv::ZERO && inside(cand) {
let j = cost(cand);
if j < best.1 {
best = (cand, j);
}
}
}
// A perfect match needs no search.
let good_enough = lambda * 256.0 * 0.5;
if best.1 > good_enough {
// Whole-sample search from the best candidate: a coarse cross, then a small diamond.
let round8 = |v: i32| (v + 4).div_euclid(8) * 8;
let mut cur = Mv::new(round8(best.0.row), round8(best.0.col));
let mut cur_j = if inside(cur) { cost(cur) } else { f64::MAX };
if cur_j < best.1 {
best = (cur, cur_j);
} else {
cur = best.0;
cur_j = best.1;
}
for (step, max_iters) in [(16, 1), (8, 2), (4, 3), (2, 4), (1, 8)] {
let s = step * 8;
for _ in 0..max_iters {
let mut moved = false;
let center = cur;
for (dr, dc) in [(-s, 0), (s, 0), (0, -s), (0, s)] {
let cand = Mv::new(center.row + dr, center.col + dc);
if !inside(cand) {
continue;
}
let j = cost(cand);
if j < cur_j {
cur = cand;
cur_j = j;
moved = true;
}
}
if !moved {
break;
}
}
}
if cur_j < best.1 {
best = (cur, cur_j);
}
}
// Sub-sample refinement: half then quarter samples (1/8 units, even: no high precision).
for s in [4, 2] {
let center = best.0;
for (dr, dc) in [(-s, 0), (s, 0), (0, -s), (0, s), (-s, -s), (-s, s), (s, -s), (s, s)] {
let cand = Mv::new(center.row + dr, center.col + dc);
if !inside(cand) {
continue;
}
let j = cost(cand);
if j < best.1 {
best = (cand, j);
}
}
}
best
}
/// Choose between one 16×16 block and four 8×8 blocks at (`r`, `c`): `Some(mv)` for 16×16.
fn decide16(&mut self, r: usize, c: usize) -> Option<Mv> {
let (mv16, j16) = self.search(r, c, BLOCK_16X16);
let lambda = self.f.lambda_sad;
// A near-perfect 16×16 prediction needs no split.
if j16 < 2.0 * 256.0 / 256.0 * 16.0 {
return Some(mv16);
}
let mut j8 = lambda * 4.0; // partition split + three more blocks of mode info (≈ 4 bits)
for (k, (dr, dc)) in [(0, 0), (0, 1), (1, 0), (1, 1)].into_iter().enumerate() {
let (rr, cc) = (r + dr, c + dc);
let (mv, j) = self.search(rr, cc, BLOCK_8X8);
j8 += j;
self.pending[k] = (rr, cc, mv);
// Tentative mode info so the next 8×8 blocks see this one as a candidate.
self.set_mi(rr, cc, 1, Mi { inter: true, mode: NEWMV, mv, skip: false, size: BLOCK_8X8 });
}
if j16 <= j8 {
self.pending = [(usize::MAX, 0, Mv::ZERO); 4];
Some(mv16)
} else {
None
}
}
/// Code an inter block with motion vector `mv`.
fn inter_block(&mut self, r: usize, c: usize, bsize: u8, mv: Mv) {
let (mi_rows, mi_cols) = (self.f.mi_rows, self.f.mi_cols);
let n8 = if bsize == BLOCK_16X16 { 2 } else { 1 };
let n = 8 * n8;
let (nearest, near, mctx) = self.mv_refs(r, c, bsize);
let d = &probs_defaults().0;
let mode_bits = |m: u8| tree_cost(&INTER_MODE_TREE, &d[probs::INTER_MODE + mctx * 3..], m - NEARESTMV);
let mut mode = (NEWMV, mode_bits(NEWMV) + mv_cost(mv.minus(nearest)));
for (m, ok) in [(NEARESTMV, mv == nearest), (NEARMV, mv == near), (ZEROMV, mv == Mv::ZERO)] {
if ok && mode_bits(m) < mode.1 {
mode = (m, mode_bits(m));
}
}
let mode = mode.0;
// Prediction of every plane (written straight into the reconstruction).
let refs = self.f.refs.as_ref().expect("reference");
for plane in 0..3 {
let ch = plane > 0;
let (row16, col16) = clamp_for_plane(mv, r, c, n8, mi_rows, mi_cols, ch);
let bn = if ch { n / 2 } else { n };
let (bx, by) = if ch { (c * 4, r * 4) } else { (c * 8, r * 8) };
let mut pred = [0u8; 256];
predict(&refs[plane], ((bx as i32) << 4) + col16, ((by as i32) << 4) + row16, bn, bn, &mut pred);
let p = &mut self.planes[plane];
for i in 0..bn {
p.rec[(by + i) * p.w + bx..][..bn].copy_from_slice(&pred[i * bn..i * bn + bn]);
}
}
// Residual: quantise every 4×4, dropping blocks whose coefficients don't pay for themselves.
let q = Quant { round_dc: 0.5, round_ac: 0.42, ..self.f.quant };
// Weight of the rate term when dropping a transform block's coefficients (low: keep quality on par
// with key frames at the same q index).
let zl = 0.07;
let tx = DCT_DCT;
let mut blocks: Vec<(usize, usize, usize, [i32; 16], usize)> = Vec::with_capacity(24);
let mut any = false;
for plane in 0..3 {
let ch = plane > 0;
let bn = if ch { n / 2 } else { n };
let (bx, by) = if ch { (c * 4, r * 4) } else { (c * 8, r * 8) };
let p = &self.planes[plane];
for ty in (0..bn).step_by(4) {
for tx4 in (0..bn).step_by(4) {
let (x, y) = (bx + tx4, by + ty);
let mut res = [0i32; 16];
for i in 0..4 {
for j in 0..4 {
let k = (y + i) * p.w + x + j;
res[i * 4 + j] = p.src[k] as i32 - p.rec[k] as i32;
}
}
let mut levels = q.levels(&res, tx);
let mut eob = eob_of(&levels, tx);
if eob > 0 && !q.lossless {
let back = q.residual(&levels, tx, eob);
let d_skip: i64 = res.iter().map(|v| (*v as i64) * (*v as i64)).sum();
let d_code: i64 = res.iter().zip(&back).map(|(a, b)| ((a - b) as i64).pow(2)).sum();
if d_skip as f64 <= d_code as f64 + zl * self.f.lambda_sse * levels_cost(&levels, eob) as f64 / 256.0 {
levels = [0; 16];
eob = 0;
}
}
any |= eob > 0;
blocks.push((plane, x, y, levels, eob));
}
}
}
let skip = !any;
// Mode info.
let avail_u = r > 0;
let avail_l = c > self.col_start;
let above = avail_u.then(|| self.mi_at(r - 1, c));
let left = avail_l.then(|| self.mi_at(r, c - 1));
let skip_ctx = above.is_some_and(|m| m.skip) as usize + left.is_some_and(|m| m.skip) as usize;
self.w.ctx(skip, probs::SKIP + skip_ctx);
// is_inter context: neighbours are inter (no intra blocks in inter frames).
let intra = |m: Option<Mi>| m.is_some_and(|m| !m.inter);
let inter_ctx = match (above, left) {
(Some(_), Some(_)) => {
if intra(above) && intra(left) {
3
} else {
(intra(above) || intra(left)) as usize
}
}
(Some(_), None) => 2 * intra(above) as usize,
(None, Some(_)) => 2 * intra(left) as usize,
(None, None) => 0,
};
self.w.ctx(true, probs::IS_INTER + inter_ctx);
// single_ref_p1 = 0 (LAST_FRAME); context for single-reference LAST neighbours.
let ref_ctx = match (above, left) {
(Some(a), Some(l)) => match (a.inter, l.inter) {
(false, false) => 2,
(false, true) => 4,
(true, false) => 4,
(true, true) => 4,
},
(Some(m), None) | (None, Some(m)) => {
if m.inter {
4
} else {
2
}
}
(None, None) => 2,
};
self.w.ctx(false, probs::SINGLE_REF + ref_ctx * 2);
self.w.tree(&INTER_MODE_TREE, mode - NEARESTMV, |i| P::Ctx((probs::INTER_MODE + mctx * 3 + i) as u16));
if mode == NEWMV {
self.write_mv(mv.minus(nearest));
}
self.set_mi(r, c, n8, Mi { inter: true, mode, mv, skip, size: bsize });
// Tokens and reconstruction.
for (plane, x, y, levels, eob) in blocks {
let ss = (plane > 0) as usize;
let (x4, ly) = (x >> 2, (y >> 2) & (15 >> ss));
if !skip {
let ctx0 = (self.above_nz[plane][x4] + self.left_nz[plane][ly]) as usize;
self.tokens(plane, true, tx, &levels, eob, ctx0);
if eob > 0 {
let resid = q.residual(&levels, tx, eob);
let p = &mut self.planes[plane];
for i in 0..4 {
for j in 0..4 {
let k = (y + i) * p.w + x + j;
p.rec[k] = (p.rec[k] as i32 + resid[i * 4 + j]).clamp(0, 255) as u8;
}
}
}
}
let nz = (!skip && eob > 0) as u8;
self.above_nz[plane][x4] = nz;
self.left_nz[plane][ly] = nz;
}
}
fn write_mv(&mut self, diff: Mv) {
let joint = (diff.row != 0) as u8 * 2 + (diff.col != 0) as u8;
self.w.tree(&MV_JOINT_TREE, joint, |i| P::Ctx((probs::MV_JOINT + i) as u16));
if diff.row != 0 {
self.write_mv_component(0, diff.row);
}
if diff.col != 0 {
self.write_mv_component(1, diff.col);
}
}
fn write_mv_component(&mut self, i: usize, v: i32) {
debug_assert!(v != 0 && v & 1 == 0, "vectors are even without high precision");
let offset = v.unsigned_abs() - 1;
let c = mv_class(offset);
self.w.ctx(v < 0, probs::MV_SIGN + i);
self.w.tree(&MV_CLASS_TREE, c as u8, |k| P::Ctx((probs::MV_CLASS + i * 10 + k) as u16));
let fr = ((offset >> 1) & 3) as u8;
if c == 0 {
let b = (offset >> 3) as usize;
self.w.ctx(b == 1, probs::MV_CLASS0_BIT + i);
self.w.tree(&MV_FR_TREE, fr, |k| P::Ctx((probs::MV_CLASS0_FR + i * 6 + b * 3 + k) as u16));
} else {
let dd = (offset - (1 << (c + 3))) >> 3;
for b in 0..c {
self.w.ctx((dd >> b) & 1 == 1, probs::MV_BITS + i * 10 + b as usize);
}
self.w.tree(&MV_FR_TREE, fr, |k| P::Ctx((probs::MV_FR + i * 3 + k) as u16));
}
}
}
+249
View File
@@ -0,0 +1,249 @@
//! 4×4 transforms: the decoder's integer inverse DCT / ADST (spec 8.7.1) and Walsh–Hadamard
//! transform (8.7.1.10), and their forward counterparts (the exact inverse matrices of the
//! decoder's linear transforms, so `inverse4(forward(res)) ≈ res`; the WHT inverse is exact).
pub const DCT_DCT: u8 = 0;
pub const ADST_DCT: u8 = 1;
pub const DCT_ADST: u8 = 2;
pub const ADST_ADST: u8 = 3;
fn r14(v: i32) -> i32 {
(v + (1 << 13)) >> 14
}
fn idct4(x: [i32; 4]) -> [i32; 4] {
let v0 = r14(x[0] * 11585 - x[2] * 11585);
let v1 = r14(x[0] * 11585 + x[2] * 11585);
let v2 = r14(x[1] * 6270 - x[3] * 15137);
let v3 = r14(x[1] * 15137 + x[3] * 6270);
[v1 + v3, v0 + v2, v0 - v2, v1 - v3]
}
fn iadst4(x: [i32; 4]) -> [i32; 4] {
let (s19, s29, s39, s49) = (5283i64, 9929i64, 13377i64, 15212i64);
let x: [i64; 4] = x.map(|v| v as i64);
let s0 = s19 * x[0];
let s1 = s29 * x[0];
let s2 = s39 * x[1];
let s3 = s49 * x[2];
let s4 = s19 * x[2];
let s5 = s29 * x[3];
let s6 = s49 * x[3];
let s7 = s39 * (x[0] - x[2] + x[3]);
let x0 = s0 + s3 + s5;
let x1 = s1 - s4 - s6;
let x2 = s7;
let x3 = s2;
let r = |v: i64| ((v + (1 << 13)) >> 14) as i32;
[r(x0 + x3), r(x1 + x3), r(x2), r(x0 + x1 - x3)]
}
/// The decoder's 4×4 inverse transform of dequantised coefficients (row-major) to a residual.
pub fn inverse4(coefs: &[i32; 16], tx_type: u8) -> [i32; 16] {
let row_adst = matches!(tx_type, DCT_ADST | ADST_ADST);
let col_adst = matches!(tx_type, ADST_DCT | ADST_ADST);
let mut t = [[0i32; 4]; 4];
for i in 0..4 {
let r = [coefs[i * 4], coefs[i * 4 + 1], coefs[i * 4 + 2], coefs[i * 4 + 3]];
t[i] = if row_adst { iadst4(r) } else { idct4(r) };
}
let mut out = [0i32; 16];
for j in 0..4 {
let c = [t[0][j], t[1][j], t[2][j], t[3][j]];
let c = if col_adst { iadst4(c) } else { idct4(c) };
for i in 0..4 {
out[i * 4 + j] = (c[i] + 8) >> 4;
}
}
out
}
/// Inverse Walsh–Hadamard (8.7.1.10) on one line.
fn iwht(x: [i32; 4], shift: u32) -> [i32; 4] {
let mut a = x[0] >> shift;
let mut c = x[1] >> shift;
let mut d = x[2] >> shift;
let mut b = x[3] >> shift;
a += c;
d -= b;
let e = (a - d) >> 1;
b = e - b;
c = e - c;
a -= b;
d += c;
[a, b, c, d]
}
/// The exact inverse of [`iwht`] (with shift 0): inputs that produce outputs `y`.
fn fwht(y: [i32; 4]) -> [i32; 4] {
let (aa, bb, cc, dd) = (y[0], y[1], y[2], y[3]);
let d1 = dd - cc;
let a1 = aa + bb;
let e = (a1 - d1) >> 1;
let b0 = e - bb;
let c0 = e - cc;
let a0 = a1 - c0;
let d0 = d1 + b0;
[a0, c0, d0, b0]
}
pub fn inverse_wht(coefs: &[i32; 16]) -> [i32; 16] {
let mut t = [[0i32; 4]; 4];
for i in 0..4 {
t[i] = iwht([coefs[i * 4], coefs[i * 4 + 1], coefs[i * 4 + 2], coefs[i * 4 + 3]], 2);
}
let mut out = [0i32; 16];
for j in 0..4 {
let r = iwht([t[0][j], t[1][j], t[2][j], t[3][j]], 0);
for i in 0..4 {
out[i * 4 + j] = r[i];
}
}
out
}
/// Lossless forward transform: levels (dequantised by 4) whose inverse is exactly `res`.
pub fn forward_wht(res: &[i32; 16]) -> [i32; 16] {
let mut t = [[0i32; 4]; 4];
for j in 0..4 {
let col = fwht([res[j], res[4 + j], res[8 + j], res[12 + j]]);
for i in 0..4 {
t[i][j] = col[i];
}
}
let mut lv = [0i32; 16];
for i in 0..4 {
let r = fwht(t[i]);
lv[i * 4..i * 4 + 4].copy_from_slice(&r);
}
lv
}
/// Forward transform matrices: the inverses of the decoder's (linear part of the) inverse
/// transforms.
pub fn forward_matrices() -> &'static [[[f64; 16]; 16]; 4] {
use std::sync::OnceLock;
static M: OnceLock<[[[f64; 16]; 16]; 4]> = OnceLock::new();
M.get_or_init(|| {
let mut out = [[[0.0; 16]; 16]; 4];
for (ty, o) in out.iter_mut().enumerate() {
// Columns of the inverse: response to a scaled unit coefficient.
let k = 1 << 16;
let mut inv = [[0.0f64; 16]; 16];
for c in 0..16 {
let mut e = [0i32; 16];
e[c] = k;
let r = inverse4(&e, ty as u8);
for p in 0..16 {
inv[p][c] = r[p] as f64 / k as f64;
}
}
*o = invert16(inv);
}
out
})
}
fn invert16(m: [[f64; 16]; 16]) -> [[f64; 16]; 16] {
let mut a = m;
let mut inv = [[0.0; 16]; 16];
for (i, r) in inv.iter_mut().enumerate() {
r[i] = 1.0;
}
for col in 0..16 {
let piv = (col..16).max_by(|&x, &y| a[x][col].abs().total_cmp(&a[y][col].abs())).unwrap_or(col);
a.swap(col, piv);
inv.swap(col, piv);
let d = a[col][col];
for j in 0..16 {
a[col][j] /= d;
inv[col][j] /= d;
}
for r in 0..16 {
if r != col {
let f = a[r][col];
if f != 0.0 {
for j in 0..16 {
a[r][j] -= f * a[col][j];
inv[r][j] -= f * inv[col][j];
}
}
}
}
}
inv
}
/// Quantisation settings of one block.
#[derive(Clone, Copy)]
pub struct Quant {
pub dcq: i32,
pub acq: i32,
pub lossless: bool,
/// Rounding offsets (fractions of a step) for DC and AC; smaller = wider dead zone.
pub round_dc: f64,
pub round_ac: f64,
}
impl Quant {
/// Quantised levels (raster order) of a residual.
pub fn levels(&self, res: &[i32; 16], tx_type: u8) -> [i32; 16] {
if self.lossless {
return forward_wht(res);
}
let f = &forward_matrices()[tx_type as usize];
let mut lv = [0i32; 16];
for k in 0..16 {
let coef: f64 = (0..16).map(|q| f[k][q] * res[q] as f64).sum();
let (qs, rnd) = if k == 0 { (self.dcq, self.round_dc) } else { (self.acq, self.round_ac) };
let l = (coef.abs() / qs as f64 + rnd).floor() as i32;
lv[k] = if coef < 0.0 { -l } else { l };
}
lv
}
/// The decoder's residual for `levels` (all zero when `eob` is 0).
pub fn residual(&self, levels: &[i32; 16], tx_type: u8, eob: usize) -> [i32; 16] {
if eob == 0 {
return [0; 16];
}
let mut deq = [0i32; 16];
for k in 0..16 {
deq[k] = levels[k] * if k == 0 { self.dcq } else { self.acq };
}
if self.lossless { inverse_wht(&deq) } else { inverse4(&deq, tx_type) }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn wht_is_exactly_invertible() {
let mut s = 7u32;
for _ in 0..2000 {
let mut r = [0i32; 16];
for v in &mut r {
s = s.wrapping_mul(1664525).wrapping_add(1013904223);
*v = (s >> 16) as i32 % 511 - 255;
}
let lv = forward_wht(&r);
let deq = lv.map(|l| l * 4);
assert_eq!(inverse_wht(&deq), r);
}
}
#[test]
fn forward_transforms_invert_the_decoder() {
for ty in 0..4u8 {
let f = &forward_matrices()[ty as usize];
let r: [i32; 16] = std::array::from_fn(|i| (i as i32 * 37 % 61) - 30);
let c: [i32; 16] = std::array::from_fn(|k| (0..16).map(|q| f[k][q] * r[q] as f64).sum::<f64>().round() as i32);
let back = inverse4(&c, ty);
for i in 0..16 {
assert!((back[i] - r[i]).abs() <= 1, "type {ty}: {back:?} vs {r:?}");
}
}
}
}
+3 -3
View File
@@ -20,14 +20,14 @@ Animation 94%, Masks 94%, Preview 94%, Interface 93%, Shapes 93%, 3D 91%, Audio
Tracking 96%, 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; Render Queue field render, crop/resize and templates;
renders); stroke taper/wave and multi-segment dashes; variable mask feather points; camera iris/bokeh and focus-link commands;
approximated effects (Key Cleaner) and Liquify's viewer brush; text in imported
PDF/AI files and Illustrator procset EPS; and more codecs. Face tracking and a true Subspace Warp
landed in M13.3. ScriptUI (dialogs that block `show()`, palettes, dockable ScriptUI panels, File ▸ Scripts install
and sample scripts), puppet pin recording and the remaining "better than After Effects" items — a
versioned effect plug-in API with sandboxed WebAssembly plug-ins ([plugins.md](plugins.md)),
branching undo history in the History panel, and a bit-identical rendering test across runs and
thread counts — landed in M13.1 (GPU particles landed in M12.7).
thread counts — landed in M13.1 (GPU particles landed in M12.7). Render Queue field render, crop/resize, templates and logs, and VP9 inter frames landed in M10.2.
The sections below are the original audit (morning of 2 October, ≈ 64%) and its updates, kept for
history.
@@ -112,7 +112,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 is intra-only, 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 | 94% | 1.0 | text and images inside PDF/AI files, Illustrator EPS relying on Adobe procsets, smart-object perspective/warps, PSD 3D layers (PDF / PDF-compatible AI / EPS vector footage with Continuously Rasterize, layered composition import and Create Shapes from Vector Layer, and PSD smart objects with embedded files landed in M13.2; PSD as footage/composition/retain layer sizes, SVG footage earlier) |
| Automation | ≈ 97% | 0.3 | `.jsxbin`, sockets, ScriptUI resource strings and custom `onDraw` graphics (the core object model landed in M14.4; ScriptUI windows/dialogs/dockable panels with `scriptui.*` agent commands, File ▸ Scripts install + sample scripts, and the effect plug-in API (EFF-6, WebAssembly) landed in M13.1) |
-2
View File
@@ -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.