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/cmd/animation.rs b/crates/engine/src/cmd/animation.rs index 1bdf3fed7..ddf6a79e5 100644 --- a/crates/engine/src/cmd/animation.rs +++ b/crates/engine/src/cmd/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/cmd/file_more.rs b/crates/engine/src/cmd/file_more.rs index c9d581581..1e57d4b5c 100644 --- a/crates/engine/src/cmd/file_more.rs +++ b/crates/engine/src/cmd/file_more.rs @@ -1066,7 +1066,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 ), @@ -1084,7 +1084,7 @@ pub fn specs() -> Vec { "Apply Interpretation", ["File", "Interpret Footage"], Some("Cmd+Alt+V"), - "{items?}", + "{items?|item?}", has_interpretation, apply_interpretation ), @@ -1115,7 +1115,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/cmd/proxy.rs b/crates/engine/src/cmd/proxy.rs index b7d644b94..d15f93730 100644 --- a/crates/engine/src/cmd/proxy.rs +++ b/crates/engine/src/cmd/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/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] diff --git a/crates/engine/src/tests/proxy.rs b/crates/engine/src/tests/proxy.rs index 16e0cb00c..941a9a96b 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,118 @@ 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 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(); @@ -187,6 +305,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] 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/disk_cache.rs b/crates/render/src/disk_cache.rs index ca5660a67..f42d31c2d 100644 --- a/crates/render/src/disk_cache.rs +++ b/crates/render/src/disk_cache.rs @@ -841,7 +841,7 @@ fn decode_layer(p: &[u8]) -> Option { // ---------------------------------------------------------------- content keys /// Bump when rendering changes in a way that makes old disk entries wrong. -const RENDER_VERSION: &str = "effectcraft-render-2"; +const RENDER_VERSION: &str = "effectcraft-render-3"; fn hash_debug(h: &mut Hash128, v: &impl std::fmt::Debug) { h.write(format!("{v:?}").as_bytes()); @@ -910,9 +910,18 @@ pub fn comp_content_key(project: &effectcraft_project::Project, comp: effectcraf /// [`comp_content_key`] with [`VirtualFileId`] for paths `std::fs::metadata` cannot describe. pub fn comp_content_key_with(project: &effectcraft_project::Project, comp: effectcraft_project::ItemId, virtual_id: Option>) -> u128 { + comp_content_key_for_renderer(project, comp, virtual_id, RENDER_VERSION) +} + +fn comp_content_key_for_renderer( + project: &effectcraft_project::Project, + comp: effectcraft_project::ItemId, + virtual_id: Option>, + renderer: &str, +) -> u128 { use effectcraft_project::{ItemKind, LayerSource}; let mut h = Hash128::default(); - h.write(RENDER_VERSION.as_bytes()); + h.write(renderer.as_bytes()); hash_debug(&mut h, &project.settings); let mut seen = std::collections::BTreeSet::new(); let mut stack = vec![comp]; @@ -1070,6 +1079,28 @@ mod tests { assert!(dc.get_frame(42).is_none()); } + #[test] + fn previous_renderer_frames_are_not_reused() { + use effectcraft_project::{Comp, ItemKind, Project}; + use effectcraft_time::{FrameRate, Tick}; + + let mut project = Project::default(); + let comp = Comp::new(2, 2, FrameRate::FPS_30, Tick::from_seconds_f64(1.0)); + let id = project.add_item("Cached composition", effectcraft_color::Label::None, None, ItemKind::Comp(comp.into())); + let key = frame_key(comp_content_key(&project, id), 0, 1000, 0); + let previous_key = frame_key(comp_content_key_for_renderer(&project, id, None, "effectcraft-render-2"), 0, 1000, 0); + let dir = tmpdir("previous-renderer"); + { + let cache = DiskCache::open(&dir, 1 << 30).unwrap(); + cache.put_frame_now(previous_key, 2, 2, &[9; 16]); + } + let cache = DiskCache::open(&dir, 1 << 30).unwrap(); + assert_eq!(cache.get_frame(previous_key).unwrap().rgba, vec![9; 16]); + assert!(cache.get_frame(key).is_none()); + cache.put_frame_now(key, 2, 2, &[17; 16]); + assert_eq!(cache.get_frame(key).unwrap().rgba, vec![17; 16]); + } + #[test] fn persists_across_restarts_and_clears() { let dir = tmpdir("persist"); diff --git a/crates/render/src/pipeline.rs b/crates/render/src/pipeline.rs index bc1879369..71ad426ce 100644 --- a/crates/render/src/pipeline.rs +++ b/crates/render/src/pipeline.rs @@ -594,8 +594,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; @@ -603,7 +605,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 @@ -737,6 +739,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) diff --git a/crates/render/src/three_d/adv/shade.rs b/crates/render/src/three_d/adv/shade.rs index 0ebd0b1ac..19548b224 100644 --- a/crates/render/src/three_d/adv/shade.rs +++ b/crates/render/src/three_d/adv/shade.rs @@ -228,6 +228,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). @@ -243,8 +262,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 @@ -312,13 +332,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 f9a0f2066..6e626ad0d 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 diff --git a/crates/ui-egui/src/app/frame.rs b/crates/ui-egui/src/app/frame.rs index d41e0a9d0..a3263a677 100644 --- a/crates/ui-egui/src/app/frame.rs +++ b/crates/ui-egui/src/app/frame.rs @@ -131,6 +131,9 @@ impl EffectcraftApp { self.apply_gpu_failure(ctx); if self.gpu_failure_applied == Some(true) { self.close_after_device_loss(ctx); + // On macOS the app quits through `-[NSApplication terminate:]` instead of closing the + // window while AppKit's run loop is running (#341). + craft_quit::on_close_request(ctx); return; } // Focus comes from the fresh viewport copy; a full pass's `InputState::focused` is only a diff --git a/crates/ui-egui/src/panels/text_panels.rs b/crates/ui-egui/src/panels/text_panels.rs index 8a8e604e2..6a36fbbc9 100644 --- a/crates/ui-egui/src/panels/text_panels.rs +++ b/crates/ui-egui/src/panels/text_panels.rs @@ -762,10 +762,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 == selected, "")); resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), index == 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, ); @@ -799,7 +800,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); } chosen }; @@ -945,6 +946,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 @@ -1003,4 +1011,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/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)); +} diff --git a/crates/ui-egui/tests/it/ui_font_search.rs b/crates/ui-egui/tests/it/ui_font_search.rs index 122003abe..141d8ec58 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 {