From decc8b72f96fdb00ea4386efe3203e0991f6d5ac Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 04:17:17 -0700 Subject: [PATCH 1/7] Advanced 3D: shadow jitter blends across its lattice cell, so GPU penumbrae match the CPU The PCF tap jitter was keyed on the receiver's 1/8-texel cell in the shadow map. The GPU computes the receiver's map position with last-bit differences from the CPU, so a receiver next to a cell line could take another cell's jitter on one backend, and every tap moved. In a wide penumbra that changed the coverage by several 8-bit levels: gpu_matches_cpu_wide_penumbrae failed on Windows with 15 of 4159 shadowed pixels on an NVIDIA RTX 5080 and 22 on an AMD Radeon iGPU, against a budget of 8. tap_jitter now blends the hashes at the four corners of the cell by the receiver's position in it, in shade.rs and in advanced3d.wgsl. At the lattice points the jitter is the per-cell hash it was before. A CPU test checks that the coverage does not jump at a lattice line. --- crates/gpu/src/shaders/advanced3d.wgsl | 37 ++++++++++++++++++++------ crates/render/src/three_d/adv/shade.rs | 32 +++++++++++++++++----- crates/render/src/three_d/adv/tests.rs | 20 ++++++++++++++ 3 files changed, 75 insertions(+), 14 deletions(-) diff --git a/crates/gpu/src/shaders/advanced3d.wgsl b/crates/gpu/src/shaders/advanced3d.wgsl index 4eea78c8c..182868423 100644 --- a/crates/gpu/src/shaders/advanced3d.wgsl +++ b/crates/gpu/src/shaders/advanced3d.wgsl @@ -194,6 +194,25 @@ fn tap_hash(x: i32, y: i32) -> u32 { return h; } +fn tap_jitter_at(x: i32, y: i32) -> vec2 { + let h = tap_hash(x, y); + return vec2(f32(h & 0xFFFFu) * (1.0 / 65536.0) - 0.5, f32((h >> 16u) & 0xFFFFu) * (1.0 / 65536.0) - 0.5); +} + +// shade::tap_jitter: the hash at the four corners of the receiver's 1/8-texel lattice +// cell (cx, cy), blended bilinearly by the receiver's position w in the cell. +fn tap_jitter(cx: i32, cy: i32, w: vec2, i: i32, j: i32) -> vec2 { + let x = cx + i * 7919; + let y = cy + j * 7919; + let c00 = tap_jitter_at(x, y); + let c10 = tap_jitter_at(x + 1, y); + let c01 = tap_jitter_at(x, y + 1); + let c11 = tap_jitter_at(x + 1, y + 1); + let a = c00 + (c10 - c00) * w.x; + let b = c01 + (c11 - c01) * w.x; + return a + (b - a) * w.y; +} + const PCF_MAX_TAPS: i32 = 10; const PLANE_SLOPE_MAX: f32 = 64.0; @@ -244,7 +263,8 @@ fn shadow_factor(m: u32, p: vec3, ng: vec3, n_l: f32) -> f32 { // shadows off their casters), each texel compared against the receiver plane's // depth at its centre so wide kernels never self-shadow a lit surface, an N×N grid // jittered by an integer hash (bit-identical across backends) of the sub-texel - // cell and tap index, and bilinear compares of the four nearest texel centres. + // lattice and tap index, blended across the receiver's lattice cell, and bilinear + // compares of the four nearest texel centers. // extra.y is the wanted penumbra in output pixels; dividing by the receiver's // texel footprint keeps it in output pixels at any map resolution, and the reach // is at least two texels so hard edges ramp over texel steps instead of showing them. @@ -268,16 +288,17 @@ fn shadow_factor(m: u32, p: vec3, ng: vec3, n_l: f32) -> f32 { let n = clamp(i32(ceil(reach)) + 1, 5, PCF_MAX_TAPS); let step = reach / f32(n - 1); let half = f32(n - 1) * 0.5; - let cx = i32(floor(u * 8.0)); - let cy = i32(floor(v * 8.0)); + let gu = u * 8.0; + let gv = v * 8.0; + let cx = i32(floor(gu)); + let cy = i32(floor(gv)); + let wc = vec2(gu - floor(gu), gv - floor(gv)); var lit = 0.0; for (var j = 0; j < n; j = j + 1) { for (var i = 0; i < n; i = i + 1) { - let h = tap_hash(cx + i * 7919, cy + j * 7919); - let jx = f32(h & 0xFFFFu) * (1.0 / 65536.0) - 0.5; - let jy = f32((h >> 16u) & 0xFFFFu) * (1.0 / 65536.0) - 0.5; - let su = u + ((f32(i) - half) + jx) * step; - let sv = v + ((f32(j) - half) + jy) * step; + let jit = tap_jitter(cx, cy, wc, i, j); + let su = u + ((f32(i) - half) + jit.x) * step; + let sv = v + ((f32(j) - half) + jit.y) * step; // Texel (x, y) holds the depth at its centre (x + 0.5, y + 0.5); each of the // four is compared against the receiver plane at that centre. let tu = su - 0.5; diff --git a/crates/render/src/three_d/adv/shade.rs b/crates/render/src/three_d/adv/shade.rs index c7c1a40f1..0168a769e 100644 --- a/crates/render/src/three_d/adv/shade.rs +++ b/crates/render/src/three_d/adv/shade.rs @@ -217,6 +217,25 @@ fn tap_hash(x: i32, y: i32) -> u32 { h } +/// The jitter of tap (`i`, `j`), each axis in [−0.5, 0.5): [`tap_hash`] values at the four +/// corners of the receiver's 1/8-texel lattice cell (`cx`, `cy`), blended bilinearly by the +/// receiver's position in the cell (`wx`, `wy`). The jitter is continuous in the receiver's +/// map position, so a position that differs between backends in its last bits moves the +/// taps by an amount proportional to that difference and does not switch them to another +/// cell's jitter. +fn tap_jitter(cx: i32, cy: i32, wx: f32, wy: f32, i: i32, j: i32) -> (f32, f32) { + let corner = |dx: i32, dy: i32| -> (f32, f32) { + let h = tap_hash(cx.wrapping_add(dx).wrapping_add(i.wrapping_mul(7919)), cy.wrapping_add(dy).wrapping_add(j.wrapping_mul(7919))); + (((h & 0xFFFF) as f32) * (1.0 / 65536.0) - 0.5, (((h >> 16) & 0xFFFF) as f32) * (1.0 / 65536.0) - 0.5) + }; + let (c00, c10, c01, c11) = (corner(0, 0), corner(1, 0), corner(0, 1), corner(1, 1)); + let ax = c00.0 + (c10.0 - c00.0) * wx; + let bx = c01.0 + (c11.0 - c01.0) * wx; + let ay = c00.1 + (c10.1 - c00.1) * wx; + let by = c01.1 + (c11.1 - c01.1) * wx; + (ax + (bx - ax) * wy, ay + (by - ay) * wy) +} + /// Upper bound of the PCF grid's taps per axis. The grid grows with the reach so taps /// stay about one texel apart for moderate penumbrae; the cap bounds the per-fragment /// cost of very wide ones (bilinear taps still overlap and the jitter dithers the rest). @@ -232,8 +251,9 @@ const PLANE_SLOPE_MAX: f32 = 64.0; /// /// PCF over an N×N tap grid (5 ≤ N ≤ [`PCF_MAX_TAPS`]) spread over a reach derived /// from `radius` (the wanted penumbra in output pixels) divided by the receiver's -/// texel footprint, each tap jittered by an integer hash of the receiver's -/// sub-texel cell and the tap index, and each tap comparing against the four +/// texel footprint, each tap jittered by [`tap_jitter`] (an integer hash of the +/// receiver's sub-texel lattice and the tap index, blended across the receiver's +/// lattice cell), and each tap comparing against the four /// nearest texel centres with bilinear weights. The jitter detaches the tap grid /// from the texel axes (axis-aligned binary taps are what stair-step a straight /// shadow edge), and keying it below texel size keeps the dither finer than an @@ -301,13 +321,13 @@ pub fn shadow_factor(m: &ShadowMap, texels: &[f32], p: [f32; 3], ng: [f32; 3], n let n = (reach.ceil() as i32 + 1).clamp(5, PCF_MAX_TAPS); let step = reach / (n - 1) as f32; let half = (n - 1) as f32 * 0.5; - let (cx, cy) = ((u * 8.0).floor() as i32, (v * 8.0).floor() as i32); + let (gu, gv) = (u * 8.0, v * 8.0); + let (cx, cy) = (gu.floor() as i32, gv.floor() as i32); + let (wx, wy) = (gu - gu.floor(), gv - gv.floor()); let mut lit = 0.0; for j in 0..n { for i in 0..n { - let h = tap_hash(cx.wrapping_add(i.wrapping_mul(7919)), cy.wrapping_add(j.wrapping_mul(7919))); - let jx = ((h & 0xFFFF) as f32) * (1.0 / 65536.0) - 0.5; - let jy = (((h >> 16) & 0xFFFF) as f32) * (1.0 / 65536.0) - 0.5; + let (jx, jy) = tap_jitter(cx, cy, wx, wy, i, j); let (su, sv) = (u + ((i as f32 - half) + jx) * step, v + ((j as f32 - half) + jy) * step); // Texel (x, y) holds the depth at its centre (x + 0.5, y + 0.5); each of the // four is compared against the receiver plane at that centre. diff --git a/crates/render/src/three_d/adv/tests.rs b/crates/render/src/three_d/adv/tests.rs index 3f5ee4813..f752406d7 100644 --- a/crates/render/src/three_d/adv/tests.rs +++ b/crates/render/src/three_d/adv/tests.rs @@ -887,6 +887,26 @@ fn penumbra_dithers_along_a_straight_edge() { assert!(distinct(&within) >= 4, "coverage inside one texel takes {} distinct values", distinct(&within)); } +#[test] +fn penumbra_jitter_is_continuous_across_its_lattice() { + // Receiver pairs 1/5000 texel either side of a line of the 1/8-texel jitter lattice, + // across u and across v, inside the penumbra. The GPU places a receiver within a few + // ulps of the CPU, so coverage that jumps at a lattice line differs between the two + // backends there; both sides of each line must agree within one 8-bit level. + let (m, texels) = wall_map(64, 1.0, 6.0); + let f = |p: [f32; 2]| shade::shadow_factor(&m, &texels, [p[0], p[1], 13.5], FACING, 1.0); + let mut worst = 0.0f32; + for k in 0..16 { + let line = k as f32 * 0.125; + for (a, b) in [([-16.0 + line - 2e-4, 0.3], [-16.0 + line + 2e-4, 0.3]), ([-16.3, line - 2e-4], [-16.3, line + 2e-4])] { + let (fa, fb) = (f(a), f(b)); + assert!(fa > 0.05 && fa < 0.95, "{a:?} lies in the penumbra: {fa}"); + worst = worst.max((fa - fb).abs()); + } + } + assert!(worst < 1.0 / 255.0, "coverage jumps by {worst} across a jitter lattice line"); +} + #[test] fn penumbra_width_is_independent_of_map_resolution() { // One wall (edge at world x = −16) in a 64-texel map at 1 texel per world unit and From 557c13b9cd26ea579105b93ac38c114bc225b598 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 09:45:52 -0700 Subject: [PATCH 2/7] Interpret Footage: the params docs list every key the commands read execute_checked refuses any key a command's params doc does not list, so MCP, the CLI and the control channel could not pass preserveRgb, overrideStart, startTimecode or guessAlpha to file.interpretProxy. The command reads all four, and the shared Interpret Footage form sends preserveRgb for every proxy, and overrideStart and startTimecode for a movie or sequence proxy. file.interpretProxy's doc now lists them. The docs of file.interpretProxy and file.interpretFootage list premulColor beside matteColor, which both commands read as the matte color. The docs of file.interpretFootage, file.applyInterpretation and file.reloadFootage list `item` beside `items`, which the three commands already read. --- crates/engine/src/commands/file_more.rs | 6 ++--- crates/engine/src/commands/proxy.rs | 2 +- crates/engine/src/tests_proxy.rs | 32 +++++++++++++++++++++++++ 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/commands/file_more.rs b/crates/engine/src/commands/file_more.rs index 74a86e1d8..5aade0a75 100644 --- a/crates/engine/src/commands/file_more.rs +++ b/crates/engine/src/commands/file_more.rs @@ -1035,7 +1035,7 @@ pub fn specs() -> Vec { "Main...", ["File", "Interpret Footage"], Some("Cmd+Alt+G"), - "{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?, preserveRgb? (the file's values unconverted), overrideStart?: bool (false: Use Source File Timecode), startTimecode?: timecode|frame (Override Start)}", + "{items?|item?, frameRate?: fps|\"file\", alpha?: straight|premultiplied|ignore|guess, guessAlpha?, matteColor?|premulColor?, invertAlpha?, loop?, pixelAspect?, fields?: off|upper|lower, colorProfile?: srgb|rec709|rec2020|p3|auto, linearLight?, preserveRgb? (the file's values unconverted), overrideStart?: bool (false: Use Source File Timecode), startTimecode?: timecode|frame (Override Start)}", has_footage_selection, interpret ), @@ -1053,7 +1053,7 @@ pub fn specs() -> Vec { "Apply Interpretation", ["File", "Interpret Footage"], Some("Cmd+Alt+V"), - "{items?}", + "{items?|item?}", has_interpretation, apply_interpretation ), @@ -1084,7 +1084,7 @@ pub fn specs() -> Vec { has_footage_selection, replace_with_solid ), - cmd!("file.reloadFootage", "Reload Footage", ["File"], Some("Cmd+Alt+L"), "{items?}", has_footage_selection, reload), + cmd!("file.reloadFootage", "Reload Footage", ["File"], Some("Cmd+Alt+L"), "{items?|item?}", has_footage_selection, reload), cmd!("file.revealInFinder", "Reveal in Finder", ["File"], None, "{item?}", has_footage_selection, reveal_in_finder), ] } diff --git a/crates/engine/src/commands/proxy.rs b/crates/engine/src/commands/proxy.rs index b7d644b94..d15f93730 100644 --- a/crates/engine/src/commands/proxy.rs +++ b/crates/engine/src/commands/proxy.rs @@ -229,7 +229,7 @@ pub fn specs() -> Vec { "Proxy...", ["File", "Interpret Footage"], None, - "{items?|item?, alpha?, matteColor?, invertAlpha?, frameRate?, loop?, pixelAspect?, fields?, colorProfile?, linearLight?}", + "{items?|item?, alpha?, guessAlpha?, matteColor?|premulColor?, invertAlpha?, frameRate?, loop?, pixelAspect?, fields?, colorProfile?, linearLight?, preserveRgb?, overrideStart?, startTimecode? (as file.interpretFootage)}", has_proxy, interpret_proxy ), diff --git a/crates/engine/src/tests_proxy.rs b/crates/engine/src/tests_proxy.rs index 82cd1dcfd..04372685f 100644 --- a/crates/engine/src/tests_proxy.rs +++ b/crates/engine/src/tests_proxy.rs @@ -187,6 +187,38 @@ fn interpret_proxy_settings_apply_to_the_proxy() { assert_eq!(f.alpha, AlphaMode::Straight, "the main footage keeps its interpretation"); } +/// Agents and the control channel run commands through `execute_checked`, which refuses keys a +/// params doc does not list. The Interpret Footage commands list every key they read. +#[test] +fn interpret_commands_accept_their_keys_when_checked() { + let mut s = session(); + let mov = import(&mut s, "/m/lines.mov"); + let b = import(&mut s, "/m/red16.png"); + s.execute("file.setProxy", json!({"item": mov.0, "path": "/m/lines.mov"})).unwrap(); + s.execute("file.setProxy", json!({"item": b.0, "path": "/m/matte_black.png"})).unwrap(); + s.execute_checked("file.interpretProxy", json!({"item": mov.0, "preserveRgb": true, "overrideStart": true, "startTimecode": 25})).unwrap(); + let px = s.project.item(mov).unwrap().proxy.clone().unwrap().footage; + assert!(px.preserve_rgb); + assert_eq!(px.start_timecode, Some(25)); + s.execute_checked("file.interpretProxy", json!({"item": mov.0, "overrideStart": false})).unwrap(); + assert_eq!(s.project.item(mov).unwrap().proxy.as_ref().unwrap().footage.start_timecode, None); + s.execute_checked("file.interpretProxy", json!({"item": b.0, "guessAlpha": true})).unwrap(); + assert_eq!(s.project.item(b).unwrap().proxy.as_ref().unwrap().footage.alpha, AlphaMode::Premultiplied); + s.execute_checked("file.interpretProxy", json!({"item": b.0, "premulColor": [1.0, 0.0, 0.0]})).unwrap(); + assert_eq!(s.project.item(b).unwrap().proxy.as_ref().unwrap().footage.premul_color, [1.0, 0.0, 0.0]); + // `item` names one footage item, as `items` names several. + s.execute_checked("file.interpretFootage", json!({"item": b.0, "invertAlpha": true, "premulColor": [0.0, 1.0, 0.0]})).unwrap(); + s.execute_checked("file.rememberInterpretation", json!({"item": b.0})).unwrap(); + s.execute_checked("file.applyInterpretation", json!({"item": mov.0})).unwrap(); + let f = match &s.project.item(mov).unwrap().kind { + ItemKind::Footage(f) => f.clone(), + _ => panic!("not footage"), + }; + assert!(f.invert_alpha); + assert_eq!(f.premul_color, [0.0, 1.0, 0.0]); + assert_eq!(s.execute_checked("file.reloadFootage", json!({"item": mov.0})).unwrap()["reloaded"], 1); +} + /// Separate Fields: each field becomes a frame at twice the frame rate, the other field's lines /// interpolated; the dominant field comes first. #[test] From 0cb05a601bd95d3ad451b9e7887e9c6a7825982e Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 13:24:17 -0700 Subject: [PATCH 3/7] Animation presets: effects saved by an earlier version are upgraded on apply, as in a project Opening a project runs migrate::upgrade_instance on every effect, so an instance saved before a parameter was renamed, moved into a twirl-down group, added or had its popup reordered takes the current shape. Apply Animation Preset added a preset's effects as saved, without that upgrade. A Change to Color saved with its single Tolerance applied with that old property and without the Tolerance group, so the effect did not read the saved tolerance. apply_preset now upgrades each preset effect before adding it, sized by the target layer's effect bounds as upgrade_effects does. A lone effect property saved at a renamed or moved parameter id (effects/ec.color.changetocolor/tolerance) is rewritten to the current id through migrate::current_param_id, so it is applied instead of being listed in skipped. --- crates/effects/src/migrate.rs | 32 +++++++++++++++++++++++++ crates/engine/src/commands/animation.rs | 30 ++++++++++++++++++++++- crates/engine/src/tests_effects.rs | 31 ++++++++++++++++++++++++ 3 files changed, 92 insertions(+), 1 deletion(-) diff --git a/crates/effects/src/migrate.rs b/crates/effects/src/migrate.rs index b5976b761..35d0960e0 100644 --- a/crates/effects/src/migrate.rs +++ b/crates/effects/src/migrate.rs @@ -344,6 +344,23 @@ pub fn upgrade_instance(spec: &EffectSpec, g: &mut PropGroup, ids: &mut Ids, lay *g != before } +/// The spec id of the parameter that an instance of `spec` saved at `id` (`a/b/param`) is now: +/// `id` itself when the spec still has it, the new id of a renamed parameter +/// ([`PARAM_ID_ALIASES`]), or the twirl-down path of a top-level parameter that moved into a +/// group, as [`upgrade_instance`] finds them. Returns `None` when no parameter matches. +pub fn current_param_id(spec: &EffectSpec, id: &str) -> Option<&'static str> { + if let Some(ps) = spec.params.iter().find(|ps| ps.id == id) { + return Some(ps.id); + } + if let Some((_, _, new)) = PARAM_ID_ALIASES.iter().find(|(e, old, _)| *e == spec.id && *old == id) { + return Some(new); + } + if id.contains('/') { + return None; + } + spec.params.iter().find(|ps| ps.id.rsplit('/').next() == Some(id)).map(|ps| ps.id) +} + /// Bring an effect instance saved before project schema 2 into effect space (#227). /// /// Layers without a source rectangle (shape, text) have comp-sized effect bounds centred on @@ -559,6 +576,21 @@ mod tests { assert_eq!(prop_at(&mut g, "stabilization/smoothness").unwrap().value, Value::Scalar(12.0)); } + /// A saved parameter id maps to the parameter `upgrade_instance` moves it to. + #[test] + fn current_param_id_follows_renames_and_twirl_down_moves() { + let stab = find(crate::warp_stab::ID).unwrap(); + assert_eq!(current_param_id(stab, "stabilization/smoothness"), Some("stabilization/smoothness")); + assert_eq!(current_param_id(stab, "smoothness"), Some("stabilization/smoothness")); + let ctc = find("ec.color.changetocolor").unwrap(); + assert_eq!(current_param_id(ctc, "tolerance"), Some("toleranceGroup/hue")); + // Radio Waves' old Velocity is the growth speed, not the new Velocity. + let waves = find("ec.generate.radiowaves").unwrap(); + assert_eq!(current_param_id(waves, "velocity"), Some("waveMotion/expansion")); + assert_eq!(current_param_id(stab, "noSuchParam"), None); + assert_eq!(current_param_id(stab, "other/smoothness"), None); + } + #[test] fn card_wipe_back_layer_popup_becomes_a_layer_parameter() { let spec = find("ec.transition.cardwipe").unwrap(); diff --git a/crates/engine/src/commands/animation.rs b/crates/engine/src/commands/animation.rs index 1bdf3fed7..ddf6a79e5 100644 --- a/crates/engine/src/commands/animation.rs +++ b/crates/engine/src/commands/animation.rs @@ -88,6 +88,21 @@ fn save_preset(s: &mut Session, p: &Value) -> Result { Ok(json!({"path": path, "properties": props.len(), "effects": effects.len()})) } +/// Rewrite an effect property path in a preset (`effects/[#n]/`) to the current +/// parameter id when it names a parameter that the effect has since renamed or moved into a +/// twirl-down group. Any other path comes back unchanged. +fn upgrade_effect_path(path: &str) -> String { + let Some((seg, tail)) = path.strip_prefix("effects/").and_then(|r| r.split_once('/')) else { return path.to_string() }; + let effect = match seg.rsplit_once('#') { + Some((m, n)) if !m.is_empty() && n.parse::().is_ok() => m, + _ => seg, + }; + match effectcraft_effects::find(effect).and_then(|spec| effectcraft_effects::migrate::current_param_id(spec, tail)) { + Some(id) if id != tail => format!("effects/{seg}/{id}"), + _ => path.to_string(), + } +} + fn apply_preset(s: &mut Session, p: &Value) -> Result { let doc: Value = match (p.get("preset"), str_p(p, "path")) { (Some(v), _) => v.clone(), @@ -117,7 +132,7 @@ fn apply_preset(s: &mut Session, p: &Value) -> Result { .map(|a| { a.iter() .filter_map(|v| { - let path = v["path"].as_str()?.to_string(); + let path = upgrade_effect_path(v["path"].as_str()?); let value: KV = serde_json::from_value(v["value"].clone()).ok()?; let keys: Vec = serde_json::from_value(v["keys"].clone()).unwrap_or_default(); let expr = serde_json::from_value(v["expression"].clone()).ok().flatten(); @@ -130,16 +145,29 @@ fn apply_preset(s: &mut Session, p: &Value) -> Result { }) .unwrap_or_default(); let t = s.time(); + // Effect bounds per layer size the point defaults of parameters added since the preset was saved. + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + let sizes: BTreeMap = + ids.iter().filter_map(|lid| comp.layer(*lid).map(|l| (*lid, effectcraft_render::effect_bounds(&s.project, comp, l).0))).collect(); let mut applied = 0; let mut skipped = vec![]; s.edit("Apply Animation Preset", None, |proj, _| { let mut next = proj.next_id; for lid in &ids { + let size = sizes.get(lid).copied().unwrap_or([100.0, 100.0]); let l = layer_mut(proj, cid, *lid)?; let lt = l.layer_time(t); if let Some(fx) = l.props.sub_mut("effects") { for g in &effects { let mut g = g.clone(); + // Effects saved by an earlier version get the upgrade a project gets on open. + let spec = match &g.kind { + GroupKind::Effect { effect } => effectcraft_effects::find(effect), + _ => None, + }; + if let Some(spec) = spec { + effectcraft_effects::migrate::upgrade_instance(spec, &mut g, &mut Ids(&mut next), size); + } g.reassign_uids(&mut next); next += 1; fx.children.push(Node::Group(g)); diff --git a/crates/engine/src/tests_effects.rs b/crates/engine/src/tests_effects.rs index 1ea57f4cf..28968b901 100644 --- a/crates/engine/src/tests_effects.rs +++ b/crates/engine/src/tests_effects.rs @@ -772,6 +772,37 @@ fn preset_round_trips_effect_properties_by_match_name() { } } +/// An animation preset saved by an earlier version gets the upgrade a project gets on open: a +/// Change to Color saved with its single Tolerance applies with the Tolerance group, that value +/// as its Hue and the added parameters at their legacy defaults, and a lone property saved at the +/// old id applies to the new one. +#[test] +fn applying_an_old_preset_upgrades_its_effects_and_property_paths() { + let (mut s, a, b) = comp_with_two_solids(); + apply(&mut s, a, "Change to Color"); + let mut old = layer(&s, a).effects().unwrap().groups().next().unwrap().clone(); + old.children.retain(|c| c.match_id() != "toleranceGroup"); + old.children.push(effectcraft_project::Property::new(0, "tolerance", "Tolerance", KV::Scalar(12.0)).into()); + let preset = json!({"effectcraftAnimationPreset": 1, "name": "old", "properties": [], "effects": [old]}); + let r = s.execute("anim.applyPreset", json!({"preset": preset, "layers": [b]})).unwrap(); + assert_eq!(r["applied"], 1, "{r}"); + let l = layer(&s, b); + let g = l.effects().unwrap().groups().next().unwrap(); + assert!(g.get("tolerance").is_none(), "the old Tolerance is still there"); + let tol = g.sub("toleranceGroup").expect("no Tolerance group"); + assert_eq!(tol.get("hue").unwrap().value, KV::Scalar(12.0)); + assert_eq!(tol.get("lightness").unwrap().value, KV::Scalar(100.0)); + assert_eq!(tol.get("saturation").unwrap().value, KV::Scalar(100.0)); + let path = "effects/ec.color.changetocolor/tolerance"; + let preset = json!({"effectcraftAnimationPreset": 1, "name": "old", "properties": [{"path": path, "value": KV::Scalar(30.0), "keys": []}], "effects": []}); + let r = s.execute("anim.applyPreset", json!({"preset": preset, "layers": [a]})).unwrap(); + assert_eq!(r["applied"], 1, "{r}"); + assert_eq!(r["skipped"], json!([]), "{r}"); + let l = layer(&s, a); + let hue = l.effects().unwrap().groups().next().unwrap().sub("toleranceGroup").unwrap().get("hue").unwrap().value.clone(); + assert_eq!(hue, KV::Scalar(30.0)); +} + /// #2064: `prop.addKey` accepts the `effects/[#n]/` path that /// `essential.list` reports. #[test] From d8e756dec573fba4cc74c1caf82c0bfdbda3f5e6 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 14:55:57 -0700 Subject: [PATCH 4/7] Proxies: a composition proxy draws with Separate Fields, Invert Alpha, linear light and Frame Blending For a precomp layer whose composition had a proxy, the renderer decoded the proxy file with frame_with and drew it as decoded. Interpret Footage > Proxy's Invert Alpha, Interpret As Linear Light and Separate Fields had no effect on the render, and neither did the layer's Frame Blending. A footage item's proxy already goes through footage_frame and gets all of them. The composition proxy now goes through footage_frame too, so it is decoded the way a footage proxy is. --- crates/engine/src/tests_proxy.rs | 79 +++++++++++++++++++++++++++++++- crates/render/src/lib.rs | 8 ++-- 2 files changed, 82 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/tests_proxy.rs b/crates/engine/src/tests_proxy.rs index 82cd1dcfd..c0223cf6f 100644 --- a/crates/engine/src/tests_proxy.rs +++ b/crates/engine/src/tests_proxy.rs @@ -14,7 +14,8 @@ 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 +/// even rows red, odd rows blue; 4×4, 10 fps), `flash.mov` (10×10, 10 fps: red, then blue from +/// the second frame), `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; @@ -52,9 +53,14 @@ fn size_of(path: &str) -> (u32, u32) { } impl FootageSource for Synthetic { - fn frame(&self, _: ItemId, f: &Footage, _t: Tick) -> Option> { + fn frame(&self, _: ItemId, f: &Footage, t: Tick) -> Option> { let (w, h) = size_of(&f.path); let mut img = Image::new(w, h); + // `flash.mov` is red for its first frame and blue from its second (0.1 s) on. + if f.path.ends_with("flash.mov") { + let c = if t.seconds() >= 0.1 - 1e-9 { [0.0, 0.0, 1.0, 1.0] } else { [1.0, 0.0, 0.0, 1.0] }; + return Some(Arc::new(Image::filled(w, h, c))); + } for y in 0..h { for x in 0..w { // Decoders hand out premultiplied pixels (straight file colour × alpha). @@ -171,6 +177,75 @@ fn comp_proxy_and_create_proxy() { assert!(blue(px(&s, outer, 0.0, o, 8, 8))); } +/// Outer (4×4, 20 fps) holding a layer of Inner (4×4, 10 fps, a red solid): (inner, outer). +fn nested(s: &mut Session) -> (ItemId, ItemId) { + s.execute("comp.new", json!({"name": "Inner", "width": 4, "height": 4, "frameRate": 10, "duration": 2})).unwrap(); + let inner = s.active_comp_id().unwrap(); + s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap(); + s.execute("comp.new", json!({"name": "Outer", "width": 4, "height": 4, "frameRate": 20, "duration": 2})).unwrap(); + let outer = s.active_comp_id().unwrap(); + s.execute("layer.addItem", json!({"item": inner.0})).unwrap(); + (inner, outer) +} + +/// Interpret Footage ▸ Proxy's Invert Alpha and Interpret As Linear Light change what a +/// composition proxy draws, as they do for the same file used as footage. +#[test] +fn comp_proxy_draws_with_its_interpretation() { + let mut s = session(); + let (inner, outer) = nested(&mut s); + let o = RenderOpts::default(); + // Invert Alpha: the 25 % proxy covers 75 %. + s.execute("file.setProxy", json!({"item": inner.0, "path": "/m/half.png"})).unwrap(); + s.execute("file.interpretProxy", json!({"item": inner.0, "alpha": "straight", "invertAlpha": true})).unwrap(); + let p = px(&s, outer, 0.0, o, 1, 1); + assert!((p[3] - 0.75).abs() < 0.01, "{p:?}"); + // Interpret As Linear Light: the proxy draws as the file does as footage with that setting. + let f = import(&mut s, "/m/matte_black.png"); + s.execute("file.interpretFootage", json!({"item": f.0, "linearLight": true})).unwrap(); + s.execute("comp.new", json!({"name": "Ref", "width": 4, "height": 4, "frameRate": 20, "duration": 2})).unwrap(); + let r = s.active_comp_id().unwrap(); + s.execute("layer.addItem", json!({"item": f.0})).unwrap(); + let want = px(&s, r, 0.0, o, 1, 1); + assert!(want[1] / want[3] > 0.7, "linear light re-encodes the 0.5 green: {want:?}"); + s.execute("file.setProxy", json!({"item": inner.0, "path": "/m/matte_black.png"})).unwrap(); + s.execute("file.interpretProxy", json!({"item": inner.0, "linearLight": true})).unwrap(); + let p = px(&s, outer, 0.0, o, 1, 1); + assert!((0..4).all(|i| (p[i] - want[i]).abs() < 1e-3), "{p:?} != {want:?}"); +} + +/// Interpret Footage ▸ Proxy ▸ Separate Fields: a composition proxy shows one field per frame +/// at twice the rate, as footage does. +#[test] +fn comp_proxy_separates_fields() { + let mut s = session(); + let (inner, outer) = nested(&mut s); + let o = RenderOpts::default(); + s.execute("file.setProxy", json!({"item": inner.0, "path": "/m/lines.mov"})).unwrap(); + assert!(red(px(&s, outer, 0.05, o, 1, 0)) && blue(px(&s, outer, 0.05, o, 1, 1)), "progressive"); + s.execute("file.interpretProxy", json!({"item": inner.0, "fields": "upper"})).unwrap(); + // At 0.05 s the lower field (odd rows, blue) is shown, with the even rows interpolated. + for y in 0..3 { + assert!(blue(px(&s, outer, 0.05, o, 1, y)), "lower field at row {y}"); + } +} + +/// Frame Blending on a precomp layer blends the frames of the composition's proxy. +#[test] +fn comp_proxy_frames_blend() { + let mut s = session(); + let (inner, outer) = nested(&mut s); + let o = RenderOpts::default(); + s.execute("file.setProxy", json!({"item": inner.0, "path": "/m/flash.mov"})).unwrap(); + assert!(red(px(&s, outer, 0.05, o, 1, 1))); + s.execute("layer.frameBlending", json!({"mode": "frameMix"})).unwrap(); + s.execute("comp.setSwitch", json!({"switch": "frameBlending", "value": true})).unwrap(); + // Halfway between the red first frame and the blue second one. + let p = px(&s, outer, 0.05, o, 1, 1); + assert!((p[0] - 0.5).abs() < 0.05 && (p[2] - 0.5).abs() < 0.05 && p[3] > 0.99, "{p:?}"); + assert!(blue(px(&s, outer, 0.1, o, 1, 1))); +} + #[test] fn interpret_proxy_settings_apply_to_the_proxy() { let mut s = session(); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index bf3aa42ee..67f200669 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -1257,8 +1257,10 @@ 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. - // Nothing before the nested comp starts or after it ends. + // A composition proxy (a rendered still or movie) stands in for the comp. It is + // decoded like footage: its Separate Fields, Invert Alpha and Interpret As Linear + // Light settings and the layer's Frame Blending apply. Nothing before the nested + // comp starts or after it ends. let lt = ctx.nested_time(layer); if !self.project.comp(*item).is_some_and(|nc| nc.covers(lt)) { return None; @@ -1266,7 +1268,7 @@ impl<'a> Renderer<'a> { if let Some(pf) = self.proxy_for(*item) && let Some(nc) = self.project.comp(*item) { - let img = self.footage.frame_with(*item, pf, lt, None, self.pipe.float_encoding())?; + let img = self.footage_frame(ctx, layer, *item, pf, lt, None)?; return Some(self.footage_buf(&img, pf, nc.width, nc.height)); } // Essential Properties overrides render the nested comp with this instance's From f4c9ba5c012824f806b4fbe591d4fdffdc2e9c79 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 25 Oct 2026 17:11:42 -0700 Subject: [PATCH 5/7] Font menu: a name the interface fonts cannot draw shows in English MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With Show Font Names in English off (the default), the Character and Properties font menus draw each family's own-language name in the interface font. On Windows, Microsoft YaHei's Simplified Chinese family name is 微软雅黑, and the Han face of an English interface can lack 软. The app drew a missing-glyph box in its place, and the font search tests in the effectcraft-ui-egui it binary panicked with "No glyph for "软"". A row now draws its own-language name when the interface fonts have a glyph for each character, and its English family name otherwise. The row's hover text follows the same rule. Search still matches both names. --- crates/ui-egui/src/panels/text_panels.rs | 25 +++++++++++- crates/ui-egui/tests/it/ui_font_search.rs | 46 +++++++++++++++++++++++ 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels/text_panels.rs b/crates/ui-egui/src/panels/text_panels.rs index 34dc4cce9..c8dd2b08d 100644 --- a/crates/ui-egui/src/panels/text_panels.rs +++ b/crates/ui-egui/src/panels/text_panels.rs @@ -702,10 +702,11 @@ pub(super) fn font_picker(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: egui: let resp = ui.add_sized([row_width, 24.0], egui::Button::selectable(index == state.selected, "")); resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), index == state.selected, &r.display)); let label_rect = Rect::from_min_size(resp.rect.min + vec2(5.0, 0.0), vec2(label_width - 5.0, resp.rect.height())); + let name = drawn_font_name(ui.ctx(), r); ui.painter().with_clip_rect(label_rect.intersect(ui.clip_rect())).text( label_rect.left_center(), Align2::LEFT_CENTER, - &r.display, + name, Tokens::ui(12.0), t.text, ); @@ -739,7 +740,7 @@ pub(super) fn font_picker(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: egui: if resp.clicked() { chosen = Some(r.family.clone()); } - resp.on_hover_text(&r.display); + resp.on_hover_text(name); } }); }); @@ -886,6 +887,13 @@ fn align_glyph(p: &egui::Painter, r: Rect, op: &str, distribute: bool, t: &Token } } +/// The name a font menu row draws: the font's own-language name when the interface fonts have a +/// glyph for each of its characters, else its English family name. A Simplified Chinese name +/// such as 微软雅黑 can have characters that the Han face of an English interface lacks. +fn drawn_font_name<'a>(ctx: &egui::Context, row: &'a effectcraft_engine::FontRow) -> &'a str { + if ctx.fonts_mut(|f| f.has_glyphs(&Tokens::ui(12.0), &row.display)) { &row.display } else { &row.family } +} + /// Where a font's "Sample" outlines (`lines`, baseline at y = 0) go in `room`: `(scale, origin)`, /// a point `p` landing at `origin + p * scale`. At their natural size on `baseline` when they fit; /// fonts with large or odd metrics are scaled down (never up) and moved inside, so no preview @@ -944,4 +952,17 @@ mod tests { assert!(fit_preview(&[], room, baseline).is_none()); assert!(fit_preview(&[vec![[f32::NAN, 1.0]]], room, baseline).is_none()); } + + /// A font menu row draws the font's own-language name when the interface fonts have its + /// glyphs, and the English family name when they do not. + #[test] + fn font_menu_rows_draw_a_name_the_interface_fonts_have() { + let ctx = egui::Context::default(); + crate::theme::install(&ctx, &Tokens::for_kind(Default::default()), "en"); + ctx.run_ui(Default::default(), |_| {}).textures_delta.clear(); + let row = |display: &str| effectcraft_engine::FontRow { family: "Menu Name Test".into(), display: display.into(), recent: false, variable: false }; + // U+F1234 is in a private use plane that no interface font maps. + assert_eq!(drawn_font_name(&ctx, &row("Menu \u{F1234}")), "Menu Name Test"); + assert_eq!(drawn_font_name(&ctx, &row("Nom du menu")), "Nom du menu"); + } } diff --git a/crates/ui-egui/tests/it/ui_font_search.rs b/crates/ui-egui/tests/it/ui_font_search.rs index 6be69cb7a..42287645d 100644 --- a/crates/ui-egui/tests/it/ui_font_search.rs +++ b/crates/ui-egui/tests/it/ui_font_search.rs @@ -106,6 +106,52 @@ fn font_search_preserves_edited_text_and_applies_only_to_selected_characters() { assert_eq!(doc.style_at(8).font, "Inter"); } +/// A font file of just `name`: the family and Regular in English, and the family again in +/// Simplified Chinese (language 0x804). +fn font_with_native_name(family: &str, native: &str) -> Vec { + let u16s = |v: &[u16]| -> Vec { v.iter().flat_map(|x| x.to_be_bytes()).collect() }; + let records = [(0x409, 1, family), (0x409, 2, "Regular"), (0x804, 1, native)]; + let strings: Vec> = records.iter().map(|(_, _, s)| s.encode_utf16().flat_map(|u| u.to_be_bytes()).collect()).collect(); + let mut name = u16s(&[0, records.len() as u16, 6 + 12 * records.len() as u16]); + let mut off = 0; + for ((lang, id, _), st) in records.iter().zip(&strings) { + name.extend(u16s(&[3, 1, *lang, *id, st.len() as u16, off])); + off += st.len() as u16; + } + strings.iter().for_each(|st| name.extend(st)); + let mut out = u16s(&[1, 0, 1, 0, 0, 0]); + out.extend(b"name"); + out.extend([0u8; 4]); + out.extend(28u32.to_be_bytes()); + out.extend((name.len() as u32).to_be_bytes()); + out.extend(name); + out +} + +/// A family whose own-language name has a character no interface font has is listed under its +/// English name: the harness fails on a missing glyph, and the app would draw boxes. Picking the +/// row still applies the family. +#[test] +fn font_menu_lists_a_name_the_interface_fonts_lack_in_english() { + const FAMILY: &str = "Native Name Menu Test"; + // U+F1234 is in a private use plane that no interface font maps. + const NATIVE: &str = "中文\u{F1234}字体"; + assert_eq!(effectcraft_engine::text::fonts::add_font_data(font_with_native_name(FAMILY, NATIVE)).len(), 1); + assert_eq!(effectcraft_engine::text::fonts::native_families().get(FAMILY).map(String::as_str), Some(NATIVE)); + let mut s = Session::default(); + s.execute("comp.new", json!({"width": 320, "height": 180})).unwrap(); + let lid = s.execute("layer.newText", json!({"text": "Native", "font": "Inter"})).unwrap()["layer"].as_u64().unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(move |_| EffectcraftApp::new(s)); + h.state_mut().show_panel(PanelKind::Character); + h.run_steps(3); + click(&mut h, "character.font"); + type_text(&mut h, "native name menu"); + let shown: Vec = h.state().auto.query("character.font.option.").iter().map(|e| e.id.clone()).collect(); + assert_eq!(shown, vec![format!("character.font.option.{FAMILY}")]); + click(&mut h, &format!("character.font.option.{FAMILY}")); + assert_eq!(layer_doc(&h.state().session, LayerId(lid)).unwrap().font, FAMILY); +} + /// A font file of just `name` (the family, Regular) and an `fvar` table with one axis pinned at /// 400: a variable font as far as the font menu's filter is concerned. fn pinned_variable_font(family: &str) -> Vec { From 9dbcd99e808fa920a6469c4579c935f9a7fcdffb Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 27 Oct 2026 07:44:59 -0700 Subject: [PATCH 6/7] Reject legacy workspace stacks beyond the layout depth limit --- crates/ui-egui/src/workspace_state/legacy.rs | 10 ++++-- .../src/workspace_state/legacy/tests.rs | 35 +++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) create mode 100644 crates/ui-egui/src/workspace_state/legacy/tests.rs diff --git a/crates/ui-egui/src/workspace_state/legacy.rs b/crates/ui-egui/src/workspace_state/legacy.rs index 93d71fd75..bcb5a9691 100644 --- a/crates/ui-egui/src/workspace_state/legacy.rs +++ b/crates/ui-egui/src/workspace_state/legacy.rs @@ -25,6 +25,9 @@ use super::{Snapshot, VERSION}; use crate::dock::{Layout, PanelKind, Workspace}; use crate::state::UiState; +#[cfg(test)] +mod tests; + #[derive(Deserialize)] enum OldSplitSize { Ratio(f32), @@ -166,7 +169,7 @@ fn node(old: OldNode, depth: usize) -> Result, String> { first: Box::new(node(*a, depth.saturating_add(1))?), second: Box::new(node(*b, depth.saturating_add(1))?), }, - OldNode::Stack { entries } => column(entries)?, + OldNode::Stack { entries } => column(entries, depth)?, }) } @@ -183,7 +186,7 @@ struct Frame { } /// A column of stacked frames as tab groups split top to bottom (see the module docs). -fn column(entries: Vec) -> Result, String> { +fn column(entries: Vec, depth: usize) -> Result, String> { let mut frames: Vec = Vec::new(); // Tabs of closed frames above the first open one. let mut waiting: Vec = Vec::new(); @@ -199,6 +202,9 @@ fn column(entries: Vec) -> Result, String> { } let active = entry.active.min(panels.len().saturating_sub(1)); if entry.open { + if depth.saturating_add(frames.len()) > craft_ui::docking::MAX_DEPTH { + return Err("workspace dock tree exceeds supported limits".into()); + } let height = entry.height.filter(|h| h.is_finite() && *h > 0.0).map(|h| h.min(MAX_FRAME)); frames.push(Frame { panels, active, height }); } else if let Some(above) = frames.last_mut() { diff --git a/crates/ui-egui/src/workspace_state/legacy/tests.rs b/crates/ui-egui/src/workspace_state/legacy/tests.rs new file mode 100644 index 000000000..a3d182f4f --- /dev/null +++ b/crates/ui-egui/src/workspace_state/legacy/tests.rs @@ -0,0 +1,35 @@ +use super::*; + +fn stack(count: usize) -> OldNode { + OldNode::Stack { + entries: (0..count).map(|_| OldStackEntry { panel: Some(PanelKind::Info), panels: Vec::new(), active: 0, open: true, height: None }).collect(), + } +} + +#[test] +fn stacked_frames_respect_the_remaining_tree_depth() { + let limit = craft_ui::docking::MAX_DEPTH; + assert!(node(stack(limit + 1), 0).is_ok()); + assert!(node(stack(limit + 2), 0).is_err()); + assert!(node(stack(1), limit).is_ok()); + assert!(node(stack(2), limit).is_err()); + assert!(node(stack(limit), 2).is_err()); +} + +#[test] +fn oversized_legacy_stack_load_does_not_abort() { + const CHILD: &str = "EFFECTCRAFT_TEST_LEGACY_STACK_CHILD"; + if std::env::var_os(CHILD).is_some() { + let entry = serde_json::json!({"panel": "Info", "open": true}); + let value = serde_json::json!({"workspaces": {"Oversized": {"Stack": {"entries": vec![entry; 20_000]}}}}); + assert!(serde_json::to_vec(&value).unwrap().len() < super::super::MAX_BYTES); + assert!(migrate(value, &UiState::default()).is_err()); + return; + } + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "workspace_state::legacy::tests::oversized_legacy_stack_load_does_not_abort", "--nocapture"]) + .env(CHILD, "1") + .output() + .unwrap(); + assert!(output.status.success(), "{}\n{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr)); +} From 87eb51a0899026d155de6b8c76290b4ded4352fc Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 27 Oct 2026 09:22:14 -0700 Subject: [PATCH 7/7] Include alpha interpretation in frame-blend cache keys --- crates/engine/src/tests/proxy.rs | 43 ++++++++++++++++++++++++++++++++ crates/render/src/pipeline.rs | 4 +++ 2 files changed, 47 insertions(+) diff --git a/crates/engine/src/tests/proxy.rs b/crates/engine/src/tests/proxy.rs index 0e57e5fa1..941a9a96b 100644 --- a/crates/engine/src/tests/proxy.rs +++ b/crates/engine/src/tests/proxy.rs @@ -246,6 +246,49 @@ fn comp_proxy_frames_blend() { assert!(blue(px(&s, outer, 0.1, o, 1, 1))); } +#[test] +fn comp_proxy_frame_blend_cache_tracks_alpha_interpretation() { + struct Interpreted(std::sync::atomic::AtomicUsize); + impl FootageSource for Interpreted { + fn frame(&self, _: ItemId, f: &Footage, _: Tick) -> Option> { + self.0.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let pixel = match f.alpha { + AlphaMode::Straight => [0.4, 0.2, 0.1, 0.5], + AlphaMode::Premultiplied => [f.premul_color[0] * 0.5, 0.1, 0.0, 0.5], + AlphaMode::Ignore => [0.8, 0.4, 0.2, 1.0], + }; + Some(Arc::new(Image::filled(4, 4, pixel))) + } + } + for mode in ["frameMix", "pixelMotion"] { + let mut s = session(); + let footage = Arc::new(Interpreted(std::sync::atomic::AtomicUsize::new(0))); + s.footage = footage.clone(); + let (inner, outer) = nested(&mut s); + s.execute("file.setProxy", json!({"item": inner.0, "path": "/m/alpha.mov"})).unwrap(); + s.execute("file.interpretProxy", json!({"item": inner.0, "alpha": "straight"})).unwrap(); + s.execute("layer.frameBlending", json!({"mode": mode})).unwrap(); + s.execute("comp.setSwitch", json!({"switch": "frameBlending", "value": true})).unwrap(); + let opts = RenderOpts::default(); + let first = px(&s, outer, 0.05, opts, 1, 1); + let calls = footage.0.load(std::sync::atomic::Ordering::Relaxed); + assert_eq!(px(&s, outer, 0.05, opts, 1, 1), first); + assert_eq!(footage.0.load(std::sync::atomic::Ordering::Relaxed), calls); + for params in [ + json!({"item": inner.0, "alpha": "ignore"}), + json!({"item": inner.0, "alpha": "premultiplied", "premulColor": [0.0, 0.0, 0.0]}), + json!({"item": inner.0, "alpha": "premultiplied", "premulColor": [1.0, 0.0, 0.0]}), + ] { + s.execute("file.interpretProxy", params.clone()).unwrap(); + let mut fresh = s.renderer(opts); + fresh.cache = None; + let want = fresh.comp_frame(outer, Tick::from_seconds_f64(0.05)).get(1, 1); + let actual = px(&s, outer, 0.05, opts, 1, 1); + assert_eq!(actual, want, "{mode} after {params}"); + } + } +} + #[test] fn interpret_proxy_settings_apply_to_the_proxy() { let mut s = session(); diff --git a/crates/render/src/pipeline.rs b/crates/render/src/pipeline.rs index faa21294e..84e4fec0d 100644 --- a/crates/render/src/pipeline.rs +++ b/crates/render/src/pipeline.rs @@ -740,6 +740,10 @@ impl<'a> Renderer<'a> { use std::hash::{Hash, Hasher}; let mut h = std::collections::hash_map::DefaultHasher::new(); (0x00f8_a3e5_u64, item, i, (w as f32).to_bits(), mode as u8, f.frame_rate.num, f.frame_rate.den, &f.path, float).hash(&mut h); + (f.alpha as u8).hash(&mut h); + if f.alpha == effectcraft_project::AlphaMode::Premultiplied { + f.premul_color.map(f32::to_bits).hash(&mut h); + } h.finish() }); if let (Some(c), Some(k)) = (self.cache, key)