From c33b80614ace5e598c48e998f59b49aaf96c26ed Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 23:11:03 -0400 Subject: [PATCH] =?UTF-8?q?PRJ-8:=20proxies=20=E2=80=94=20Set=20Proxy=20/?= =?UTF-8?q?=20None=20/=20Use=20Proxy=20/=20Create=20Proxy=20/=20Interpret?= =?UTF-8?q?=20Proxy,=20Proxy=20Use;=20PRJ-3=20interpret=20fields,=20PAR,?= =?UTF-8?q?=20alpha=20guess?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Items (footage and comps) carry a proxy rendered in their place at the source's size when its switch and Render Settings > Proxy Use allow it; Create Proxy queues a half-resolution render whose post-render action attaches it. Interpret Footage gains Separate Fields (upper/lower first, fields become frames at twice the rate), Guess alpha with matte colour, explicit matte colour, Invert Alpha, Interpret As Linear Light; non-square footage, solids and precomps are stretched by their pixel aspect in the comp. The four proxy stubs are gone. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands/file_more.rs | 207 +++++++++++++--- crates/engine/src/commands/layer.rs | 2 +- crates/engine/src/commands/mod.rs | 9 +- crates/engine/src/commands/proxy.rs | 223 +++++++++++++++++ crates/engine/src/commands/render_queue.rs | 8 +- crates/engine/src/commands/stubs.rs | 6 - crates/engine/src/lib.rs | 5 +- crates/engine/src/render_queue.rs | 5 + crates/engine/src/tests_menu_cmds.rs | 2 +- crates/engine/src/tests_proxy.rs | 275 +++++++++++++++++++++ crates/engine/src/tests_stubs.rs | 2 +- crates/export/src/lib.rs | 1 + crates/host/src/lib.rs | 53 ++++ crates/project/src/render_queue.rs | 51 ++++ crates/render/src/cache.rs | 2 + crates/render/src/eval.rs | 25 +- crates/render/src/lib.rs | 130 ++++++++-- crates/ui-egui/src/lib.rs | 1 + 18 files changed, 938 insertions(+), 69 deletions(-) create mode 100644 crates/engine/src/commands/proxy.rs create mode 100644 crates/engine/src/tests_proxy.rs diff --git a/crates/engine/src/commands/file_more.rs b/crates/engine/src/commands/file_more.rs index f5f4d370..45e6865a 100644 --- a/crates/engine/src/commands/file_more.rs +++ b/crates/engine/src/commands/file_more.rs @@ -400,56 +400,179 @@ fn run_script(s: &mut Session, p: &Value) -> Result { // ---------------------------------------------------------------- footage +/// Interpret Footage settings parsed from command parameters (every field optional). +#[derive(Clone, Debug, Default)] +pub(crate) struct Interpretation { + rate: Option, + /// Back to the file's own rate (Use frame rate from file). + native: bool, + alpha: Option, + /// Alpha ▸ Guess: decide Straight / Premultiplied (and the matte colour) from a frame. + guess: bool, + matte: Option<[f32; 3]>, + invert_alpha: Option, + loops: Option, + par: Option, + fields: Option, + profile: Option>, + linear: Option, +} + +impl Interpretation { + pub(crate) fn parse(p: &Value, cmd: &str) -> Result { + let mut it = Interpretation { rate: f_p(p, "frameRate").map(FrameRate::from_f64), ..Default::default() }; + if str_p(p, "frameRate") == Some("file") { + it.native = true; + } + match str_p(p, "alpha") { + Some("straight") => it.alpha = Some(AlphaMode::Straight), + Some("premultiplied") => it.alpha = Some(AlphaMode::Premultiplied), + Some("ignore") => it.alpha = Some(AlphaMode::Ignore), + Some("guess") => it.guess = true, + Some(a) => return Err(bad(cmd, format!("alpha: straight|premultiplied|ignore|guess, not `{a}`"))), + None => {} + } + it.guess |= super::b_p(p, "guessAlpha").unwrap_or(false); + it.matte = match p.get("matteColor").or(p.get("premulColor")) { + None => None, + Some(v) => Some( + effectcraft_keyframe::Value::Color([0.0, 0.0, 0.0, 1.0]) + .coerce_json(v) + .map(|c| { + let c = c.as_color(); + [c[0], c[1], c[2]] + }) + .ok_or_else(|| bad(cmd, "matteColor: #hex or [r, g, b] (0..1)"))?, + ), + }; + it.invert_alpha = super::b_p(p, "invertAlpha"); + it.loops = p.get("loop").and_then(Value::as_u64).map(|v| v.clamp(1, 9999) as u32); + it.par = f_p(p, "pixelAspect").filter(|x| *x > 0.0); + it.fields = match str_p(p, "fields") { + Some(f) => Some(effectcraft_project::FieldOrder::parse(f).ok_or_else(|| bad(cmd, "fields: off|upper|lower"))?), + None => None, + }; + // Color ▸ Assign Profile: a colour space id, or "auto"/"none" for the file's own (sRGB). + it.profile = match str_p(p, "colorProfile") { + Some("auto" | "none" | "") => Some(None), + Some(c) => match effectcraft_project::ColorSpace::parse(c) { + Some(cs) => Some(Some(cs)), + None => return Err(bad(cmd, format!("colorProfile: srgb|rec709|rec2020|p3|auto, not `{c}`"))), + }, + None => None, + }; + it.linear = super::b_p(p, "linearLight"); + Ok(it) + } + + pub(crate) fn apply(&self, f: &mut Footage, guessed: Option<(AlphaMode, [f32; 3])>) { + if self.native { + if let Some(n) = f.native_rate.take() { + f.frame_rate = n; + } + } else if let Some(r) = self.rate { + if f.native_rate.is_none() { + f.native_rate = Some(f.frame_rate); + } + f.frame_rate = r; + } + if let Some(a) = self.alpha { + f.alpha = a; + } + if let Some((a, m)) = guessed { + f.alpha = a; + f.premul_color = m; + } + if let Some(m) = self.matte { + f.premul_color = m; + } + if let Some(v) = self.invert_alpha { + f.invert_alpha = v; + } + if let Some(l) = self.loops { + f.loop_count = l; + } + if let Some(x) = self.par { + f.pixel_aspect = x; + } + if let Some(x) = self.fields { + f.fields = x; + } + if let Some(c) = self.profile { + f.color_profile = c; + } + if let Some(v) = self.linear { + f.linear_light = v; + } + } +} + +/// Interpret Footage ▸ Alpha ▸ Guess: read the first frame as straight colour and decide +/// whether the file is premultiplied (every partly transparent pixel's colour within its alpha +/// of a black or white matte) or straight. `None` when the frame has no partial alpha. +pub(crate) fn guess_alpha(s: &Session, item: ItemId, f: &Footage) -> Option<(AlphaMode, [f32; 3])> { + let probe = Footage { alpha: AlphaMode::Straight, invert_alpha: false, ..f.clone() }; + let img = s.footage.frame(item, &probe, Tick::ZERO)?; + let (mut partial, mut black, mut white) = (0usize, true, true); + let eps = 1.5 / 255.0; + for px in img.data.iter() { + let a = px[3]; + if a <= eps || a >= 1.0 - eps { + continue; + } + partial += 1; + for c in &px[..3] { + // The file's colour (the decoder premultiplied it as straight). + let file = c / a; + black &= file <= a + eps; + white &= file >= 1.0 - a - eps; + } + } + if partial == 0 { + return None; + } + Some(if black { + (AlphaMode::Premultiplied, [0.0; 3]) + } else if white { + (AlphaMode::Premultiplied, [1.0; 3]) + } else { + (AlphaMode::Straight, f.premul_color) + }) +} + fn interpret(s: &mut Session, p: &Value) -> Result { let items = items_p(s, p); if items.is_empty() { return Err(bad("file.interpretFootage", "select footage")); } - let rate = f_p(p, "frameRate").map(FrameRate::from_f64); - let alpha = match str_p(p, "alpha") { - Some("straight") => Some(AlphaMode::Straight), - Some("premultiplied") => Some(AlphaMode::Premultiplied), - Some("ignore") => Some(AlphaMode::Ignore), - Some(a) => return Err(bad("file.interpretFootage", format!("alpha: straight|premultiplied|ignore, not `{a}`"))), - None => None, - }; - let loops = p.get("loop").and_then(Value::as_u64).map(|v| v.max(1) as u32); - let par = f_p(p, "pixelAspect"); - // Color ▸ Assign Profile: a colour space id, or "auto"/"none" for the file's own (sRGB). - let profile = match str_p(p, "colorProfile") { - Some("auto" | "none" | "") => Some(None), - Some(c) => match effectcraft_project::ColorSpace::parse(c) { - Some(cs) => Some(Some(cs)), - None => return Err(bad("file.interpretFootage", format!("colorProfile: srgb|rec709|rec2020|p3|auto, not `{c}`"))), - }, - None => None, - }; + let it = Interpretation::parse(p, "file.interpretFootage")?; + let guesses: Vec> = items + .iter() + .map(|i| match (it.guess, s.project.item(*i).map(|x| &x.kind)) { + (true, Some(ItemKind::Footage(f))) => guess_alpha(s, *i, f), + _ => None, + }) + .collect(); s.edit("Interpret Footage", None, |proj, _| { - for i in &items { + for (i, g) in items.iter().zip(&guesses) { if let Some(ItemKind::Footage(f)) = proj.item_mut(*i).map(|x| &mut x.kind) { - if let Some(r) = rate { - if f.native_rate.is_none() { - f.native_rate = Some(f.frame_rate); - } - f.frame_rate = r; - } - if let Some(a) = alpha { - f.alpha = a; - } - if let Some(l) = loops { - f.loop_count = l; - } - if let Some(x) = par { - f.pixel_aspect = x; - } - if let Some(c) = profile { - f.color_profile = c; - } + it.apply(f, *g); } } Ok(()) })?; - Ok(Value::Null) + let out: Vec = items + .iter() + .filter_map(|i| match s.project.item(*i).map(|x| &x.kind) { + Some(ItemKind::Footage(f)) => Some(json!({ + "item": i.0, "alpha": format!("{:?}", f.alpha), "matteColor": f.premul_color, "invertAlpha": f.invert_alpha, + "fields": f.fields.label(), "pixelAspect": f.pixel_aspect, "loop": f.loop_count, "frameRate": f.frame_rate.as_f64(), + "linearLight": f.linear_light, + })), + _ => None, + }) + .collect(); + Ok(json!({"items": out})) } fn remember_interpretation(s: &mut Session, p: &Value) -> Result { @@ -470,6 +593,10 @@ fn apply_interpretation(s: &mut Session, p: &Value) -> Result { f.premul_color = src.premul_color; f.pixel_aspect = src.pixel_aspect; f.loop_count = src.loop_count; + f.fields = src.fields; + f.invert_alpha = src.invert_alpha; + f.linear_light = src.linear_light; + f.color_profile = src.color_profile; if src.native_rate.is_some() { if f.native_rate.is_none() { f.native_rate = Some(f.frame_rate); @@ -648,7 +775,7 @@ pub fn specs() -> Vec { "Main...", ["File", "Interpret Footage"], Some("Cmd+Alt+G"), - "{items?, frameRate?, alpha?: straight|premultiplied|ignore, loop?, pixelAspect?, colorProfile?: srgb|rec709|rec2020|p3|auto}", + "{items?, frameRate?: fps|\"file\", alpha?: straight|premultiplied|ignore|guess, guessAlpha?, matteColor?, invertAlpha?, loop?, pixelAspect?, fields?: off|upper|lower, colorProfile?: srgb|rec709|rec2020|p3|auto, linearLight?}", has_footage_selection, interpret ), diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index e57b1c0a..6f326b02 100644 --- a/crates/engine/src/commands/layer.rs +++ b/crates/engine/src/commands/layer.rs @@ -53,7 +53,7 @@ fn new_solid_like(s: &mut Session, p: &Value, adjustment: bool) -> Result let name = str_p(p, "name").map(str::to_string).unwrap_or(default_name); let id = s.edit(if adjustment { "New Adjustment Layer" } else { "New Solid" }, None, |proj, st| { let folder = solids_folder(proj); - let sid = proj.add_item(&name, Label::Red, Some(folder), ItemKind::Solid(Solid { color, width: w, height: h, pixel_aspect: 1.0 })); + let sid = proj.add_item(&name, Label::Red, Some(folder), ItemKind::Solid(Solid { color, width: w, height: h, pixel_aspect: comp.pixel_aspect })); let mut l = build::layer(proj, &comp, &name, LayerSource::Solid { item: sid }, (w, h), None); if adjustment { l.switches.adjustment = true; diff --git a/crates/engine/src/commands/mod.rs b/crates/engine/src/commands/mod.rs index e1b3f15f..7d44f5e5 100644 --- a/crates/engine/src/commands/mod.rs +++ b/crates/engine/src/commands/mod.rs @@ -11,7 +11,7 @@ mod effect; pub mod essential; pub mod expr_tools; mod file; -mod file_more; +pub(crate) mod file_more; mod frontend; mod help; mod key_transform; @@ -29,6 +29,7 @@ pub mod paint; mod paths; mod project_items; mod prop; +mod proxy; pub mod puppet; mod query; mod render_queue; @@ -140,11 +141,17 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(settings::specs()); v.extend(essential::specs()); v.extend(expr_tools::specs()); + v.extend(proxy::specs()); v.extend(stubs::specs()); v }) } +/// Set `footage` as the proxy of `item` (File ▸ Create Proxy's post-render action). +pub(crate) fn proxy_set(s: &mut Session, item: ItemId, footage: effectcraft_project::Footage) -> Result<()> { + proxy::set_proxy_footage(s, &[item], footage) +} + /// (id, name) of the text animation presets. pub fn text_preset_list() -> Vec<(String, String)> { text_anim::preset_names() diff --git a/crates/engine/src/commands/proxy.rs b/crates/engine/src/commands/proxy.rs new file mode 100644 index 00000000..607dd5dd --- /dev/null +++ b/crates/engine/src/commands/proxy.rs @@ -0,0 +1,223 @@ +//! Proxies (File ▸ Create Proxy / Set Proxy / Interpret Footage ▸ Proxy): a stand-in file for +//! a footage item or a composition. The Project panel's proxy switch (`file.useProxy`) turns +//! it on or off per item; renders follow Render Settings ▸ Proxy Use (the viewer uses each +//! item's switch). + +use effectcraft_project::render_queue::{Channels, OutputFormat, OutputModule, PostRenderAction, ProResProfile, RenderQuality, RenderQueueItem, TimeSpan}; +use effectcraft_project::{Footage, ItemId, ItemKind, Proxy}; +use effectcraft_time::Tick; +use serde_json::{Value, json}; + +use super::{CommandSpec, b_p, bad, has_project_selection, str_p}; +use crate::{EngineError, Result, Session, cmd}; + +/// Footage items and comps a proxy command acts on: `items`/`item`, else the Project panel +/// selection. +fn targets(s: &Session, p: &Value) -> Vec { + let ids: Vec = match p.get("items").or(p.get("item")) { + Some(Value::Array(a)) => a.iter().filter_map(Value::as_u64).map(ItemId).collect(), + Some(Value::Number(n)) => n.as_u64().map(ItemId).into_iter().collect(), + Some(Value::String(name)) => s.project.find_by_name(name).map(|i| i.id).into_iter().collect(), + _ => s.state.project_selection.clone(), + }; + ids.into_iter().filter(|i| matches!(s.project.item(*i).map(|x| &x.kind), Some(ItemKind::Footage(_) | ItemKind::Comp(_)))).collect() +} + +fn has_targets(s: &Session) -> std::result::Result<(), String> { + has_project_selection(s)?; + if targets(s, &Value::Null).is_empty() { Err("select footage or a composition in the Project panel".into()) } else { Ok(()) } +} + +fn has_proxy(s: &Session) -> std::result::Result<(), String> { + has_targets(s)?; + if targets(s, &Value::Null).iter().any(|i| s.project.item(*i).is_some_and(|x| x.proxy.is_some())) { + Ok(()) + } else { + Err("the selected items have no proxy".into()) + } +} + +/// Probe a proxy file through the importer. +fn probe(s: &Session, path: &str, cmd: &str) -> Result { + let importer = s.importer.clone().ok_or_else(|| EngineError::Other("media import is not available in this build".into()))?; + let f = importer.probe(path).map_err(|e| bad(cmd, format!("{path}: {e}")))?; + if !f.has_video { + return Err(bad(cmd, format!("{path} has no video to use as a proxy"))); + } + Ok(f) +} + +/// Attach `footage` as the proxy of `items` (Use Proxy on). One undo step. +pub(crate) fn set_proxy_footage(s: &mut Session, items: &[ItemId], footage: Footage) -> Result<()> { + s.edit("Set Proxy", None, |proj, _| { + for i in items { + if let Some(it) = proj.item_mut(*i) { + it.proxy = Some(Box::new(Proxy { footage: footage.clone(), enabled: true })); + } + } + Ok(()) + }) +} + +fn set_proxy(s: &mut Session, p: &Value) -> Result { + const C: &str = "file.setProxy"; + let items = targets(s, p); + if items.is_empty() { + return Err(bad(C, "select footage or a composition")); + } + let path = str_p(p, "path").ok_or_else(|| bad(C, "missing `path`"))?.to_string(); + let f = probe(s, &path, C)?; + let (w, h) = (f.width, f.height); + set_proxy_footage(s, &items, f)?; + Ok(json!({"items": items.iter().map(|i| i.0).collect::>(), "path": path, "width": w, "height": h})) +} + +fn set_proxy_none(s: &mut Session, p: &Value) -> Result { + let items = targets(s, p); + s.edit("Set Proxy None", None, |proj, _| { + for i in &items { + if let Some(it) = proj.item_mut(*i) { + it.proxy = None; + } + } + Ok(()) + })?; + Ok(json!({"items": items.iter().map(|i| i.0).collect::>()})) +} + +/// The Project panel's proxy switch: use the proxy (filled box) or the full-resolution source. +fn use_proxy(s: &mut Session, p: &Value) -> Result { + const C: &str = "file.useProxy"; + let items: Vec = targets(s, p).into_iter().filter(|i| s.project.item(*i).is_some_and(|x| x.proxy.is_some())).collect(); + if items.is_empty() { + return Err(bad(C, "the selected items have no proxy")); + } + let first = s.project.item(items[0]).and_then(|x| x.proxy.as_ref()).is_some_and(|px| px.enabled); + let on = b_p(p, "on").unwrap_or(!first); + s.edit(if on { "Use Proxy" } else { "Use Full Resolution" }, None, |proj, _| { + for i in &items { + if let Some(px) = proj.item_mut(*i).and_then(|x| x.proxy.as_mut()) { + px.enabled = on; + } + } + Ok(()) + })?; + Ok(json!({"items": items.iter().map(|i| i.0).collect::>(), "on": on})) +} + +/// File ▸ Interpret Footage ▸ Proxy: Interpret Footage settings for the proxy file. +fn interpret_proxy(s: &mut Session, p: &Value) -> Result { + const C: &str = "file.interpretProxy"; + let items: Vec = targets(s, p).into_iter().filter(|i| s.project.item(*i).is_some_and(|x| x.proxy.is_some())).collect(); + if items.is_empty() { + return Err(bad(C, "the selected items have no proxy")); + } + let it = super::file_more::Interpretation::parse(p, C)?; + let guesses: Vec<_> = items + .iter() + .map(|i| { + let px = s.project.item(*i).and_then(|x| x.proxy.as_ref()); + match (p.get("alpha").and_then(Value::as_str) == Some("guess") || b_p(p, "guessAlpha") == Some(true), px) { + (true, Some(px)) => super::file_more::guess_alpha(s, *i, &px.footage), + _ => None, + } + }) + .collect(); + s.edit("Interpret Proxy", None, |proj, _| { + for (i, g) in items.iter().zip(&guesses) { + if let Some(px) = proj.item_mut(*i).and_then(|x| x.proxy.as_mut()) { + it.apply(&mut px.footage, *g); + } + } + Ok(()) + })?; + Ok(json!({"items": items.iter().map(|i| i.0).collect::>()})) +} + +/// File ▸ Create Proxy ▸ Still / Movie: queue a half-resolution render of the composition whose +/// post-render action sets the result as its proxy. +fn create_proxy(s: &mut Session, p: &Value) -> Result { + const C: &str = "file.createProxy"; + let cid = match p.get("comp").or(p.get("item")) { + Some(_) => { + let q = json!({"comp": p.get("comp").or(p.get("item")).cloned()}); + super::comp_id(s, &q)? + } + None => s + .state + .project_selection + .iter() + .copied() + .find(|i| s.project.comp(*i).is_some()) + .or(s.active_comp_id()) + .ok_or_else(|| bad(C, "select a composition"))?, + }; + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone(); + let still = match str_p(p, "kind").unwrap_or("movie") { + "still" => true, + "movie" => false, + k => return Err(bad(C, format!("kind: still|movie, not `{k}`"))), + }; + let mut it = RenderQueueItem::new(0, cid); + 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(); + } else { + it.settings.time_span = TimeSpan::LengthOfComp; + 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; + if let Some(o) = str_p(p, "path").or(str_p(p, "output")) { + it.output.output = o.to_string(); + } + it.post_render = PostRenderAction::SetProxy; + let id = s.edit("Create Proxy", 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, "comp": cid.0, "kind": if still { "still" } else { "movie" }, "output": path, "time": Tick::ZERO.seconds()})) +} + +fn has_comp_selection(s: &Session) -> std::result::Result<(), String> { + if s.state.project_selection.iter().any(|i| s.project.comp(*i).is_some()) || s.active_comp_id().is_some() { + Ok(()) + } else { + Err("select a composition".into()) + } +} + +pub fn specs() -> Vec { + vec![ + cmd!( + "file.createProxy", + "Create Proxy", + [], + None, + "{kind: still|movie, comp?|item?, path?|output? (template), resolution? (default 0.5)}", + has_comp_selection, + create_proxy + ), + cmd!("file.setProxy", "File...", ["File", "Set Proxy"], None, "{path, items?|item?}", has_targets, set_proxy), + cmd!("file.setProxyNone", "None", ["File", "Set Proxy"], None, "{items?|item?}", has_proxy, set_proxy_none), + cmd!("file.useProxy", "Use Proxy", [], None, "{items?|item?, on?}", has_proxy, use_proxy), + cmd!( + "file.interpretProxy", + "Proxy...", + ["File", "Interpret Footage"], + None, + "{items?|item?, alpha?, matteColor?, invertAlpha?, frameRate?, loop?, pixelAspect?, fields?, colorProfile?, linearLight?}", + has_proxy, + interpret_proxy + ), + ] +} diff --git a/crates/engine/src/commands/render_queue.rs b/crates/engine/src/commands/render_queue.rs index c905ce6c..acdf4912 100644 --- a/crates/engine/src/commands/render_queue.rs +++ b/crates/engine/src/commands/render_queue.rs @@ -104,6 +104,10 @@ fn apply_settings(rs: &mut RenderSettings, p: &Value, cmd: &str) -> Result { return Err(bad(cmd, "end must be after start")); } + if let Some(u) = str_p(p, "proxyUse") { + rs.proxy_use = effectcraft_project::render_queue::ProxyUse::parse(u).ok_or_else(|| bad(cmd, "proxyUse: current|all|comp|none"))?; + any = true; + } match p.get("frameRate") { Some(Value::Null) => { rs.frame_rate = None; @@ -509,7 +513,7 @@ pub fn specs() -> Vec { "Add to Render Queue", ["Composition"], Some("Cmd+M"), - "{comp?: id|name, format?: h264|prores|png|jpeg|tiff|exr|gif, output?: path|template, quality?: best|draft|1-100 (jpeg), resolution?: full|half|third|quarter|scale, timeSpan?: workArea|comp|custom, start?: s, end?: s, frameRate?: fps|null, motionBlur?: bool, skipExisting?: bool, channels?: rgb|rgba, bitrate?: kbps, proresProfile?: proxy|lt|standard|hq|4444|4444xq, audio?: auto|on|off, sampleRate?, loop?: bool}", + "{comp?: id|name, format?: h264|prores|png|jpeg|tiff|exr|gif, output?: path|template, quality?: best|draft|1-100 (jpeg), 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}", can_add, add ), @@ -520,7 +524,7 @@ pub fn specs() -> Vec { "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}", + "{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}", has_items, set_render_settings ), diff --git a/crates/engine/src/commands/stubs.rs b/crates/engine/src/commands/stubs.rs index 8b244bf6..b62f3654 100644 --- a/crates/engine/src/commands/stubs.rs +++ b/crates/engine/src/commands/stubs.rs @@ -20,12 +20,6 @@ pub fn specs() -> Vec { // macOS application menu items handled by the OS. stub!("app.hideOthers", "Hide Others", [], "{}"), stub!("app.showAll", "Show All", [], "{}"), - // Render queue / export (export milestone). - stub!("file.createProxy", "Create Proxy", [], "{kind: still|movie}"), - // Proxies (no proxy model yet). - stub!("file.setProxy", "File...", ["File", "Set Proxy"], "{path}"), - stub!("file.setProxyNone", "None", ["File", "Set Proxy"], "{}"), - stub!("file.interpretProxy", "Proxy...", ["File", "Interpret Footage"], "{}"), stub!("keys.selectLabelGroup", "Select Keyframe Label Group", [], "{scope}"), // Layer ▸ New (3D / content-aware fill). stub!("layer.newContentAwareFill", "Content-Aware Fill Layer...", ["Layer", "New"], "{}"), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index d0a4ba1a..b2c4d342 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -340,7 +340,8 @@ impl Session { let spec = commands::find(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?; if let Err(why) = (spec.enabled)(self) { // Explicit targets (agents, scripts) don't need a UI selection. - let explicit = ["layer", "layers", "prop", "keys"].iter().any(|k| params.get(k).is_some()) && commands::has_comp(self).is_ok(); + let explicit = (["layer", "layers", "prop", "keys"].iter().any(|k| params.get(k).is_some()) && commands::has_comp(self).is_ok()) + || ["item", "items"].iter().any(|k| params.get(k).is_some()); if !explicit { return Err(EngineError::Disabled(id.to_string(), why)); } @@ -601,6 +602,8 @@ mod tests_model3d; #[cfg(test)] mod tests_project_items; #[cfg(test)] +mod tests_proxy; +#[cfg(test)] mod tests_settings; #[cfg(test)] mod tests_stubs; diff --git a/crates/engine/src/render_queue.rs b/crates/engine/src/render_queue.rs index bc490f47..efeb670f 100644 --- a/crates/engine/src/render_queue.rs +++ b/crates/engine/src/render_queue.rs @@ -388,6 +388,11 @@ impl Session { } let importer = self.importer.clone().ok_or("media import is not available in this build")?; let footage = importer.probe(path)?; + if item.post_render == PostRenderAction::SetProxy { + crate::commands::proxy_set(self, item.comp, footage).map_err(|e| e.to_string())?; + self.toast(format!("Proxy set from {path}")); + return Ok(None); + } let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string()); let replace = item.post_render == PostRenderAction::ImportAndReplace; let comp = item.comp; diff --git a/crates/engine/src/tests_menu_cmds.rs b/crates/engine/src/tests_menu_cmds.rs index 040c494e..13087ca3 100644 --- a/crates/engine/src/tests_menu_cmds.rs +++ b/crates/engine/src/tests_menu_cmds.rs @@ -487,7 +487,7 @@ fn frontend_commands_emit_events_and_stubs_are_disabled() { let ev = frontend_events(&mut s); assert_eq!(ev[0].0, "view.zoomIn"); assert_eq!(ev[1], ("window.panel".to_string(), json!({"panel": "align"}))); - for id in ["file.createProxy", "camera.stereoRig", "view.displayColorManagement", "view.3d.default", "layer.create", "track.motion"] { + for id in ["keys.audioToKeyframes", "camera.stereoRig", "view.displayColorManagement", "view.3d.default", "layer.create", "track.motion"] { assert!(!s.is_enabled(id), "{id}"); assert!(matches!(s.execute(id, json!({})), Err(EngineError::Disabled(..))), "{id}"); } diff --git a/crates/engine/src/tests_proxy.rs b/crates/engine/src/tests_proxy.rs new file mode 100644 index 00000000..83ae59be --- /dev/null +++ b/crates/engine/src/tests_proxy.rs @@ -0,0 +1,275 @@ +//! Proxies (Set Proxy, Use Proxy, Proxy Use render settings, comp proxies, Create Proxy) and +//! Interpret Footage (fields, pixel aspect, alpha guess / invert, linear light), rendered through +//! a synthetic footage source. + +use std::sync::Arc; + +use effectcraft_project::render_queue::ProxyUse; +use effectcraft_project::{AlphaMode, Footage, FootageKind, ItemId, ItemKind}; +use effectcraft_raster::Image; +use effectcraft_time::{FrameRate, Tick}; +use serde_json::json; + +use crate::Session; +use crate::render::{FootageSource, RenderOpts}; + +/// Files by name: `red16.png` (16×16 red), `blue8.png` (8×8 blue), `lines.mov` (interlaced: +/// even rows red, odd rows blue; 4×4, 10 fps), `matte_black.png` / `matte_white.png` (a +/// semi-transparent green premultiplied against black / white, read as straight), `half.png` +/// (white at 25 % alpha), `wide.png` (10×10 white). +struct Synthetic; + +fn file_px(path: &str, x: u32, y: u32) -> [f32; 4] { + let name = std::path::Path::new(path).file_name().and_then(|n| n.to_str()).unwrap_or(""); + match name { + "red16.png" => [1.0, 0.0, 0.0, 1.0], + "blue8.png" => [0.0, 0.0, 1.0, 1.0], + "lines.mov" => { + if y.is_multiple_of(2) { + [1.0, 0.0, 0.0, 1.0] + } else { + [0.0, 0.0, 1.0, 1.0] + } + } + // Straight interpretation of a file whose colour is green·a + matte·(1 − a), a = 0.5. + "matte_black.png" => [0.0, 0.5, 0.0, 0.5], + "matte_white.png" => [0.5, 1.0, 0.5, 0.5], + "half.png" => [1.0, 1.0, 1.0, 0.25], + _ => { + let _ = x; + [1.0, 1.0, 1.0, 1.0] + } + } +} + +fn size_of(path: &str) -> (u32, u32) { + match std::path::Path::new(path).file_name().and_then(|n| n.to_str()).unwrap_or("") { + "red16.png" => (16, 16), + "blue8.png" => (8, 8), + "lines.mov" | "matte_black.png" | "matte_white.png" | "half.png" => (4, 4), + _ => (10, 10), + } +} + +impl FootageSource for Synthetic { + fn frame(&self, _: ItemId, f: &Footage, _t: Tick) -> Option> { + let (w, h) = size_of(&f.path); + let mut img = Image::new(w, h); + for y in 0..h { + for x in 0..w { + // Decoders hand out premultiplied pixels (straight file colour × alpha). + let p = file_px(&f.path, x, y); + img.set(x, y, [p[0] * p[3], p[1] * p[3], p[2] * p[3], p[3]]); + } + } + Some(Arc::new(img)) + } +} + +struct Probe; +impl crate::Importer for Probe { + fn probe(&self, path: &str) -> Result { + let (w, h) = size_of(path); + let video = path.ends_with(".mov"); + Ok(Footage { + path: path.into(), + kind: if video { FootageKind::Video } else { FootageKind::Still }, + width: w, + height: h, + has_video: true, + frame_rate: FrameRate::from_f64(10.0), + duration: Tick::from_seconds_f64(if video { 2.0 } else { 0.0 }), + ..Default::default() + }) + } +} + +fn session() -> Session { + Session { footage: Arc::new(Synthetic), importer: Some(Arc::new(Probe)), ..Default::default() } +} + +fn import(s: &mut Session, path: &str) -> ItemId { + ItemId(s.execute("file.import", json!({"paths": [path]})).unwrap()["items"][0].as_u64().unwrap()) +} + +fn px(s: &Session, comp: ItemId, t: f64, opts: RenderOpts, x: i64, y: i64) -> [f32; 4] { + s.render(comp, Tick::from_seconds_f64(t), opts).get(x, y) +} + +fn red(p: [f32; 4]) -> bool { + p[0] > 0.99 && p[2] < 0.01 && p[3] > 0.99 +} +fn blue(p: [f32; 4]) -> bool { + p[2] > 0.99 && p[0] < 0.01 && p[3] > 0.99 +} + +#[test] +fn footage_proxy_follows_use_proxy_and_proxy_use() { + let mut s = session(); + let full = import(&mut s, "/m/red16.png"); + s.execute("comp.new", json!({"name": "C", "width": 16, "height": 16, "frameRate": 10, "duration": 1})).unwrap(); + let c = s.active_comp_id().unwrap(); + s.execute("layer.addItem", json!({"item": full.0})).unwrap(); + let o = RenderOpts::default(); + assert!(red(px(&s, c, 0.0, o, 8, 8))); + s.execute("file.setProxy", json!({"item": full.0, "path": "/m/blue8.png"})).unwrap(); + // The 8×8 proxy fills the 16×16 footage frame. + for (x, y) in [(1, 1), (8, 8), (14, 14)] { + assert!(blue(px(&s, c, 0.0, o, x, y)), "proxy at {x},{y}"); + } + assert!(s.project.item(full).unwrap().proxy.as_ref().unwrap().enabled); + // Render Settings ▸ Proxy Use. + assert!(red(px(&s, c, 0.0, RenderOpts { proxy: ProxyUse::UseNone, ..o }, 8, 8))); + assert!(red(px(&s, c, 0.0, RenderOpts { proxy: ProxyUse::UseCompOnly, ..o }, 8, 8))); + // Use Proxy off: the viewer shows the source, Use All Proxies still uses it. + s.execute("file.useProxy", json!({"item": full.0})).unwrap(); + assert!(!s.project.item(full).unwrap().proxy.as_ref().unwrap().enabled); + assert!(red(px(&s, c, 0.0, o, 8, 8))); + assert!(blue(px(&s, c, 0.0, RenderOpts { proxy: ProxyUse::UseAll, ..o }, 8, 8))); + // Undo / Set Proxy ▸ None. + s.execute("edit.undo", json!({})).unwrap(); + assert!(blue(px(&s, c, 0.0, o, 8, 8))); + s.execute("file.setProxyNone", json!({"item": full.0})).unwrap(); + assert!(s.project.item(full).unwrap().proxy.is_none()); + assert!(red(px(&s, c, 0.0, RenderOpts { proxy: ProxyUse::UseAll, ..o }, 8, 8))); + // Render queue items carry Proxy Use (Best Settings: none). + let r = s.execute("renderQueue.add", json!({"proxyUse": "all"})).unwrap(); + assert_eq!(s.project.render_queue[0].settings.proxy_use, ProxyUse::UseAll, "{r}"); + assert_eq!(effectcraft_project::render_queue::RenderSettings::default().proxy_use, ProxyUse::UseNone); +} + +#[test] +fn comp_proxy_and_create_proxy() { + let mut s = session(); + s.execute("comp.new", json!({"name": "Inner", "width": 16, "height": 16, "frameRate": 10, "duration": 1})).unwrap(); + let inner = s.active_comp_id().unwrap(); + s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap(); + s.execute("comp.new", json!({"name": "Outer", "width": 16, "height": 16, "frameRate": 10, "duration": 1})).unwrap(); + let outer = s.active_comp_id().unwrap(); + s.execute("layer.addItem", json!({"item": inner.0})).unwrap(); + let o = RenderOpts::default(); + assert!(red(px(&s, outer, 0.0, o, 8, 8))); + s.execute("file.setProxy", json!({"item": inner.0, "path": "/m/blue8.png"})).unwrap(); + assert!(blue(px(&s, outer, 0.0, o, 14, 1))); + assert!(blue(px(&s, outer, 0.0, RenderOpts { proxy: ProxyUse::UseCompOnly, ..o }, 8, 8))); + assert!(red(px(&s, outer, 0.0, RenderOpts { proxy: ProxyUse::UseNone, ..o }, 8, 8))); + s.execute("file.setProxyNone", json!({"item": inner.0})).unwrap(); + // Create Proxy queues a half-resolution render whose post-render action sets the proxy. + s.state.project_selection = vec![inner]; + let r = s.execute("file.createProxy", json!({"kind": "still"})).unwrap(); + let it = s.project.render_queue.last().unwrap().clone(); + assert_eq!(it.comp, inner); + assert_eq!(it.post_render, effectcraft_project::render_queue::PostRenderAction::SetProxy); + assert_eq!(it.settings.resolution, 0.5); + assert!(r["output"].as_str().unwrap().contains("Inner_proxy_"), "{r}"); + let m = s.execute("file.createProxy", json!({"kind": "movie", "comp": inner.0})).unwrap(); + assert!(m["output"].as_str().unwrap().ends_with("Inner_proxy.mov"), "{m}"); + // The post-render action (what a finished render runs) attaches the file. + let id = it.id; + s.post_render_action(id, "/m/blue8.png").unwrap(); + assert_eq!(s.project.item(inner).unwrap().proxy.as_ref().unwrap().footage.path, "/m/blue8.png"); + assert!(blue(px(&s, outer, 0.0, o, 8, 8))); +} + +#[test] +fn interpret_proxy_settings_apply_to_the_proxy() { + let mut s = session(); + let full = import(&mut s, "/m/red16.png"); + s.execute("file.setProxy", json!({"item": full.0, "path": "/m/half.png"})).unwrap(); + s.execute("file.interpretProxy", json!({"item": full.0, "alpha": "ignore", "invertAlpha": true})).unwrap(); + let px = s.project.item(full).unwrap().proxy.clone().unwrap(); + assert_eq!(px.footage.alpha, AlphaMode::Ignore); + assert!(px.footage.invert_alpha); + let f = match &s.project.item(full).unwrap().kind { + ItemKind::Footage(f) => f.clone(), + _ => unreachable!(), + }; + assert_eq!(f.alpha, AlphaMode::Straight, "the main footage keeps its interpretation"); +} + +/// Separate Fields: each field becomes a frame at twice the frame rate, the other field's lines +/// interpolated; the dominant field comes first. +#[test] +fn separate_fields_make_frames_from_fields() { + let mut s = session(); + let mov = import(&mut s, "/m/lines.mov"); + s.execute("comp.new", json!({"name": "C", "width": 4, "height": 4, "frameRate": 20, "duration": 1})).unwrap(); + let c = s.active_comp_id().unwrap(); + s.execute("layer.addItem", json!({"item": mov.0})).unwrap(); + let o = RenderOpts::default(); + // Progressive: alternating rows. + assert!(red(px(&s, c, 0.0, o, 1, 0)) && blue(px(&s, c, 0.0, o, 1, 1))); + s.execute("file.interpretFootage", json!({"item": mov.0, "fields": "upper"})).unwrap(); + // Field 1 (upper = even rows, red) then field 2 (odd rows, blue), each 1/20 s. + for y in 0..3 { + assert!(red(px(&s, c, 0.0, o, 1, y)), "upper field at row {y}"); + assert!(blue(px(&s, c, 0.05, o, 1, y + 1)), "lower field at row {}", y + 1); + } + s.execute("file.interpretFootage", json!({"item": mov.0, "fields": "lower"})).unwrap(); + assert!(blue(px(&s, c, 0.0, o, 1, 2))); + assert!(red(px(&s, c, 0.05, o, 1, 1))); + let r = s.execute("file.interpretFootage", json!({"item": mov.0, "fields": "off"})).unwrap(); + assert_eq!(r["items"][0]["fields"], "Off"); +} + +/// Non-square pixels: a 10×10 footage with pixel aspect 2 covers 20×10 comp pixels. +#[test] +fn pixel_aspect_stretches_footage_in_the_comp() { + let mut s = session(); + let w = import(&mut s, "/m/wide.png"); + s.execute("comp.new", json!({"name": "C", "width": 40, "height": 20, "frameRate": 10, "duration": 1})).unwrap(); + let c = s.active_comp_id().unwrap(); + s.execute("layer.addItem", json!({"item": w.0})).unwrap(); + let o = RenderOpts::default(); + let covered = |s: &Session| s.render(c, Tick::ZERO, o).data.iter().filter(|p| p[3] > 0.5).count(); + assert_eq!(covered(&s), 100); + s.execute("file.interpretFootage", json!({"item": w.0, "pixelAspect": 2.0})).unwrap(); + assert_eq!(covered(&s), 200); + // Centered on the comp: x 10..30 (edge pixels are filtered). + assert!(px(&s, c, 0.0, o, 11, 10)[3] > 0.99 && px(&s, c, 0.0, o, 28, 10)[3] > 0.99); + assert!(px(&s, c, 0.0, o, 8, 10)[3] < 0.01 && px(&s, c, 0.0, o, 31, 10)[3] < 0.01); + // A comp with the same pixel aspect shows the footage 1:1. + s.execute("comp.settings", json!({"pixelAspect": 2.0})).unwrap(); + assert_eq!(covered(&s), 100); +} + +/// Alpha ▸ Guess: premultiplied files are recognised with their matte colour; Invert Alpha +/// and Interpret As Linear Light change the decoded pixels. +#[test] +fn guess_alpha_invert_and_linear_light() { + let mut s = session(); + let b = import(&mut s, "/m/matte_black.png"); + let w = import(&mut s, "/m/matte_white.png"); + let h = import(&mut s, "/m/half.png"); + let foot = |s: &Session, i: ItemId| match &s.project.item(i).unwrap().kind { + ItemKind::Footage(f) => f.clone(), + _ => unreachable!(), + }; + s.execute("file.interpretFootage", json!({"items": [b.0, w.0, h.0], "alpha": "guess"})).unwrap(); + assert_eq!(foot(&s, b).alpha, AlphaMode::Premultiplied); + assert_eq!(foot(&s, b).premul_color, [0.0; 3]); + assert_eq!(foot(&s, w).alpha, AlphaMode::Premultiplied); + assert_eq!(foot(&s, w).premul_color, [1.0; 3]); + // White at 25 %: not explained by a black matte (1 > 0.25) but it is by white… either way + // the guess is consistent with the pixels. + assert!(matches!(foot(&s, h).alpha, AlphaMode::Straight | AlphaMode::Premultiplied)); + // An explicit matte colour. + s.execute("file.interpretFootage", json!({"item": b.0, "alpha": "premultiplied", "matteColor": "#808080"})).unwrap(); + assert!((foot(&s, b).premul_color[0] - 128.0 / 255.0).abs() < 1e-3); + // Invert Alpha: 25 % → 75 %. + s.execute("file.interpretFootage", json!({"item": h.0, "alpha": "straight", "invertAlpha": true})).unwrap(); + s.execute("comp.new", json!({"name": "C", "width": 4, "height": 4, "frameRate": 10, "duration": 1})).unwrap(); + let c = s.active_comp_id().unwrap(); + s.execute("layer.addItem", json!({"item": h.0})).unwrap(); + let p = px(&s, c, 0.0, RenderOpts::default(), 1, 1); + assert!((p[3] - 0.75).abs() < 0.01 && p[0] > 0.7, "{p:?}"); + // Interpret As Linear Light keeps alpha and re-encodes the colour. + s.execute("file.interpretFootage", json!({"item": h.0, "invertAlpha": false, "linearLight": true})).unwrap(); + let p = px(&s, c, 0.0, RenderOpts::default(), 1, 1); + assert!((p[3] - 0.25).abs() < 0.01, "{p:?}"); + // Remember / Apply Interpretation carries the new settings. + s.state.project_selection = vec![h]; + s.execute("file.rememberInterpretation", json!({"item": h.0})).unwrap(); + s.execute("file.applyInterpretation", json!({"items": [w.0]})).unwrap(); + assert!(foot(&s, w).linear_light); +} diff --git a/crates/engine/src/tests_stubs.rs b/crates/engine/src/tests_stubs.rs index 5cb70246..924fb579 100644 --- a/crates/engine/src/tests_stubs.rs +++ b/crates/engine/src/tests_stubs.rs @@ -332,7 +332,7 @@ fn implemented_stubs_are_enabled_and_workspaces_are_frontend() { assert!(s.drain_events().iter().any(|e| matches!(e, Event::Frontend { command, .. } if command == id))); } // What remains a stub is still disabled. - for id in ["view.displayColorManagement", "file.createProxy", "track.camera"] { + for id in ["view.displayColorManagement", "keys.audioToKeyframes", "track.camera"] { assert!(!s.is_enabled(id), "{id}"); } } diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index dc4c35c8..86568871 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -176,6 +176,7 @@ pub(crate) fn render_frame(job: &Job, comp: &Comp, i: u64) -> Image { view: None, backend: effectcraft_render::Backend::Auto, roi: None, + proxy: job.settings.proxy_use, }; let mut r = Renderer::new(job.project, job.footage, opts); r.expr = job.expr; diff --git a/crates/host/src/lib.rs b/crates/host/src/lib.rs index dccf1aac..b90a42f0 100644 --- a/crates/host/src/lib.rs +++ b/crates/host/src/lib.rs @@ -297,6 +297,59 @@ mod tests { assert!(px[1] > 0.99 && px[0] < 0.01, "the box is filled with the sampled green: {px:?}"); } + /// Proxies through the real media and export layers: Render Settings ▸ Proxy Use decides + /// whether an export uses the proxy, and Create Proxy renders and attaches one. + #[test] + fn proxies_in_exports_and_create_proxy() { + let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/test-out/host-proxy"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let p = |n: &str| dir.join(n).to_string_lossy().to_string(); + let mut s = super::session(); + // Source and proxy stills, written by the app itself. + s.execute("comp.new", json!({"name": "Red", "width": 16, "height": 16, "frameRate": 10, "duration": 1})).unwrap(); + let red = s.active_comp_id().unwrap(); + s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap(); + s.execute("comp.saveFrameAs", json!({"path": p("red.png")})).unwrap(); + s.execute("comp.new", json!({"name": "Blue", "width": 8, "height": 8, "frameRate": 10, "duration": 1})).unwrap(); + s.execute("layer.newSolid", json!({"color": "#0000ff"})).unwrap(); + s.execute("comp.saveFrameAs", json!({"path": p("blue.png")})).unwrap(); + let foot = s.execute("file.import", json!({"paths": [p("red.png")]})).unwrap()["items"][0].as_u64().unwrap(); + s.execute("file.setProxy", json!({"item": foot, "path": p("blue.png")})).unwrap(); + s.execute("comp.new", json!({"name": "Out", "width": 16, "height": 16, "frameRate": 10, "duration": 1})).unwrap(); + let out = s.active_comp_id().unwrap(); + s.execute("layer.addItem", json!({"item": foot})).unwrap(); + // Export one frame with each Proxy Use. + for (name, using) in [("all", "use_all_[#].png"), ("none", "use_none_[#].png")] { + s.execute( + "renderQueue.add", + json!({"comp": out.0, "format": "png", "output": p(using), "timeSpan": "custom", "start": 0.0, "end": 0.1, "proxyUse": name}), + ) + .unwrap(); + } + s.execute("renderQueue.render", json!({"wait": true})).unwrap(); + s.poll_render(); + let centre = |s: &mut effectcraft_engine::Session, path: &str| { + let id = s.execute("file.import", json!({"paths": [path]})).unwrap()["items"][0].as_u64().unwrap(); + s.execute("comp.new", json!({"name": "Probe", "width": 16, "height": 16, "frameRate": 10, "duration": 1})).unwrap(); + s.execute("layer.addItem", json!({"item": id})).unwrap(); + let c = s.active_comp_id().unwrap(); + s.render(c, Default::default(), Default::default()).get(8, 8) + }; + let all = centre(&mut s, &p("use_all_0.png")); + assert!(all[2] > 0.99 && all[0] < 0.01, "Use All Proxies renders the blue proxy: {all:?}"); + let none = centre(&mut s, &p("use_none_0.png")); + assert!(none[0] > 0.99 && none[2] < 0.01, "Use No Proxies renders the red source: {none:?}"); + // Create Proxy ▸ Still: rendered at half size and attached to the comp. + s.execute("file.createProxy", json!({"kind": "still", "comp": red.0, "path": p("red_proxy_[#####].png")})).unwrap(); + s.execute("renderQueue.render", json!({"wait": true})).unwrap(); + s.poll_render(); + let px = s.project.item(red).unwrap().proxy.clone().expect("Create Proxy attached the rendered still"); + assert!(px.enabled); + assert_eq!((px.footage.width, px.footage.height), (8, 8)); + assert!(std::path::Path::new(&px.footage.path).exists(), "{}", px.footage.path); + } + #[test] fn expression_syntax_errors_disable_the_expression() { let mut s = super::session(); diff --git a/crates/project/src/render_queue.rs b/crates/project/src/render_queue.rs index c41462b5..31537a5b 100644 --- a/crates/project/src/render_queue.rs +++ b/crates/project/src/render_queue.rs @@ -39,6 +39,51 @@ pub enum TimeSpan { Custom { start: Tick, end: Tick }, } +/// Render Settings ▸ Proxy Use: which proxies a render uses. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum ProxyUse { + /// Each item's Use Proxy switch (what the viewer shows). + #[default] + CurrentSettings, + /// Every item that has a proxy uses it. + UseAll, + /// Only compositions' proxies. + UseCompOnly, + /// No proxies (full-resolution sources). + UseNone, +} + +impl ProxyUse { + pub const ALL: [ProxyUse; 4] = [ProxyUse::CurrentSettings, ProxyUse::UseAll, ProxyUse::UseCompOnly, ProxyUse::UseNone]; + pub fn label(self) -> &'static str { + match self { + ProxyUse::CurrentSettings => "Current Settings", + ProxyUse::UseAll => "Use All Proxies", + ProxyUse::UseCompOnly => "Use Comp Proxies Only", + ProxyUse::UseNone => "Use No Proxies", + } + } + pub fn parse(s: &str) -> Option { + match s.to_ascii_lowercase().replace([' ', '_', '-'], "").as_str() { + "current" | "currentsettings" => Some(ProxyUse::CurrentSettings), + "all" | "useall" | "useallproxies" => Some(ProxyUse::UseAll), + "comp" | "componly" | "usecompproxiesonly" | "usecomponly" => Some(ProxyUse::UseCompOnly), + "none" | "usenone" | "usenoproxies" => Some(ProxyUse::UseNone), + _ => None, + } + } + /// Whether an item's proxy is used: `comp` = the item is a composition, `enabled` = its Use + /// Proxy switch. + pub fn uses(self, comp: bool, enabled: bool) -> bool { + match self { + ProxyUse::CurrentSettings => enabled, + ProxyUse::UseAll => true, + ProxyUse::UseCompOnly => comp, + ProxyUse::UseNone => false, + } + } +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct RenderSettings { @@ -52,6 +97,8 @@ pub struct RenderSettings { pub motion_blur: bool, /// Image sequences: skip frames whose file already exists. pub skip_existing: bool, + /// Proxy Use (Best Settings: Use No Proxies). + pub proxy_use: ProxyUse, } impl Default for RenderSettings { @@ -63,6 +110,7 @@ impl Default for RenderSettings { frame_rate: None, motion_blur: true, skip_existing: false, + proxy_use: ProxyUse::UseNone, } } } @@ -419,6 +467,8 @@ pub enum PostRenderAction { Import, /// Import it and replace every use of the rendered composition (Composition ▸ Pre-render). ImportAndReplace, + /// Set the rendered file as the composition's proxy (File ▸ Create Proxy). + SetProxy, } impl PostRenderAction { @@ -430,6 +480,7 @@ impl PostRenderAction { PostRenderAction::None => "None", PostRenderAction::Import => "Import", PostRenderAction::ImportAndReplace => "Import & Replace Usage", + PostRenderAction::SetProxy => "Set Proxy", } } } diff --git a/crates/render/src/cache.rs b/crates/render/src/cache.rs index 8cd27d59..ca850a86 100644 --- a/crates/render/src/cache.rs +++ b/crates/render/src/cache.rs @@ -286,6 +286,8 @@ fn key_with(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, footage: bool let it = ctx.project.item(*item)?; let ItemKind::Footage(f) = &it.kind else { return None }; hash_debug(&mut h, f); + // A proxy (and its Use Proxy switch) changes the pixels. + hash_debug(&mut h, &it.proxy); item.hash(&mut h); ctx.source_time(layer).0.hash(&mut h); // Frame blending mixes neighbouring source frames. diff --git a/crates/render/src/eval.rs b/crates/render/src/eval.rs index aff4d93c..d8d3f641 100644 --- a/crates/render/src/eval.rs +++ b/crates/render/src/eval.rs @@ -120,6 +120,28 @@ impl<'a> EvalCtx<'a> { /// Local (parent-space) transform of a layer. pub fn local_matrix(&self, layer: &Layer) -> Mat4 { + self.local_matrix_par(layer, 1.0) + } + + /// Source pixel aspect relative to the comp's: non-square footage (or solids, or precomps + /// with another pixel aspect) is stretched horizontally by this factor in the comp. + pub fn par_ratio(&self, layer: &Layer) -> f64 { + let par = match &layer.source { + LayerSource::Footage { item } | LayerSource::Solid { item } | LayerSource::Comp { item } => match self.project.item(*item).map(|i| &i.kind) { + Some(effectcraft_project::ItemKind::Footage(f)) => f.pixel_aspect, + Some(effectcraft_project::ItemKind::Solid(s)) => s.pixel_aspect, + Some(effectcraft_project::ItemKind::Comp(c)) => c.pixel_aspect, + _ => 1.0, + }, + _ => 1.0, + }; + let r = par / if self.comp.pixel_aspect > 0.0 { self.comp.pixel_aspect } else { 1.0 }; + if r.is_finite() && r > 0.0 && (r - 1.0).abs() > 1e-9 { r } else { 1.0 } + } + + /// [`Self::local_matrix`] with the source's pixel aspect folded into the scale (`par`; 1 for + /// a parent's matrix, which children don't inherit). + fn local_matrix_par(&self, layer: &Layer, par: f64) -> Mat4 { let Some(tr) = layer.transform() else { return Mat4::IDENTITY }; let three = layer.is_3d(); let pos = self.position(layer, tr); @@ -130,6 +152,7 @@ impl<'a> EvalCtx<'a> { } let anchor = self.v3(layer, tr, "anchor", [0.0; 3]); let mut scale = self.v3(layer, tr, "scale", [100.0; 3]); + scale[0] *= par; if !three { scale[2] = 100.0; let a = Vec3::from([anchor[0], anchor[1], 0.0]); @@ -154,7 +177,7 @@ impl<'a> EvalCtx<'a> { /// Layer space → comp (world) space, including parents. pub fn world_matrix(&self, layer: &Layer) -> Mat4 { - let mut m = self.local_matrix(layer); + let mut m = self.local_matrix_par(layer, self.par_ratio(layer)); let mut cur = layer.parent; let mut guard = 0; while let Some(pid) = cur { diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 6699d68d..01f5f96a 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -247,11 +247,22 @@ pub struct RenderOpts { /// Region of interest `[x, y, w, h]` in comp pixels (viewer only): the top-level frame covers /// just this rectangle, and only its pixels are composited. pub roi: Option<[f64; 4]>, + /// Which proxies stand in for footage and compositions (Render Settings ▸ Proxy Use). + pub proxy: effectcraft_project::render_queue::ProxyUse, } impl Default for RenderOpts { fn default() -> Self { - RenderOpts { scale: 1.0, motion_blur: true, guides: false, draft: false, view: None, roi: None, backend: Backend::Cpu } + RenderOpts { + scale: 1.0, + motion_blur: true, + guides: false, + draft: false, + view: None, + roi: None, + backend: Backend::Cpu, + proxy: effectcraft_project::render_queue::ProxyUse::CurrentSettings, + } } } @@ -675,6 +686,13 @@ impl<'a> Renderer<'a> { if self.project.comp_contains(*item, ctx.comp_id) { return None; } + // A composition proxy (a rendered still or movie) stands in for the comp. + if let Some(pf) = self.proxy_for(*item) + && let Some(nc) = self.project.comp(*item) + { + let img = self.footage.frame(*item, pf, ctx.source_time(layer))?; + return Some(self.footage_buf(&img, pf, nc.width, nc.height)); + } // Essential Properties overrides render the nested comp with this instance's // values. let ov = essential_overrides(ctx, layer); @@ -706,20 +724,10 @@ impl<'a> Renderer<'a> { return None; } let lt = ctx.source_time(layer); - let img = self.footage_frame(ctx, layer, *item, f, lt)?; - let k = if img.width > 0 { f.width.max(1) as f64 / img.width as f64 } else { 1.0 }; - // Keep native pixels when downsampling is small; resample otherwise. - let mut buf = if s < 0.75 { - let w = ((f.width as f64 * s).round() as u32).max(1); - let h = ((f.height as f64 * s).round() as u32).max(1); - Buf { img: effectcraft_raster::resample(&img, w, h), offset: [0.0; 2], scale: s } - } else { - Buf { img: (*img).clone(), offset: [0.0; 2], scale: 1.0 / k } - }; - if let Some(c) = self.pipe.media_in(f.color_profile) { - color::convert(&mut buf.img, &c); - } - Some(buf) + // The proxy is decoded instead, at its own size, and fills the footage's frame. + let pf = self.proxy_for(*item).unwrap_or(f); + let img = self.footage_frame(ctx, layer, *item, pf, lt)?; + Some(self.footage_buf(&img, pf, f.width, f.height)) } LayerSource::Text => Some(self.authored(text::render(ctx, layer, self.raster_scale(ctx, layer)))), LayerSource::Shape => layer.props.sub("contents").map(|c| self.authored(shapes::render(ctx, layer, c, self.raster_scale(ctx, layer)))), @@ -727,6 +735,32 @@ impl<'a> Renderer<'a> { } } + /// The proxy footage used for `item` under this render's Proxy Use, if any. + pub fn proxy_for(&self, item: ItemId) -> Option<&'a Footage> { + let it = self.project.item(item)?; + let px = it.proxy.as_deref()?; + (self.opts.proxy.uses(matches!(it.kind, ItemKind::Comp(_)), px.enabled) && px.footage.has_video).then_some(&px.footage) + } + + /// A decoded frame of `f` as a layer buffer covering `w`×`h` layer pixels (the footage's + /// nominal size; proxies are scaled up to it), converted from its colour profile. + fn footage_buf(&self, img: &Image, f: &Footage, w: u32, h: u32) -> Buf { + let s = self.opts.scale; + let k = if img.width > 0 { w.max(1) as f64 / img.width as f64 } else { 1.0 }; + // Keep native pixels when downsampling is small; resample otherwise. + let mut buf = if s < 0.75 && k <= 1.0 + 1e-9 { + let rw = ((w as f64 * s).round() as u32).max(1); + let rh = ((h as f64 * s).round() as u32).max(1); + Buf { img: effectcraft_raster::resample(img, rw, rh), offset: [0.0; 2], scale: s } + } else { + Buf { img: img.clone(), offset: [0.0; 2], scale: 1.0 / k } + }; + if let Some(c) = self.pipe.media_in(f.color_profile) { + color::convert(&mut buf.img, &c); + } + buf + } + /// Authored colours into the working space (linear working spaces). fn authored(&self, mut buf: Buf) -> Buf { if let Some(c) = self.pipe.authored_in() { @@ -739,6 +773,21 @@ impl<'a> Renderer<'a> { /// when the layer's Frame Blending switch (and the comp's Enable Frame Blending) is on and /// `t` falls between frames (footage rate ≠ comp rate, time stretch, time remapping). fn footage_frame(&self, ctx: &EvalCtx, layer: &Layer, item: ItemId, f: &Footage, t: Tick) -> Option> { + // Interpret Footage ▸ Separate Fields: each field is a frame at twice the rate. + if f.fields != effectcraft_project::FieldOrder::Off && matches!(f.kind, FootageKind::Video | FootageKind::Sequence) { + let field_rate = FrameRate::new(f.frame_rate.num * 2, f.frame_rate.den); + let i = field_rate.frame_at(t).max(0); + let img = self.footage.frame(item, f, f.frame_rate.tick_of(i / 2))?; + let dominant_upper = f.fields == effectcraft_project::FieldOrder::UpperFirst; + // Upper field = even lines (0, 2, …). + let parity = if (i % 2 == 0) == dominant_upper { 0 } else { 1 }; + return Some(Arc::new(interpret_pixels(&field_frame(&img, parity), f))); + } + let img = self.footage_frame_raw(ctx, layer, item, f, t)?; + if f.invert_alpha || f.linear_light { Some(Arc::new(interpret_pixels(&img, f))) } else { Some(img) } + } + + fn footage_frame_raw(&self, ctx: &EvalCtx, layer: &Layer, item: ItemId, f: &Footage, t: Tick) -> Option> { let mode = frame_blend_mode(ctx, layer); if mode != FrameBlend::Off && matches!(f.kind, FootageKind::Video | FootageKind::Sequence) { let (i, w) = frame_position(t, f.frame_rate); @@ -1307,6 +1356,57 @@ impl<'a> Renderer<'a> { } /// The layer's frame blending mode, if the comp's Enable Frame Blending switch is on. +/// One field of an interlaced frame as a full frame: the field's lines (`parity` 0 = even / +/// upper, 1 = odd / lower) kept, the other lines interpolated from their neighbours. +pub fn field_frame(img: &Image, parity: usize) -> Image { + let (w, h) = (img.width as usize, img.height as usize); + let mut out = img.clone(); + if h < 2 { + return out; + } + for y in 0..h { + if y % 2 == parity { + continue; + } + let above = y.checked_sub(1); + let below = (y + 1 < h).then_some(y + 1); + for x in 0..w { + let px = match (above, below) { + (Some(a), Some(b)) => { + let (p, q) = (img.data[a * w + x], img.data[b * w + x]); + [(p[0] + q[0]) * 0.5, (p[1] + q[1]) * 0.5, (p[2] + q[2]) * 0.5, (p[3] + q[3]) * 0.5] + } + (Some(a), None) => img.data[a * w + x], + (None, Some(b)) => img.data[b * w + x], + (None, None) => img.data[y * w + x], + }; + out.data[y * w + x] = px; + } + } + out +} + +/// Interpret Footage ▸ Invert Alpha and Interpret As Linear Light on decoded (premultiplied) +/// pixels. +pub fn interpret_pixels(img: &Image, f: &Footage) -> Image { + if !f.invert_alpha && !f.linear_light { + return img.clone(); + } + let space = f.color_profile.unwrap_or(effectcraft_color::ColorSpace::Srgb); + let mut out = img.clone(); + for p in out.data.iter_mut() { + let a = p[3]; + let mut c = if a > 1e-6 { [p[0] / a, p[1] / a, p[2] / a] } else { [0.0; 3] }; + if f.linear_light { + // The file's values are linear light: encode them like the rest of the footage. + c = c.map(|v| space.encode(v)); + } + let a = if f.invert_alpha { 1.0 - a } else { a }; + *p = [c[0] * a, c[1] * a, c[2] * a, a]; + } + out +} + fn frame_blend_mode(ctx: &EvalCtx, layer: &Layer) -> FrameBlend { if ctx.comp.enable_frame_blending { layer.switches.frame_blend } else { FrameBlend::Off } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 071c1948..cc12a341 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -419,6 +419,7 @@ impl EffectcraftApp { view: self.session.view_camera(comp), roi, backend: effectcraft_engine::render::Backend::Auto, + proxy: Default::default(), }; if urgent { self.frames.request_urgent(&self.render_source(), key, comp, t, opts);