From da67ea95533b3b905173a64f9042974b0b995271 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sat, 10 Oct 2026 21:38:57 +1100 Subject: [PATCH 001/107] Keep a composition work area inside the composition and at least one frame long. Setting the start past the current end, or shortening the composition, stored an inverted interval. Trimming that interval then saved a negative duration. --- crates/engine/src/commands/comp.rs | 25 +++++++++++ crates/engine/src/tests_menu_cmds.rs | 64 ++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+) diff --git a/crates/engine/src/commands/comp.rs b/crates/engine/src/commands/comp.rs index a3267fa1..877993e4 100644 --- a/crates/engine/src/commands/comp.rs +++ b/crates/engine/src/commands/comp.rs @@ -76,6 +76,8 @@ fn apply_settings(c: &mut Comp, p: &Value) { c.work_area.1 = c.duration; } c.work_area.1 = c.work_area.1.min(c.duration); + // A shorter composition must not leave the start past the end. + keep_work_area_ordered(c); } if let Some(pa) = f_p(p, "pixelAspect") { c.pixel_aspect = pa.max(0.1); @@ -196,17 +198,40 @@ fn work_area(s: &mut Session, p: &Value) -> Result { if let Some(e) = end { c.work_area.1 = e.clamp(c.work_area.0 + fd, c.duration); } + // A start on its own can land after the current end. Pull it back so the interval + // still covers one frame, the same rule as `set: "begin"`. + keep_work_area_ordered(c); Ok(()) })?; let c = s.project.comp(cid).ok_or(EngineError::NoComp)?; Ok(json!({"start": c.work_area.0.seconds(), "end": c.work_area.1.seconds()})) } +/// Keep `work_area` inside the composition and at least one frame long. +fn keep_work_area_ordered(c: &mut Comp) { + let fd = c.frame_duration(); + c.work_area.1 = c.work_area.1.min(c.duration); + if c.work_area.0 < Tick::ZERO { + c.work_area.0 = Tick::ZERO; + } + if c.work_area.0 + fd > c.work_area.1 { + c.work_area.0 = (c.work_area.1 - fd).max(Tick::ZERO); + } + if c.work_area.0 + fd > c.work_area.1 { + c.work_area.1 = (c.work_area.0 + fd).min(c.duration); + } +} + fn trim_to_wa(s: &mut Session, p: &Value) -> Result { let cid = comp_id(s, p)?; s.edit("Trim Comp to Work Area", None, |proj, _| { let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; let (a, b) = c.work_area; + let fd = c.frame_duration(); + // An interval that is already inverted must not become a negative duration. + if a < Tick::ZERO || b > c.duration || a + fd > b { + return Err(bad("comp.trimToWorkArea", "the work area must cover at least one frame inside the composition")); + } for l in &mut c.layers { l.start_time -= a; l.in_point = (l.in_point - a).max(Tick::ZERO); diff --git a/crates/engine/src/tests_menu_cmds.rs b/crates/engine/src/tests_menu_cmds.rs index c342f72f..b8c534b8 100644 --- a/crates/engine/src/tests_menu_cmds.rs +++ b/crates/engine/src/tests_menu_cmds.rs @@ -3,6 +3,7 @@ use effectcraft_keyframe::Value as KV; use effectcraft_project::{FrameBlend, ItemKind, MatteKind, Quality, Sampling}; +use effectcraft_time::Tick; use serde_json::{Value, json}; use crate::{EngineError, Event, Session}; @@ -253,6 +254,69 @@ fn lift_and_extract_work_area() { assert!((spans[1].0 - 2.0).abs() < 1e-6 && (spans[1].1 - 8.0).abs() < 1e-6, "{spans:?}"); } +#[test] +fn work_area_stays_ordered_when_the_start_passes_the_end() { + let ordered = |s: &Session| { + let c = s.active_comp().unwrap(); + let fd = c.frame_duration(); + assert!(c.duration >= fd, "duration {}", c.duration.seconds()); + assert!(c.work_area.0 >= Tick::ZERO); + assert!(c.work_area.0 + fd <= c.work_area.1, "start {} end {}", c.work_area.0.seconds(), c.work_area.1.seconds()); + assert!(c.work_area.1 <= c.duration); + (c.duration, c.work_area) + }; + let one_frame_at_two = |s: &Session| { + let end = Tick::from_seconds_f64(2.0); + (end - s.active_comp().unwrap().frame_duration(), end) + }; + + let mut s = comp(); + s.execute("comp.workArea", json!({"end": 2.0})).unwrap(); + s.execute("comp.workArea", json!({"start": 8.0})).unwrap(); + assert_eq!(ordered(&s).1, one_frame_at_two(&s)); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(ordered(&s).1, (Tick::ZERO, Tick::from_seconds_f64(2.0))); + + s.execute("comp.workArea", json!({"start": 0.0, "end": 2.0})).unwrap(); + s.execute("comp.trimToWorkArea", json!({})).unwrap(); + assert_eq!(ordered(&s), (Tick::from_seconds_f64(2.0), (Tick::ZERO, Tick::from_seconds_f64(2.0)))); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(ordered(&s).0, Tick::from_seconds_f64(10.0)); + + let mut s = comp(); + s.execute("comp.workArea", json!({"start": 9.0, "end": 10.0})).unwrap(); + let before = ordered(&s); + s.execute("comp.settings", json!({"duration": 2.0})).unwrap(); + let (dur, wa) = ordered(&s); + assert_eq!(dur, Tick::from_seconds_f64(2.0)); + assert_eq!(wa, one_frame_at_two(&s)); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(ordered(&s), before); + + let mut s = comp(); + s.execute("comp.workArea", json!({"end": 2.0})).unwrap(); + s.execute("time.set", json!({"time": 8.0})).unwrap(); + s.execute("comp.workArea", json!({"set": "begin"})).unwrap(); + assert_eq!(ordered(&s).1, one_frame_at_two(&s)); + let path = tmp("WorkArea.ecproj"); + s.execute("file.saveAs", json!({"path": path})).unwrap(); + s.execute("file.open", json!({"path": path})).unwrap(); + assert_eq!(ordered(&s).1, one_frame_at_two(&s)); + + // A project that already has an inverted interval must not trim into a negative duration. + let mut s = comp(); + let cid = s.state.active_comp.unwrap(); + { + let c = std::sync::Arc::make_mut(&mut s.project).comp_mut(cid).unwrap(); + c.work_area = (Tick::from_seconds_f64(8.0), Tick::from_seconds_f64(2.0)); + } + let err = s.execute("comp.trimToWorkArea", json!({})).unwrap_err(); + assert!(err.to_string().contains("work area"), "{err}"); + let c = s.active_comp().unwrap(); + assert_eq!(c.duration, Tick::from_seconds_f64(10.0)); + assert_eq!(c.work_area, (Tick::from_seconds_f64(8.0), Tick::from_seconds_f64(2.0))); +} + #[test] fn label_group_purge_and_edit_original() { crate::tests_roto::hold_roto_cache_test_lock(); From 39a0c6aa547d6bad87bc22cc0824f45ee54ce0b1 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sat, 10 Oct 2026 21:42:03 +1100 Subject: [PATCH 002/107] Move a layer when Position is edited and its dimensions are separated. The renderer follows X, Y and Z Position once those channels exist. The Position command was writing only the combined property, so the layer stayed where it was. --- crates/engine/src/commands/layer_menu.rs | 26 ++++++++++++ crates/engine/src/tests_menu_cmds.rs | 53 ++++++++++++++++++++++++ 2 files changed, 79 insertions(+) diff --git a/crates/engine/src/commands/layer_menu.rs b/crates/engine/src/commands/layer_menu.rs index 5ad0c1fc..ba00db38 100644 --- a/crates/engine/src/commands/layer_menu.rs +++ b/crates/engine/src/commands/layer_menu.rs @@ -192,6 +192,32 @@ fn set_transform(s: &mut Session, p: &Value) -> Result { for lid in &ids { let l = layer_mut(proj, cid, *lid)?; let lt = l.layer_time(t); + // Separate Dimensions: the picture follows X/Y/Z Position, not the combined property. + if m == "position" && l.transform().is_some_and(|tr| tr.get("positionX").is_some()) { + let cur = l + .transform() + .map(|tr| { + [ + tr.get("positionX").map(|p| p.value_at(lt).as_f64()).unwrap_or(0.0), + tr.get("positionY").map(|p| p.value_at(lt).as_f64()).unwrap_or(0.0), + tr.get("positionZ").map(|p| p.value_at(lt).as_f64()).unwrap_or(0.0), + ] + }) + .unwrap_or([0.0; 3]); + // Two numbers keep the current z. + let j = match &v { + Value::Array(a) if a.len() == 2 => json!([a[0], a[1], cur[2]]), + _ => v.clone(), + }; + let nv = KV::Vec3(cur).coerce_json(&j).ok_or_else(|| bad("layer.setTransform", format!("can't use {v} for {label}")))?; + let xyz = nv.as_vec3(); + let Some(tr) = l.transform_mut() else { continue }; + super::layer::update_position(tr, lt, |_| xyz); + if let Some(pr) = tr.get_mut("position") { + pr.set_value_at(lt, KV::Vec3(xyz)); + } + continue; + } let Some(pr) = l.transform_mut().and_then(|tr| tr.get_mut(m)) else { continue }; let cur = pr.value_at(lt); // Two numbers for a 3D value keep the current z. diff --git a/crates/engine/src/tests_menu_cmds.rs b/crates/engine/src/tests_menu_cmds.rs index c342f72f..0d626dd8 100644 --- a/crates/engine/src/tests_menu_cmds.rs +++ b/crates/engine/src/tests_menu_cmds.rs @@ -3,6 +3,7 @@ use effectcraft_keyframe::Value as KV; use effectcraft_project::{FrameBlend, ItemKind, MatteKind, Quality, Sampling}; +use effectcraft_time::Tick; use serde_json::{Value, json}; use crate::{EngineError, Event, Session}; @@ -103,6 +104,58 @@ fn transform_dialog_values_and_center_anchor() { assert_eq!(layer(&s, a).auto_orient, effectcraft_project::AutoOrient::AlongPath); } +/// Pixel-centre of the opaque samples, so a 10×10 solid centred on (11, 22) reports (11, 22). +fn opaque_centroid(s: &Session) -> (f64, f64, u32) { + let img = s.render(s.active_comp_id().unwrap(), Tick::ZERO, Default::default()); + let mut n = 0u32; + let mut sx = 0.0; + let mut sy = 0.0; + for y in 0..img.height { + for x in 0..img.width { + if img.get(x as i64, y as i64)[3] > 0.5 { + n += 1; + sx += x as f64 + 0.5; + sy += y as f64 + 0.5; + } + } + } + assert!(n > 0, "the layer rendered nothing"); + (sx / n as f64, sy / n as f64, n) +} + +#[test] +fn separated_position_set_transform_moves_the_layer() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Scene", "width": 100, "height": 100, "frameRate": 30, "duration": 2})).unwrap(); + let a = s.execute("layer.newSolid", json!({"name": "Square", "color": "#ff0000", "width": 10, "height": 10})).unwrap()["layer"].as_u64().unwrap(); + let (x, y, n) = opaque_centroid(&s); + assert_eq!(n, 100); + assert!((x - 50.0).abs() < 0.6 && (y - 50.0).abs() < 0.6, "starts centred, centroid ({x}, {y})"); + + s.execute("prop.separateDimensions", json!({"layer": a, "value": true})).unwrap(); + s.execute("layer.setTransform", json!({"layers": [a], "prop": "position", "value": [11, 22]})).unwrap(); + let l = layer(&s, a); + let tr = l.transform().unwrap(); + assert!((tr.get("positionX").unwrap().value.as_f64() - 11.0).abs() < 1e-6); + assert!((tr.get("positionY").unwrap().value.as_f64() - 22.0).abs() < 1e-6); + assert_eq!(tr.get("position").unwrap().value, KV::Vec3([11.0, 22.0, 0.0])); + let (x, y, n) = opaque_centroid(&s); + assert_eq!(n, 100); + assert!((x - 11.0).abs() < 0.6 && (y - 22.0).abs() < 0.6, "separated position renders at ({x}, {y})"); + + s.execute("edit.undo", json!({})).unwrap(); + let (x, y, _) = opaque_centroid(&s); + assert!((x - 50.0).abs() < 0.6 && (y - 50.0).abs() < 0.6, "undo puts it back at ({x}, {y})"); + + // Combined Position, the control, still moves. + s.execute("edit.undo", json!({})).unwrap(); + assert!(layer(&s, a).transform().unwrap().get("positionX").is_none()); + s.execute("layer.setTransform", json!({"prop": "position", "value": [11, 22]})).unwrap(); + let (x, y, n) = opaque_centroid(&s); + assert_eq!(n, 100); + assert!((x - 11.0).abs() < 0.6 && (y - 22.0).abs() < 0.6, "combined position renders at ({x}, {y})"); +} + #[test] fn mask_menu_on_existing_masks() { let mut s = comp(); From be9e1e9f425b7a2a3ed0e669cdf8b70c88d02770 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sat, 10 Oct 2026 21:45:20 +1100 Subject: [PATCH 003/107] Show a layer through its first Intersect or Darken mask. Those modes were combined with an empty coverage, so the first one hid an opaque solid. A later mask still intersects or darkens. --- crates/render/src/masks.rs | 7 +++++++ crates/render/src/tests.rs | 39 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/crates/render/src/masks.rs b/crates/render/src/masks.rs index 980e6475..969e8d64 100644 --- a/crates/render/src/masks.rs +++ b/crates/render/src/masks.rs @@ -145,9 +145,16 @@ pub fn apply(ctx: &EvalCtx, layer: &Layer, buf: &mut Buf, blur: MaskBlur) -> boo let n = (buf.img.width * buf.img.height) as usize; let first_sub = matches!(list[0].kind, GroupKind::Mask { mode: MaskMode::Subtract, .. }); let mut acc = vec![if first_sub { 1.0f32 } else { 0.0 }; n]; + // The first mask has nothing to combine with. Intersect and Darken would multiply or + // minimise against that empty coverage and hide the layer; they cover like Add instead. + // Lighten and Difference already do. Filling the accumulator first would make Add show + // the whole layer. + let mut first = true; for g in list { let GroupKind::Mask { mode, inverted, .. } = g.kind else { continue }; let Some(mut cov) = coverage(ctx, layer, g, buf, blur) else { continue }; + let mode = if first && matches!(mode, MaskMode::Intersect | MaskMode::Darken) { MaskMode::Add } else { mode }; + first = false; let op = (ctx.f(layer, g, "opacity", 100.0) / 100.0) as f32; cov.data.par_iter_mut().for_each(|c| { if inverted { diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 93c9a1a5..56d84b8d 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -80,6 +80,45 @@ fn mask_cuts_layer() { assert!(img.get(150, 50)[3] < 0.01); } +#[test] +fn first_intersect_and_darken_keep_their_rectangle() { + let render = |modes: &[MaskMode], centers: &[[f64; 2]]| { + let (mut p, cid, comp) = setup(); + let mut l = solid(&mut p, &comp, [1.0, 1.0, 1.0], 200, 100); + for (i, (mode, center)) in modes.iter().zip(centers).enumerate() { + let mut next = p.next_id; + let m = build::mask(&mut Ids(&mut next), &format!("Mask {}", i + 1), ShapePath::rect(*center, 40.0, 40.0), *mode, [255, 255, 0]); + p.next_id = next; + l.props.sub_mut("masks").unwrap().children.push(m.into()); + } + p.comp_mut(cid).unwrap().layers.push(l); + render_frame(&p, cid, Tick::ZERO, 1.0) + }; + // A first Intersect or Darken has nothing to combine with, so it covers like Add. + // Lighten and Difference already do; Subtract still starts from the whole layer. + for mode in [MaskMode::Intersect, MaskMode::Darken, MaskMode::Add, MaskMode::Lighten, MaskMode::Difference] { + let img = render(&[mode], &[[50.0, 50.0]]); + assert!(img.get(50, 50)[3] > 0.99, "{mode:?} keeps the rectangle"); + assert!(img.get(150, 50)[3] < 0.01, "{mode:?} stays outside it"); + } + let hole = render(&[MaskMode::Subtract], &[[50.0, 50.0]]); + assert!(hole.get(50, 50)[3] < 0.01, "subtract still cuts its rectangle"); + assert!(hole.get(150, 50)[3] > 0.99, "subtract leaves the rest of the layer"); + + // Add, then Intersect with a shifted rectangle: only the overlap remains. + let overlap = render(&[MaskMode::Add, MaskMode::Intersect], &[[50.0, 50.0], [70.0, 50.0]]); + assert!(overlap.get(50, 50)[3] > 0.99, "overlap stays"); + assert!(overlap.get(35, 50)[3] < 0.01, "the first rectangle's exclusive side is cut"); + assert!(overlap.get(85, 50)[3] < 0.01, "the second rectangle's exclusive side is cut"); + let darkened = render(&[MaskMode::Add, MaskMode::Darken], &[[50.0, 50.0], [70.0, 50.0]]); + assert!(darkened.get(50, 50)[3] > 0.99 && darkened.get(35, 50)[3] < 0.01 && darkened.get(85, 50)[3] < 0.01); + + // A later disjoint Add still contributes after a first Intersect. + let both = render(&[MaskMode::Intersect, MaskMode::Add], &[[50.0, 50.0], [150.0, 50.0]]); + assert!(both.get(50, 50)[3] > 0.99 && both.get(150, 50)[3] > 0.99); + assert!(both.get(100, 50)[3] < 0.01); +} + #[test] fn track_matte_alpha() { let (mut p, cid, comp) = setup(); From 9f53d9c6bc19610be049a659c4850c53817da38b Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sat, 10 Oct 2026 21:47:15 +1100 Subject: [PATCH 004/107] Move separated Position when the composition frame is resized or cropped. Those edits offset the combined Position property, which a separated layer does not render. X and Y Position, including their keys, now move by the same amount. --- crates/engine/src/commands/comp.rs | 15 +--- crates/engine/src/commands/comp_more.rs | 11 +-- crates/engine/src/commands/layer.rs | 31 ++++++++ crates/engine/src/tests_menu_cmds.rs | 95 +++++++++++++++++++++++++ 4 files changed, 130 insertions(+), 22 deletions(-) diff --git a/crates/engine/src/commands/comp.rs b/crates/engine/src/commands/comp.rs index a3267fa1..769ed362 100644 --- a/crates/engine/src/commands/comp.rs +++ b/crates/engine/src/commands/comp.rs @@ -36,19 +36,8 @@ fn apply_settings(c: &mut Comp, p: &Value) { let dy = (c.height as f64 - oh as f64) * (a / 3) as f64 / 2.0; if dx != 0.0 || dy != 0.0 { for l in c.layers.iter_mut().filter(|l| l.parent.is_none()) { - let Some(pr) = l.props.prop_mut("transform/position") else { continue }; - let shift = |v: &mut effectcraft_keyframe::Value| { - if let effectcraft_keyframe::Value::Vec2(x) = v { - x[0] += dx; - x[1] += dy; - } else if let effectcraft_keyframe::Value::Vec3(x) = v { - x[0] += dx; - x[1] += dy; - } - }; - shift(&mut pr.value); - for k in &mut pr.keys { - shift(&mut k.value); + if let Some(tr) = l.transform_mut() { + super::layer::offset_position(tr, dx, dy); } } } diff --git a/crates/engine/src/commands/comp_more.rs b/crates/engine/src/commands/comp_more.rs index 0f07fd67..2c4ca409 100644 --- a/crates/engine/src/commands/comp_more.rs +++ b/crates/engine/src/commands/comp_more.rs @@ -1,7 +1,6 @@ //! Composition menu, part two: crop to region of interest / layer bounds, Save Frame As ▸ File, //! Responsive Design — Time. -use effectcraft_keyframe::Value as KV; use effectcraft_project::Marker; use effectcraft_time::Tick; use serde_json::{Value, json}; @@ -24,14 +23,8 @@ fn crop(s: &mut Session, cid: effectcraft_project::ItemId, r: [f64; 4], label: & c.width = w; c.height = h; for l in c.layers.iter_mut().filter(|l| l.parent.is_none()) { - let Some(pos) = l.transform_mut().and_then(|tr| tr.get_mut("position")) else { continue }; - let mv = |v: &KV| { - let a = v.as_vec3(); - KV::Vec3([a[0] - x, a[1] - y, a[2]]) - }; - pos.value = mv(&pos.value); - for k in &mut pos.keys { - k.value = mv(&k.value); + if let Some(tr) = l.transform_mut() { + super::layer::offset_position(tr, -x, -y); } } for g in &mut c.guides { diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index eaf70cf7..0c8472c8 100644 --- a/crates/engine/src/commands/layer.rs +++ b/crates/engine/src/commands/layer.rs @@ -1124,6 +1124,37 @@ fn set_text(s: &mut Session, p: &Value) -> Result { Ok(Value::Null) } +/// Move Position by `(dx, dy)`, including every key. Separated dimensions move X and Y as well +/// as the combined property the channels were split from. +pub(crate) fn offset_position(tr: &mut PropGroup, dx: f64, dy: f64) { + if dx == 0.0 && dy == 0.0 { + return; + } + if tr.get("positionX").is_some() { + for (name, d) in [("positionX", dx), ("positionY", dy)] { + if let Some(pr) = tr.get_mut(name) { + pr.value = KV::Scalar(pr.value.as_f64() + d); + for k in &mut pr.keys { + k.value = KV::Scalar(k.value.as_f64() + d); + } + } + } + } + if let Some(pr) = tr.get_mut("position") { + let shift = |v: &KV| match v { + KV::Vec2(a) => KV::Vec2([a[0] + dx, a[1] + dy]), + other => { + let a = other.as_vec3(); + KV::Vec3([a[0] + dx, a[1] + dy, a[2]]) + } + }; + pr.value = shift(&pr.value); + for k in &mut pr.keys { + k.value = shift(&k.value); + } + } +} + /// Set a layer's Position at layer time `lt` to `f(current)`: X/Y/Z Position when its dimensions /// are separated. pub(crate) fn update_position(tr: &mut PropGroup, lt: Tick, f: impl Fn([f64; 3]) -> [f64; 3]) { diff --git a/crates/engine/src/tests_menu_cmds.rs b/crates/engine/src/tests_menu_cmds.rs index c342f72f..01886936 100644 --- a/crates/engine/src/tests_menu_cmds.rs +++ b/crates/engine/src/tests_menu_cmds.rs @@ -3,6 +3,7 @@ use effectcraft_keyframe::Value as KV; use effectcraft_project::{FrameBlend, ItemKind, MatteKind, Quality, Sampling}; +use effectcraft_time::Tick; use serde_json::{Value, json}; use crate::{EngineError, Event, Session}; @@ -103,6 +104,100 @@ fn transform_dialog_values_and_center_anchor() { assert_eq!(layer(&s, a).auto_orient, effectcraft_project::AutoOrient::AlongPath); } +/// Inclusive bounds of samples whose alpha is above one half, plus the frame size and the count. +fn opaque_bounds(s: &Session, t: Tick) -> (u32, u32, i64, i64, i64, i64, u32) { + let img = s.render(s.active_comp_id().unwrap(), t, Default::default()); + let mut n = 0u32; + let mut x0 = i64::MAX; + let mut y0 = i64::MAX; + let mut x1 = i64::MIN; + let mut y1 = i64::MIN; + for y in 0..img.height as i64 { + for x in 0..img.width as i64 { + if img.get(x, y)[3] > 0.5 { + n += 1; + x0 = x0.min(x); + y0 = y0.min(y); + x1 = x1.max(x); + y1 = y1.max(y); + } + } + } + (img.width, img.height, x0, y0, x1, y1, n) +} + +#[test] +fn separated_position_follows_canvas_resize_and_crop() { + let square = |separated: bool| { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Test", "width": 100, "height": 100, "frameRate": 10, "duration": 2})).unwrap(); + let a = s.execute("layer.newSolid", json!({"name": "Square", "color": "#ffffff", "width": 10, "height": 10})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setTransform", json!({"layer": a, "prop": "position", "value": [40, 50]})).unwrap(); + if separated { + s.execute("prop.separateDimensions", json!({"layer": a, "value": true})).unwrap(); + } + (s, a) + }; + let placed = |s: &Session, a: u64, t: f64, x: f64, y: f64| { + let l = layer(s, a); + let tr = l.transform().unwrap(); + let at = Tick::from_seconds_f64(t); + if tr.get("positionX").is_some() { + assert!((tr.get("positionX").unwrap().value_at(at).as_f64() - x).abs() < 1e-6); + assert!((tr.get("positionY").unwrap().value_at(at).as_f64() - y).abs() < 1e-6); + } + let p = tr.get("position").unwrap().value_at(at).as_vec3(); + assert!((p[0] - x).abs() < 1e-6 && (p[1] - y).abs() < 1e-6, "combined {p:?}"); + }; + + let (mut combined, a) = square(false); + let (mut separated, b) = square(true); + assert_eq!(opaque_bounds(&combined, Tick::ZERO), opaque_bounds(&separated, Tick::ZERO)); + combined.execute("comp.settings", json!({"width": 200})).unwrap(); + separated.execute("comp.settings", json!({"width": 200})).unwrap(); + placed(&separated, b, 0.0, 90.0, 50.0); + placed(&combined, a, 0.0, 90.0, 50.0); + assert_eq!(opaque_bounds(&separated, Tick::ZERO), (200, 100, 85, 45, 94, 54, 100)); + assert_eq!(opaque_bounds(&combined, Tick::ZERO), opaque_bounds(&separated, Tick::ZERO)); + + let (mut separated, b) = square(true); + separated.execute("view.setRegionOfInterest", json!({"rect": [10, 20, 70, 70]})).unwrap(); + separated.execute("comp.cropToRegionOfInterest", json!({})).unwrap(); + placed(&separated, b, 0.0, 30.0, 30.0); + assert_eq!(opaque_bounds(&separated, Tick::ZERO), (70, 70, 25, 25, 34, 34, 100)); + + let (mut separated, b) = square(true); + separated.execute("layer.select", json!({"layers": [b]})).unwrap(); + separated.execute("comp.cropToLayerBounds", json!({})).unwrap(); + let bounds = opaque_bounds(&separated, Tick::ZERO); + assert_eq!((bounds.0, bounds.1, bounds.6), (10, 10, 100)); + assert!(bounds.2 == 0 && bounds.3 == 0, "the square fills the cropped frame: {bounds:?}"); + + // Two Position keys keep their spacing after the same canvas offset. + let (mut separated, b) = square(true); + separated.execute("edit.undo", json!({})).unwrap(); + separated.execute("prop.addKey", json!({"layer": b, "path": "transform/position", "time": 0, "value": [40, 50]})).unwrap(); + separated.execute("prop.addKey", json!({"layer": b, "path": "transform/position", "time": 1, "value": [60, 50]})).unwrap(); + separated.execute("prop.separateDimensions", json!({"layer": b, "value": true})).unwrap(); + separated.execute("comp.settings", json!({"width": 200})).unwrap(); + placed(&separated, b, 0.0, 90.0, 50.0); + placed(&separated, b, 1.0, 110.0, 50.0); + assert_eq!(opaque_bounds(&separated, Tick::ZERO).2, 85); + assert_eq!(opaque_bounds(&separated, Tick::from_seconds_f64(1.0)).2, 105); + + let (mut separated, b) = square(true); + separated.execute("edit.undo", json!({})).unwrap(); + separated.execute("prop.addKey", json!({"layer": b, "path": "transform/position", "time": 0, "value": [40, 50]})).unwrap(); + separated.execute("prop.addKey", json!({"layer": b, "path": "transform/position", "time": 1, "value": [60, 50]})).unwrap(); + separated.execute("prop.separateDimensions", json!({"layer": b, "value": true})).unwrap(); + separated.execute("view.setRegionOfInterest", json!({"rect": [10, 20, 70, 70]})).unwrap(); + separated.execute("comp.cropToRegionOfInterest", json!({})).unwrap(); + placed(&separated, b, 0.0, 30.0, 30.0); + placed(&separated, b, 1.0, 50.0, 30.0); + assert_eq!(opaque_bounds(&separated, Tick::ZERO), (70, 70, 25, 25, 34, 34, 100)); + assert_eq!(opaque_bounds(&separated, Tick::from_seconds_f64(1.0)), (70, 70, 45, 25, 54, 34, 100)); +} + #[test] fn mask_menu_on_existing_masks() { let mut s = comp(); From 4e8a906fae424bd81bfdca86dd68e2d17f89b4bb Mon Sep 17 00:00:00 2001 From: Fabio Lussi <198260595+itrycodinganditsbad@users.noreply.github.com> Date: Sat, 10 Oct 2026 13:52:49 +0200 Subject: [PATCH 005/107] Rotation: dragging the degrees field can go negative The degrees field shows the remainder's size, so a drag was applied to the size and the sign was re-applied afterwards; past zero it flipped back to positive. A drag now moves the angle itself. Fixes #569 Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/fx_widgets.rs | 32 +++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels/fx_widgets.rs b/crates/ui-egui/src/panels/fx_widgets.rs index 036ae070..ca49c3fd 100644 --- a/crates/ui-egui/src/panels/fx_widgets.rs +++ b/crates/ui-egui/src/panels/fx_widgets.rs @@ -41,6 +41,17 @@ pub fn parse_angle(s: &str) -> Option { s.parse().ok() } +/// The angle after the degrees field of `v` (which shows `shown`, the remainder's size) went to +/// `new`. A drag moves the angle by the pointer's distance, so it runs through zero into negative +/// angles; a typed number is a size, so the remainder keeps its sign. +fn degrees_edit(v: f64, shown: f64, new: f64, dragging: bool) -> f64 { + if dragging { + return v + (new - shown); + } + let (rev, deg) = split_angle(v); + rev as f64 * 360.0 + if deg < 0.0 || (deg == 0.0 && v < 0.0) { -new } else { new } +} + /// An angle value, `0x+45.0°`, at `at` (top left): the revolutions and the degrees /// are hot numbers of their own (drag to scrub, click to type) and changing one keeps the other. /// Returns the revolutions' rect, the degrees' rect and the new angle when either changed. @@ -49,10 +60,10 @@ pub fn angle_field(ui: &mut egui::Ui, at: Pos2, id: egui::Id, v: f64, decimals: let (rr, new_rev, _) = widgets::hot_int_at(ui, at, id.with("rev"), rev, 0.1, "x", t); let sign = ui.painter().text(pos2(rr.max.x - 2.0, rr.center().y), Align2::LEFT_CENTER, if deg < 0.0 { "-" } else { "+" }, Tokens::ui(12.0), t.hot_text); let (dr, new_deg, _) = widgets::hot_number_at(ui, pos2(sign.max.x - 2.0, at.y), id, deg.abs(), 0.5, (-1e9, 1e9), decimals, "°", t); + let dragging = new_deg.is_some() && ui.ctx().is_being_dragged(id); let out = match (new_rev, new_deg) { (Some(r), _) => Some(r as f64 * 360.0 + deg), - // The degrees field shows the remainder's size; its sign stays. - (None, Some(d)) => Some(rev as f64 * 360.0 + if deg < 0.0 || (deg == 0.0 && v < 0.0) { -d } else { d }), + (None, Some(d)) => Some(degrees_edit(v, deg.abs(), d, dragging)), (None, None) => None, }; (rr, dr, out) @@ -551,6 +562,23 @@ mod tests { assert_eq!(split_angle(-360.0), (-1, 0.0)); } + #[test] + fn dragging_degrees_crosses_zero_into_negative_angles() { + // The field shows the size (0.3), so a 1-degree drag left lands on -0.7, and keeps going. + let mut v = 0.3; + for _ in 0..3 { + let (_, deg) = split_angle(v); + v = degrees_edit(v, deg.abs(), deg.abs() - 1.0, true); + } + assert!((v + 2.7).abs() < 1e-9, "{v}"); + // And back up through zero. + let (_, deg) = split_angle(v); + assert!((degrees_edit(v, deg.abs(), deg.abs() + 3.0, true) - 0.3).abs() < 1e-9); + // Typing keeps the sign shown. + assert_eq!(degrees_edit(-30.0, 30.0, 45.0, false), -45.0); + assert_eq!(degrees_edit(405.0, 45.0, 10.0, false), 370.0); + } + #[test] fn angle_parsing_round_trips() { for v in [0.0, 45.0, 405.0, -30.5, -400.0, 1080.25] { From 00156ce22ed7d415790c3b67d7439f4967f32bde Mon Sep 17 00:00:00 2001 From: Fabio Lussi <198260595+itrycodinganditsbad@users.noreply.github.com> Date: Sat, 10 Oct 2026 13:57:45 +0200 Subject: [PATCH 006/107] Effect reset: restore parameters inside twirl-down groups effect.reset looked parameters up by their full spec id among the effect's direct children, so every 'group/param' id (most of Fractal, Scribble, and other grouped effects) was skipped and Reset did nothing for them. Fixes #526 Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands/effect.rs | 3 ++- crates/engine/src/tests_effects.rs | 23 +++++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/commands/effect.rs b/crates/engine/src/commands/effect.rs index 68684650..fe0d4210 100644 --- a/crates/engine/src/commands/effect.rs +++ b/crates/engine/src/commands/effect.rs @@ -387,7 +387,8 @@ fn reset(s: &mut Session, p: &Value) -> Result { s.edit(&format!("Reset {name}"), None, |proj, _| { let g = layer_mut(proj, cid, lid)?.props.find_group_mut(uid).ok_or_else(|| bad("effect.reset", "gone"))?; for ps in &spec.params { - if let Some(pr) = g.get_mut(ps.id) { + // (`group/param` ids live in twirl-down groups.) + if let Some(pr) = g.prop_mut(ps.id) { pr.set_value_at(lt, effectcraft_effects::default_value(ps, size)); } } diff --git a/crates/engine/src/tests_effects.rs b/crates/engine/src/tests_effects.rs index 2f164991..82f51036 100644 --- a/crates/engine/src/tests_effects.rs +++ b/crates/engine/src/tests_effects.rs @@ -110,6 +110,29 @@ fn reset_effect_restores_defaults_in_one_undo_step() { assert_eq!(g.get("start").unwrap().value, KV::Vec2([10.0, 20.0])); } +#[test] +fn reset_restores_parameters_inside_twirl_down_groups() { + let (mut s, a, _) = comp_with_two_solids(); + // Fractal and Scribble keep most parameters in groups (`mandelbrot/…`, `edgeOptions/…`). + for (effect, changes) in [ + ("Fractal", [("setChoice", json!(1)), ("mandelbrot/mandelbrotX", json!(1.25)), ("fractalColor/hue", json!(90.0))]), + ("Scribble", [("scribble", json!(2)), ("edgeOptions/edgeWidth", json!(33.0)), ("strokeOptions/spacing", json!(9.0))]), + ] { + let fx = apply(&mut s, a, effect); + let group = |s: &Session| layer(s, a).effects().unwrap().groups().find(|g| g.uid == fx).unwrap().clone(); + let defaults: Vec<_> = changes.iter().map(|(id, _)| group(&s).prop(id).unwrap().value.clone()).collect(); + for (id, v) in &changes { + let uid = group(&s).prop(id).unwrap().uid; + s.execute("prop.set", json!({"layer": a, "prop": uid, "value": v})).unwrap(); + assert_ne!(group(&s).prop(id).unwrap().value, defaults[changes.iter().position(|(i, _)| i == id).unwrap()], "{effect} {id} changed"); + } + s.execute("effect.reset", json!({"layer": a, "effect": fx})).unwrap(); + for ((id, _), d) in changes.iter().zip(&defaults) { + assert_eq!(&group(&s).prop(id).unwrap().value, d, "{effect} {id} back to its default"); + } + } +} + #[test] fn reset_keys_animated_params_at_the_cti() { let (mut s, a, _) = comp_with_two_solids(); From 98cdc981ad674fe34a417a20c8893844701ad638 Mon Sep 17 00:00:00 2001 From: Katt <238968166+KattKnight@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:21:21 +0530 Subject: [PATCH 007/107] Footage: a file that can't be read is tried again once it changes, not on every look A footage file the automatic reload couldn't read (still being written, or one the importer doesn't read) kept its old stamp, so every two-second look saw it as changed and probed it again. It now keeps its new stamp and is tried again once it changes again (when the other app finishes writing it). Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/footage_reload.rs | 9 ++++----- docs/preferences.md | 2 +- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/footage_reload.rs b/crates/engine/src/footage_reload.rs index 0d26a45c..3a7f0719 100644 --- a/crates/engine/src/footage_reload.rs +++ b/crates/engine/src/footage_reload.rs @@ -184,8 +184,8 @@ fn forget(s: &mut Session, paths: &BTreeSet) { /// time is only remembered; one whose stamp moved is reloaded: the items reading it get their /// size and duration from it again and its pixels are read again. A file that can't be stamped /// right now (an app saving by delete and rename) keeps its last stamp, and one that can't be -/// read yet (still being written) keeps its old stamp so the next scan tries again; missing -/// files are left to the footage check. Returns `{files, reloaded}`. +/// read (still being written) is tried again once it changes again; missing files are left to +/// the footage check. Returns `{files, reloaded}`. pub fn apply_stamps(s: &mut Session, now: Vec<(String, Option)>) -> crate::Result { let before = std::mem::take(&mut s.footage_stamps); let mut changed = BTreeSet::new(); @@ -209,10 +209,9 @@ pub fn apply_stamps(s: &mut Session, now: Vec<(String, Option)>) -> crate } let ids: Vec = s.project.items.values().filter(|i| matches!(&i.kind, ItemKind::Footage(f) if changed.contains(&f.path))).map(|i| i.id).collect(); let (reloaded, failed) = if ids.is_empty() { (0, BTreeSet::new()) } else { reload(s, &ids, false)? }; + // Not readable (still being written, or not a file the importer reads): its new stamp is kept, + // so it is tried again once it changes again, not on every look. for p in &failed { - if let Some(old) = before.get(p) { - s.footage_stamps.insert(p.clone(), *old); - } changed.remove(p); } if changed.is_empty() { diff --git a/docs/preferences.md b/docs/preferences.md index 75c606b7..f6909cef 100644 --- a/docs/preferences.md +++ b/docs/preferences.md @@ -114,7 +114,7 @@ Commands (CLI, MCP, control channel): size and modification time with what they were, and reloads what another app saved: a Photoshop file edited in PhotoCraft or Photoshop shows its new pixels without File ▸ Reload Footage. It is not an undo step and doesn't modify the project; a layer item stays on its layer - (found again by name), and a file still being written is tried again on the next check. + (found again by name), and a file that can't be read yet (still being written) is tried again once it changes again. `footage.reloadChanged` runs the same check once (CLI, agents) - `import.unlabeledAlpha`: alpha of TGA / TIFF / movie footage (Ask opens Interpret Footage; Guess takes premultiplied for movies, straight for stills) From d86c4aff520feaec3ae5cce12f99229e941cc0e8 Mon Sep 17 00:00:00 2001 From: Jeremy Yao Date: Sat, 10 Oct 2026 09:36:59 -0400 Subject: [PATCH 008/107] Startup: a launch that stops as its window is shown switches the next to OpenGL On an ASUS G513QY (Radeon RX 6800M, Adrenalin 26.9.2, Windows 11) every launch died with STATUS_ACCESS_VIOLATION inside amdvlk64.dll, on a driver worker thread, a moment after the first frame: eframe shows the window it created hidden, the resulting resize re-creates the swapchain, and the driver crashes there (same offset on two driver versions, with the OBS/Steam/Epic Vulkan layers disabled, and with FIFO, immediate and latency-1 presentation). DirectX 12 and OpenGL open the window fine. A crash in a driver can't be caught, and the launch guard (#243) removed its marker at the first frame, so this looked like a successful launch every time and Window Graphics never switched. The marker now stays until the window has kept drawing for SETTLE (3 s) or the app quits normally before that; frame_drawn returns the time left and the app asks for a repaint then, so an idle window settles too. The notice, the Settings note and docs/preferences.md describe the new rule. Verified on that machine with a scratch settings folder: launch 1 (Vulkan) crashed and left the marker, launch 2 switched to OpenGL with the notice and closed with exit code 0, launch 3 stayed on OpenGL without the notice. Tests: effectcraft launch_guard a_launch_that_stops_after_its_first_frame_switches_the_next_to_opengl (a first frame then drop: the next launch gets GL and the notice; a normal quit in that time leaves no marker), and the existing test now asserts that the first frames don't settle a launch (marker present at SETTLE/2, gone at SETTLE). Co-Authored-By: Claude Fable 5.1 --- apps/effectcraft/src/launch_guard.rs | 111 ++++++++++++++++++++++----- apps/effectcraft/src/main.rs | 11 ++- crates/engine/src/prefs.rs | 6 +- docs/preferences.md | 2 +- 4 files changed, 102 insertions(+), 28 deletions(-) diff --git a/apps/effectcraft/src/launch_guard.rs b/apps/effectcraft/src/launch_guard.rs index 14c4c440..fac91c05 100644 --- a/apps/effectcraft/src/launch_guard.rs +++ b/apps/effectcraft/src/launch_guard.rs @@ -1,39 +1,52 @@ //! Settings ▸ Startup & Repair ▸ Window Graphics, and the launch guard behind it. //! //! Some graphics drivers crash EffectCraft as its window's device is created (an access -//! violation in an older Intel UHD driver with DirectX 12 / Vulkan, #243). A crash in a driver -//! can't be caught, so each launch leaves a marker in the settings folder until its window has -//! drawn a frame. A launch that finds the previous one's marker switches Window Graphics to -//! OpenGL, which those drivers run, and says so once the window is up. `WGPU_BACKEND` (wgpu's -//! own override) still wins over both. The marker is locked while its launch runs, so a second -//! copy opened meanwhile isn't taken for a crash; and a window OpenGL can't open either puts -//! Window Graphics back to Automatic ([`opengl_failed`]) rather than failing every launch. +//! violation in an older Intel UHD driver with DirectX 12 / Vulkan, #243), or a moment after +//! its first frame, as the window is shown and its surface re-created (AMD's Vulkan driver for +//! a Radeon RX 6800M, Adrenalin 26.9.2: an access violation in amdvlk64.dll on every launch). +//! A crash in a driver can't be caught, so each launch leaves a marker in the settings folder +//! until its window has kept drawing for [`SETTLE`] (or the app quits normally before then). A +//! launch that finds the previous one's marker switches Window Graphics to OpenGL, which those +//! drivers run, and says so once the window is up. `WGPU_BACKEND` (wgpu's own override) still +//! wins over both. The marker is locked while its launch runs, so a second copy opened meanwhile +//! isn't taken for a crash; and a window OpenGL can't open either puts Window Graphics back to +//! Automatic ([`opengl_failed`]) rather than failing every launch. use std::fs::File; use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; use effectcraft_engine::prefs::{PREFS_FILE, Prefs}; use eframe::wgpu::Backends; -/// Left in the settings folder from a launch until its window draws. +/// Left in the settings folder from a launch until its window has settled. const MARKER: &str = "launch-pending"; +/// How long the window keeps drawing after its first frame before the launch counts as made. +/// A driver that fails as the window is shown and its surface re-created does so within the +/// first second; a user quitting sooner than this is told the next launch uses OpenGL and can +/// switch back in Settings. +pub const SETTLE: Duration = Duration::from_secs(3); + /// This launch's window graphics. #[derive(Debug, Default)] pub struct Launch { /// The graphics APIs the window may use (`None`: eframe's default). pub backends: Option, - /// Switched to OpenGL because the previous launch never drew: the message to show. + /// Switched to OpenGL because the previous launch stopped as its window opened: the message + /// to show. pub notice: Option, marker: Option, - /// Holds the marker's lock until the window draws (or the process ends). + /// Holds the marker's lock until the launch settles (or the process ends). lock: Option, + /// When the window drew its first frame, until the launch settles. + first_frame: Option, } impl Launch { /// Read Window Graphics in the settings folder `dir`, switch it to OpenGL when the previous - /// launch never drew (where OpenGL exists: not macOS), and leave this launch's marker. - /// `env_override`: `WGPU_BACKEND` is set. + /// launch stopped as its window opened (where OpenGL exists: not macOS), and leave this + /// launch's marker. `env_override`: `WGPU_BACKEND` is set. pub fn begin(dir: Option<&Path>, env_override: bool) -> Launch { let Some(dir) = dir.filter(|_| !env_override) else { return Launch::default() }; let prefs_path = dir.join(PREFS_FILE); @@ -45,11 +58,11 @@ impl Launch { if marker.exists() && !held && prefs.startup.window_graphics == "auto" && cfg!(not(target_os = "macos")) { prefs.startup.window_graphics = "gl".into(); match std::fs::create_dir_all(dir).and_then(|()| std::fs::write(&prefs_path, prefs.to_json())) { - Ok(()) => log::warn!("the last launch never drew its window: switching Window Graphics to OpenGL"), - Err(e) => log::warn!("the last launch never drew its window; saving Window Graphics failed: {e}"), + Ok(()) => log::warn!("the last launch stopped as its window opened: switching Window Graphics to OpenGL"), + Err(e) => log::warn!("the last launch stopped as its window opened; saving Window Graphics failed: {e}"), } notice = Some( - "EffectCraft's last launch stopped before its window opened, so it now draws with OpenGL \ + "EffectCraft's last launch stopped as its window opened, so it now draws with OpenGL \ (Settings ▸ Startup & Repair ▸ Window Graphics). Updating the graphics driver may let it use \ Automatic again." .to_string(), @@ -64,11 +77,33 @@ impl Launch { (None, None) } }; - Launch { backends, notice, marker, lock } + Launch { backends, notice, marker, lock, first_frame: None } } - /// The window drew its first frame: the launch made it. - pub fn drawn(&mut self) { + /// The window drew a frame. Until the launch has settled, returns how long that still takes + /// (ask for a repaint then, so that an idle window settles too). + pub fn frame_drawn(&mut self) -> Option { + self.frame_drawn_at(Instant::now()) + } + + fn frame_drawn_at(&mut self, now: Instant) -> Option { + self.marker.as_ref()?; + let first = *self.first_frame.get_or_insert(now); + let left = SETTLE.saturating_sub(now.saturating_duration_since(first)); + if left.is_zero() { + self.settled(); + return None; + } + Some(left) + } + + /// The app quits normally: however soon after opening, that's no crash. + pub fn exited(&mut self) { + self.settled(); + } + + /// The launch made it: release and remove its marker. + fn settled(&mut self) { self.lock = None; if let Some(m) = self.marker.take() && let Err(e) = std::fs::remove_file(&m) @@ -103,12 +138,18 @@ mod tests { fn a_launch_that_never_drew_switches_the_next_to_opengl() { let dir = std::env::temp_dir().join(format!("ec-launch-guard-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); - // A first launch that draws: nothing changes. + // A first launch that draws: nothing changes. Its first frames don't settle it (a driver + // may still crash as the window is shown); frames for `SETTLE` do. let mut l = Launch::begin(Some(&dir), false); assert_eq!((l.backends, l.notice.is_some()), (None, false)); assert!(dir.join(MARKER).exists()); - l.drawn(); + let t0 = Instant::now(); + assert_eq!(l.frame_drawn_at(t0), Some(SETTLE)); + assert!(l.frame_drawn_at(t0 + SETTLE / 2).is_some_and(|left| left <= SETTLE / 2)); + assert!(dir.join(MARKER).exists()); + assert_eq!(l.frame_drawn_at(t0 + SETTLE), None); assert!(!dir.join(MARKER).exists()); + assert_eq!(l.frame_drawn_at(t0 + SETTLE), None, "settled stays settled"); // A launch that crashes before drawing (it never calls `drawn`)… drop(Launch::begin(Some(&dir), false)); // …makes the next one use OpenGL and say so, once. @@ -119,7 +160,7 @@ mod tests { assert_eq!(l.backends, Some(Backends::GL)); assert!(l.notice.is_some()); assert_eq!(window_graphics(&dir), "gl", "saved in Settings"); - l.drawn(); + l.exited(); let l = Launch::begin(Some(&dir), false); assert_eq!((l.backends, l.notice.is_some()), (Some(Backends::GL), false), "it stays, without the notice"); } @@ -130,6 +171,34 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + /// A launch that stops moments after its first frame (AMD's Vulkan driver crashing as the + /// window is shown and its surface re-created) switches the next one to OpenGL too; a + /// normal quit in that time doesn't. + #[test] + fn a_launch_that_stops_after_its_first_frame_switches_the_next_to_opengl() { + let dir = std::env::temp_dir().join(format!("ec-launch-guard-3-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let mut l = Launch::begin(Some(&dir), false); + assert!(l.frame_drawn().is_some()); + drop(l); + let l = Launch::begin(Some(&dir), false); + if cfg!(target_os = "macos") { + assert_eq!(l.backends, None, "no OpenGL on macOS"); + } else { + assert_eq!(l.backends, Some(Backends::GL)); + assert!(l.notice.is_some()); + } + drop(l); + let _ = std::fs::remove_dir_all(&dir); + // Quitting right after the window opened leaves no marker. + let mut l = Launch::begin(Some(&dir), false); + assert!(l.frame_drawn().is_some()); + l.exited(); + assert!(!dir.join(MARKER).exists()); + assert_eq!(Launch::begin(Some(&dir), false).backends, None); + let _ = std::fs::remove_dir_all(&dir); + } + /// A second copy opened while the first is still opening isn't a crash; OpenGL that can't /// open a window either goes back to Automatic instead of failing every launch. #[test] diff --git a/apps/effectcraft/src/main.rs b/apps/effectcraft/src/main.rs index 72afab5a..b4e39836 100644 --- a/apps/effectcraft/src/main.rs +++ b/apps/effectcraft/src/main.rs @@ -78,8 +78,8 @@ fn main() -> eframe::Result { // Wayland shows the window's icon from its desktop entry: an AppImage brings its own. #[cfg(target_os = "linux")] appimage::integrate_from_env(ICON_PNG); - // Settings ▸ Startup & Repair ▸ Window Graphics, switched to OpenGL when the last launch's - // window never drew (a crashing graphics driver, #243). + // Settings ▸ Startup & Repair ▸ Window Graphics, switched to OpenGL when the last launch + // stopped as its window opened (a crashing graphics driver, #243). let mut launch = launch_guard::Launch::begin(effectcraft_host::config_dir().as_deref(), std::env::var_os("WGPU_BACKEND").is_some()); let on_gl = launch.backends == Some(eframe::wgpu::Backends::GL); let notice = launch.notice.take(); @@ -290,12 +290,17 @@ impl eframe::App for Desktop { self.app.ui(ui, frame); if !self.drawn { self.drawn = true; - self.launch.drawn(); log::info!("first frame drawn"); } + // The launch guard's marker goes once the window has kept drawing for a few seconds: a + // driver may still crash as the window is shown and its surface re-created. + if let Some(left) = self.launch.frame_drawn() { + ui.ctx().request_repaint_after(left); + } } fn on_exit(&mut self) { + self.launch.exited(); self.app.on_exit(); } diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 4ee6cc97..852c44c5 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -96,8 +96,8 @@ page!(Startup { /// After a crash, offer to open the most recent auto-save. offer_crash_recovery: bool = true, /// What the desktop window draws with, from the next launch: `auto` (the platform's best - /// graphics API) or `gl` (OpenGL, for drivers that crash with the others). A launch whose - /// window never drew switches it to `gl` (#243). + /// graphics API) or `gl` (OpenGL, for drivers that crash with the others). A launch that + /// stops as its window opens switches it to `gl` (#243). window_graphics: String = "auto".into(), }); @@ -948,7 +948,7 @@ pub fn pages() -> Vec { s("startup.offerCrashRecovery", "Offer to Open the Latest Auto-Save After a Crash", B, true), s("startup.windowGraphics", "Window Graphics", Kind::Choice(WINDOW_GRAPHICS), true), Note( - "Window Graphics applies from the next launch. When the graphics driver stops EffectCraft before its window draws, the next launch switches to OpenGL.", + "Window Graphics applies from the next launch. When the graphics driver stops EffectCraft as its window opens, the next launch switches to OpenGL.", ), Section("Repair"), Button { label: "Reset Settings", command: "prefs.reset", params: "{}" }, diff --git a/docs/preferences.md b/docs/preferences.md index 9e3d4147..426f5d25 100644 --- a/docs/preferences.md +++ b/docs/preferences.md @@ -38,7 +38,7 @@ Commands (CLI, MCP, control channel): - `general.defaultSpatialLinear`: Default Spatial Interpolation to Linear - `startup.showHomeOnLaunch`: Show Home Screen When Launching - `startup.offerCrashRecovery`: Offer to Open the Latest Auto-Save After a Crash -- `startup.windowGraphics`: Window Graphics (`auto` / `gl`), what the desktop window draws with from the next launch. `auto` lets the platform pick (DirectX 12, Vulkan or Metal); `gl` uses OpenGL, with CPU compositing, for graphics drivers that crash with the others. A launch whose window never drew leaves a `launch-pending` marker in the settings folder, and the next launch switches to `gl` and says so (not on macOS, which has no OpenGL backend). `WGPU_BACKEND` overrides both. +- `startup.windowGraphics`: Window Graphics (`auto` / `gl`), what the desktop window draws with from the next launch. `auto` lets the platform pick (DirectX 12, Vulkan or Metal); `gl` uses OpenGL, with CPU compositing, for graphics drivers that crash with the others. A launch leaves a `launch-pending` marker in the settings folder until its window has been drawing for a few seconds (or the app quits normally before that); a launch that stops earlier (a graphics driver crashing as the window opens or as its surface is re-created) leaves the marker behind, and the next launch switches to `gl` and says so (not on macOS, which has no OpenGL backend). `WGPU_BACKEND` overrides both. - `project.useTemplate`: New Project Loads Template - `project.templatePath`: Template Project - `autoSave.enabled`: Automatically Save Projects From a91952dadc54a0a572a16b98a7f1bf7b3513c986 Mon Sep 17 00:00:00 2001 From: Jeremy Yao Date: Sat, 10 Oct 2026 09:36:59 -0400 Subject: [PATCH 009/107] Effects: the .spimtx parser reads its rows with as_chunks clippy 1.99 (current stable, which CI tracks unpinned) fails the workspace gate on chunks_exact_to_as_chunks in parse_spimtx. The length is already checked to be 12, so as_chunks::<4>() has no remainder and the behaviour is unchanged; the workspace's rust-version (1.95) has it. Co-Authored-By: Claude Fable 5.1 --- crates/effects/src/ocio.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/effects/src/ocio.rs b/crates/effects/src/ocio.rs index d00f6e9e..c8fcf848 100644 --- a/crates/effects/src/ocio.rs +++ b/crates/effects/src/ocio.rs @@ -372,7 +372,7 @@ pub fn parse_spimtx(text: &str) -> Option { } let mut m = [0.0; 16]; let mut offset = [0.0; 4]; - for (r, row) in v.chunks_exact(4).enumerate() { + for (r, row) in v.as_chunks::<4>().0.iter().enumerate() { m[r * 4..r * 4 + 3].copy_from_slice(&row[..3]); offset[r] = row[3] / 65535.0; } From 2a404f6226791c132cb1c0fef13e4f1147f4e271 Mon Sep 17 00:00:00 2001 From: Jeremy Yao Date: Sat, 10 Oct 2026 09:37:00 -0400 Subject: [PATCH 010/107] MCP: the auto-save recovery tests look for the JSON-quoted path automation autosave_is_durable_before_reply_and_queries_do_not_rotate and the CLI's mcp_autosave_survives_kill_and_restart_without_touching_ desktop_settings asserted that the initialize instructions contain the previous session's auto-save path as raw text. The instructions embed the auto-save info as JSON, so on Windows the path's backslashes are escaped and the raw match can never succeed (both failed on every Windows run; the server's reply is right). They now look for the path as serde_json prints it, which also matches on Linux and macOS. Co-Authored-By: Claude Fable 5.1 --- apps/effectcraft-cli/tests/cli.rs | 4 +++- crates/automation/src/tests.rs | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/effectcraft-cli/tests/cli.rs b/apps/effectcraft-cli/tests/cli.rs index 723016a8..51bdcc4c 100644 --- a/apps/effectcraft-cli/tests/cli.rs +++ b/apps/effectcraft-cli/tests/cli.rs @@ -373,7 +373,9 @@ fn mcp_autosave_survives_kill_and_restart_without_touching_desktop_settings() { assert_eq!(manifest["ended"], false); let mut restarted = McpChild::start(&config, true); let init = restarted.rpc("initialize", json!({})); - assert!(init["instructions"].as_str().unwrap().contains(path)); + // The instructions embed the auto-save info as JSON, so the path appears JSON-quoted (on + // Windows with its backslashes escaped). + assert!(init["instructions"].as_str().unwrap().contains(&json!(path).to_string())); assert_eq!(init["_meta"]["effectcraftAutoSave"]["previousSessions"][0]["autosave"], path); restarted.tool("open_project", json!({"path":path})); let comp = restarted.tool("get_comp", json!({"comp":"Survives restart"})); diff --git a/crates/automation/src/tests.rs b/crates/automation/src/tests.rs index 44a61cc1..b4090ca7 100644 --- a/crates/automation/src/tests.rs +++ b/crates/automation/src/tests.rs @@ -269,7 +269,9 @@ fn autosave_is_durable_before_reply_and_queries_do_not_rotate() { drop(s); // Abrupt process loss has no EOF cleanup; the checkpoint already exists. let mut restarted = server().with_autosave(&dir).unwrap(); let init = rpc(&mut restarted, 1, "initialize", json!({})); - assert!(init["instructions"].as_str().unwrap().contains(path)); + // The instructions embed the auto-save info as JSON, so the path appears JSON-quoted (on + // Windows with its backslashes escaped). + assert!(init["instructions"].as_str().unwrap().contains(&json!(path).to_string())); assert_eq!(init["_meta"]["effectcraftAutoSave"]["previousSessions"][0]["autosave"], path); call_json(&mut restarted, "open_project", json!({"path": path})); assert_eq!(call_json(&mut restarted, "get_project", json!({}))["items"][0]["name"], "Durable"); From adbe4dff4ee18294015ba2777c0a3a47efd03386 Mon Sep 17 00:00:00 2001 From: kikikikier <140857557+kikikikier@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:14:06 +0300 Subject: [PATCH 011/107] UI: support continuous numeric scrubbing across monitor edges --- crates/ui-egui/src/lib.rs | 8 +- crates/ui-egui/src/panels/comp_settings.rs | 14 +- crates/ui-egui/src/panels/dialogs.rs | 4 +- crates/ui-egui/src/panels/dialogs_3d.rs | 26 +- crates/ui-egui/src/panels/essential.rs | 12 +- crates/ui-egui/src/panels/forms.rs | 2 +- crates/ui-egui/src/panels/key_dialogs.rs | 2 +- .../ui-egui/src/panels/layer_styles_dialog.rs | 8 +- crates/ui-egui/src/panels/markers_ui.rs | 4 +- crates/ui-egui/src/panels/settings.rs | 4 +- crates/ui-egui/src/panels/tracker.rs | 5 +- crates/ui-egui/src/panels/viewer_tools.rs | 12 +- crates/ui-egui/src/pointer_wrap.rs | 462 ++++++++++++++++++ crates/ui-egui/src/widgets.rs | 10 + 14 files changed, 527 insertions(+), 46 deletions(-) create mode 100644 crates/ui-egui/src/pointer_wrap.rs diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 82be8a32..0686dddc 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -23,6 +23,7 @@ pub mod menu_keys; pub mod menus; pub mod native_menu; pub mod panels; +mod pointer_wrap; pub mod prefs_live; pub mod state; pub mod theme; @@ -1559,7 +1560,7 @@ impl eframe::App for EffectcraftApp { self.session.end_recovery(); } - fn raw_input_hook(&mut self, _ctx: &egui::Context, raw_input: &mut egui::RawInput) { + fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) { if !self.synthetic.is_empty() { // Pointer events go one per frame so egui sees press → moves → release as a real drag. let pointer = |e: &egui::Event| matches!(e, egui::Event::PointerMoved(_) | egui::Event::PointerButton { .. } | egui::Event::MouseWheel { .. }); @@ -1573,6 +1574,9 @@ impl eframe::App for EffectcraftApp { }; raw_input.events.extend(self.synthetic.drain(..n)); } + if pointer_wrap::prepare_input(ctx, raw_input) { + self.session.history.merge_key = None; + } } fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { @@ -1618,6 +1622,8 @@ impl eframe::App for EffectcraftApp { } self.frame(ui); let ctx = ui.ctx().clone(); + #[cfg(not(target_arch = "wasm32"))] + pointer_wrap::finish_native(&ctx, frame); // wasm32: no frame threads; render queued frames now, between UI frames. if cfg!(target_arch = "wasm32") && self.frames.pump(std::time::Duration::from_millis(if self.playback.playing { 24 } else { 40 })) { ctx.request_repaint(); diff --git a/crates/ui-egui/src/panels/comp_settings.rs b/crates/ui-egui/src/panels/comp_settings.rs index 39b7580e..c94a8dab 100644 --- a/crates/ui-egui/src/panels/comp_settings.rs +++ b/crates/ui-egui/src/panels/comp_settings.rs @@ -212,7 +212,7 @@ pub fn show(ui: &mut egui::Ui, d: &mut CompDraft, t: &Tokens, auto: &mut Registr ui.label(tr("Width:")); ui.horizontal(|ui| { let (ow, oh) = (d.width, d.height); - let r = ui.add(egui::DragValue::new(&mut d.width).range(4..=30000).suffix(" px")); + let r = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut d.width).range(4..=30000).suffix(" px"))); auto.add("dialog.comp.width", r.rect, "Width"); let lbl = format!("Lock Aspect Ratio to {}", aspect_label(ow as f64, oh as f64)); let r = ui.checkbox(&mut d.lock_aspect, lbl); @@ -224,7 +224,7 @@ pub fn show(ui: &mut egui::Ui, d: &mut CompDraft, t: &Tokens, auto: &mut Registr ui.end_row(); ui.label(tr("Height:")); let (ow, oh) = (d.width, d.height); - let r = ui.add(egui::DragValue::new(&mut d.height).range(4..=30000).suffix(" px")); + let r = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut d.height).range(4..=30000).suffix(" px"))); auto.add("dialog.comp.height", r.rect, "Height"); if d.lock_aspect && oh != d.height && oh > 0 { d.width = ((d.height as f64) * ow as f64 / oh as f64).round().max(4.0) as u32; @@ -254,7 +254,7 @@ pub fn show(ui: &mut egui::Ui, d: &mut CompDraft, t: &Tokens, auto: &mut Registr ui.label(tr("Frame Rate:")); ui.horizontal(|ui| { // Any rate can be typed; the list holds the common ones. - let r = ui.add(egui::DragValue::new(&mut d.fps).range(1.0..=999.0).speed(0.01).max_decimals(3)); + let r = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut d.fps).range(1.0..=999.0).speed(0.01).max_decimals(3))); auto.add("dialog.comp.frameRate", r.rect, "Frame Rate"); let r = egui::ComboBox::from_id_salt("comp-fps").width(18.0).selected_text("").show_ui(ui, |ui| { for f in FRAME_RATES { @@ -320,19 +320,19 @@ pub fn show(ui: &mut egui::Ui, d: &mut CompDraft, t: &Tokens, auto: &mut Registr ui.label(egui::RichText::new(tr("Motion Blur")).strong()); ui.end_row(); ui.label(tr("Shutter Angle:")); - let r = ui.add(egui::DragValue::new(&mut d.shutter_angle).range(0.0..=720.0).suffix("°")); + let r = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut d.shutter_angle).range(0.0..=720.0).suffix("°"))); auto.add("dialog.comp.shutterAngle", r.rect, "Shutter Angle"); ui.end_row(); ui.label(tr("Shutter Phase:")); - let r = ui.add(egui::DragValue::new(&mut d.shutter_phase).range(-360.0..=360.0).suffix("°")); + let r = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut d.shutter_phase).range(-360.0..=360.0).suffix("°"))); auto.add("dialog.comp.shutterPhase", r.rect, "Shutter Phase"); ui.end_row(); ui.label(tr("Samples Per Frame:")); - let r = ui.add(egui::DragValue::new(&mut d.samples).range(2..=64)); + let r = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut d.samples).range(2..=64))); auto.add("dialog.comp.samples", r.rect, "Samples Per Frame"); ui.end_row(); ui.label(tr("Adaptive Sample Limit:")); - let r = ui.add(egui::DragValue::new(&mut d.adaptive_limit).range(16..=256)); + let r = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut d.adaptive_limit).range(16..=256))); auto.add("dialog.comp.adaptiveLimit", r.rect, "Adaptive Sample Limit"); ui.end_row(); }); diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index f123906b..ead96989 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -360,14 +360,14 @@ fn solid(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { let (ow, oh) = (size[0], size[1]); ui.label(tr("Width:")); ui.horizontal(|ui| { - let r = ui.add(egui::DragValue::new(&mut size[0]).range(1..=30000).suffix(" px")); + let r = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut size[0]).range(1..=30000).suffix(" px"))); auto.add("dialog.solid.width", r.rect, "Width"); let r = ui.checkbox(&mut lock, tr_args("Lock Aspect Ratio to {}", &[&(super::comp_settings::aspect_label(ow as f64, oh as f64))])); auto.add("dialog.solid.lockAspect", r.rect, "Lock Aspect Ratio"); }); ui.end_row(); ui.label(tr("Height:")); - let r = ui.add(egui::DragValue::new(&mut size[1]).range(1..=30000).suffix(" px")); + let r = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut size[1]).range(1..=30000).suffix(" px"))); auto.add("dialog.solid.height", r.rect, "Height"); ui.end_row(); if lock && ow > 0 && oh > 0 { diff --git a/crates/ui-egui/src/panels/dialogs_3d.rs b/crates/ui-egui/src/panels/dialogs_3d.rs index 485e2e8b..6113c866 100644 --- a/crates/ui-egui/src/panels/dialogs_3d.rs +++ b/crates/ui-egui/src/panels/dialogs_3d.rs @@ -188,13 +188,13 @@ pub fn camera(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { ui.end_row(); ui.label(tr("Zoom")); ui.horizontal(|ui| { - ui.add(egui::DragValue::new(&mut d.zoom).range(1.0..=100000.0).speed(1.0).suffix(" px")); + ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut d.zoom).range(1.0..=100000.0).speed(1.0).suffix(" px"))); let mut aov = angle_of_view(w, d.zoom); - if ui.add(egui::DragValue::new(&mut aov).range(0.1..=179.0).speed(0.1).suffix("°")).changed() { + if ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut aov).range(0.1..=179.0).speed(0.1).suffix("°"))).changed() { d.zoom = w * 0.5 / (aov.to_radians() * 0.5).tan(); } let mut fl = focal_for_zoom(w, d.zoom); - if ui.add(egui::DragValue::new(&mut fl).range(0.1..=10000.0).speed(0.1).suffix(" mm")).changed() { + if ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut fl).range(0.1..=10000.0).speed(0.1).suffix(" mm"))).changed() { d.zoom = zoom_for_focal(w, fl); } }); @@ -204,15 +204,15 @@ pub fn camera(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { ui.end_row(); ui.label(tr("Focus Distance")); ui.horizontal(|ui| { - ui.add_enabled(!d.lock_to_zoom, egui::DragValue::new(&mut d.focus).range(0.0..=100000.0).suffix(" px")); + ui.add_enabled(!d.lock_to_zoom, crate::widgets::drag_value(egui::DragValue::new(&mut d.focus).range(0.0..=100000.0).suffix(" px"))); ui.checkbox(&mut d.lock_to_zoom, tr("Lock to Zoom")); }); ui.end_row(); ui.label(tr("Aperture")); - ui.add(egui::DragValue::new(&mut d.aperture).range(0.0..=10000.0).speed(0.1).suffix(" px")); + ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut d.aperture).range(0.0..=10000.0).speed(0.1).suffix(" px"))); ui.end_row(); ui.label(tr("Blur Level")); - ui.add(egui::DragValue::new(&mut d.blur).range(0.0..=10000.0).suffix(" %")); + ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut d.blur).range(0.0..=10000.0).suffix(" %"))); ui.end_row(); }); ui.add_space(14.0); @@ -271,15 +271,15 @@ pub fn light(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { ui.color_edit_button_rgb(&mut d.color); ui.end_row(); ui.label(tr("Intensity")); - ui.add(egui::DragValue::new(&mut d.intensity).range(-10000.0..=10000.0).suffix(" %")); + ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut d.intensity).range(-10000.0..=10000.0).suffix(" %"))); ui.end_row(); let kind = KINDS[d.kind.min(3)]; if kind == LightKind::Spot { ui.label(tr("Cone Angle")); - ui.add(egui::DragValue::new(&mut d.cone_angle).range(0.0..=180.0).suffix("°")); + ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut d.cone_angle).range(0.0..=180.0).suffix("°"))); ui.end_row(); ui.label(tr("Cone Feather")); - ui.add(egui::DragValue::new(&mut d.cone_feather).range(0.0..=100.0).suffix(" %")); + ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut d.cone_feather).range(0.0..=100.0).suffix(" %"))); ui.end_row(); } if kind != LightKind::Ambient { @@ -292,20 +292,20 @@ pub fn light(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { ui.end_row(); if d.falloff > 0 { ui.label(tr("Radius")); - ui.add(egui::DragValue::new(&mut d.radius).range(0.0..=100000.0).suffix(" px")); + ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut d.radius).range(0.0..=100000.0).suffix(" px"))); ui.end_row(); ui.label(tr("Falloff Distance")); - ui.add(egui::DragValue::new(&mut d.falloff_distance).range(0.0..=100000.0).suffix(" px")); + ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut d.falloff_distance).range(0.0..=100000.0).suffix(" px"))); ui.end_row(); } ui.label(tr("Shadows")); ui.checkbox(&mut d.casts_shadows, tr("Casts Shadows")); ui.end_row(); ui.label(tr("Shadow Darkness")); - ui.add_enabled(d.casts_shadows, egui::DragValue::new(&mut d.shadow_darkness).range(0.0..=100.0).suffix(" %")); + ui.add_enabled(d.casts_shadows, crate::widgets::drag_value(egui::DragValue::new(&mut d.shadow_darkness).range(0.0..=100.0).suffix(" %"))); ui.end_row(); ui.label(tr("Shadow Diffusion")); - ui.add_enabled(d.casts_shadows, egui::DragValue::new(&mut d.shadow_diffusion).range(0.0..=1000.0).suffix(" px")); + ui.add_enabled(d.casts_shadows, crate::widgets::drag_value(egui::DragValue::new(&mut d.shadow_diffusion).range(0.0..=1000.0).suffix(" px"))); ui.end_row(); } }); diff --git a/crates/ui-egui/src/panels/essential.rs b/crates/ui-egui/src/panels/essential.rs index 26d458d4..c264e74e 100644 --- a/crates/ui-egui/src/panels/essential.rs +++ b/crates/ui-egui/src/panels/essential.rs @@ -377,8 +377,8 @@ fn value_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, auto: &str, ty: Opt } ControlType::Point => { let mut a = v.as_vec2(); - let r1 = ui.add(egui::DragValue::new(&mut a[0]).speed(1.0).max_decimals(1)); - let r2 = ui.add(egui::DragValue::new(&mut a[1]).speed(1.0).max_decimals(1)); + let r1 = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut a[0]).speed(1.0).max_decimals(1))); + let r2 = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut a[1]).speed(1.0).max_decimals(1))); if r1.changed() || r2.changed() { out = Some(json!(a)); } @@ -386,7 +386,7 @@ fn value_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, auto: &str, ty: Opt } ControlType::Angle => { let mut a = v.as_f64(); - let r = ui.add(egui::DragValue::new(&mut a).speed(1.0).suffix("°").max_decimals(1)); + let r = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut a).speed(1.0).suffix("°").max_decimals(1))); if r.changed() { out = Some(json!(a)); } @@ -415,7 +415,7 @@ fn value_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, auto: &str, ty: Opt } } }); - let r3 = ui.add(egui::DragValue::new(&mut d.size).speed(0.5).range(1.0..=2000.0).suffix(" px").max_decimals(1)); + let r3 = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut d.size).speed(0.5).range(1.0..=2000.0).suffix(" px").max_decimals(1))); app.auto.add(&format!("{auto}.font"), r1.response.rect, "Font"); app.auto.add(&format!("{auto}.style"), r2.response.rect, "Style"); app.auto.add(&format!("{auto}.size"), r3.rect, "Size"); @@ -428,7 +428,7 @@ fn value_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, auto: &str, ty: Opt // One percentage for every axis. let comps = v.components(); let mut x = comps.first().copied().unwrap_or(100.0); - let r = ui.add(egui::DragValue::new(&mut x).speed(0.5).suffix("%").max_decimals(1)); + let r = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut x).speed(0.5).suffix("%").max_decimals(1))); if r.changed() { out = Some(json!(vec![x; comps.len().max(1)])); } @@ -438,7 +438,7 @@ fn value_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, auto: &str, ty: Opt let mut x = v.as_f64(); let r = match p.ui { ParamUi::Slider { slider_min, slider_max, .. } => ui.add(egui::Slider::new(&mut x, slider_min..=slider_max).max_decimals(2)), - _ => ui.add(egui::DragValue::new(&mut x).speed(0.5).max_decimals(2)), + _ => ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut x).speed(0.5).max_decimals(2))), }; if r.changed() { out = Some(json!(x)); diff --git a/crates/ui-egui/src/panels/forms.rs b/crates/ui-egui/src/panels/forms.rs index a9468451..88987553 100644 --- a/crates/ui-egui/src/panels/forms.rs +++ b/crates/ui-egui/src/panels/forms.rs @@ -769,7 +769,7 @@ pub fn show_form(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { let r = match &mut fl.kind { FieldKind::Number { value, speed } => { let dec = if *speed < 0.01 { 5 } else { 3 }; - ui.add(egui::DragValue::new(value).speed(*speed).max_decimals(dec)).rect + ui.add(crate::widgets::drag_value(egui::DragValue::new(value).speed(*speed).max_decimals(dec))).rect } FieldKind::Text(s) => ui.add(egui::TextEdit::singleline(s).desired_width(220.0)).rect, FieldKind::Path { value, .. } => { diff --git a/crates/ui-egui/src/panels/key_dialogs.rs b/crates/ui-egui/src/panels/key_dialogs.rs index 3e170c12..f4574a7d 100644 --- a/crates/ui-egui/src/panels/key_dialogs.rs +++ b/crates/ui-egui/src/panels/key_dialogs.rs @@ -126,7 +126,7 @@ fn buttons(ui: &mut egui::Ui, app: &mut EffectcraftApp, t: &Tokens, prefix: &str } fn drag(ui: &mut egui::Ui, app: &mut EffectcraftApp, id: &str, v: &mut f64, speed: f64, range: std::ops::RangeInclusive, suffix: &str, enabled: bool) { - let r = ui.add_enabled(enabled, egui::DragValue::new(v).speed(speed).range(range).max_decimals(2).suffix(suffix)); + let r = ui.add_enabled(enabled, crate::widgets::drag_value(egui::DragValue::new(v).speed(speed).range(range).max_decimals(2).suffix(suffix))); app.auto.add(id, r.rect, id); } diff --git a/crates/ui-egui/src/panels/layer_styles_dialog.rs b/crates/ui-egui/src/panels/layer_styles_dialog.rs index 7738fbea..c6b6abbe 100644 --- a/crates/ui-egui/src/panels/layer_styles_dialog.rs +++ b/crates/ui-egui/src/panels/layer_styles_dialog.rs @@ -103,10 +103,10 @@ fn control(ui: &mut egui::Ui, p: &Property, v: &KV) -> Option { ParamUi::Slider { slider_min, slider_max, decimals, .. } => { ui.add(egui::Slider::new(&mut y, *slider_min..=*slider_max).clamping(egui::SliderClamping::Never).max_decimals(*decimals as usize)) } - ParamUi::Percent => ui.add(egui::DragValue::new(&mut y).speed(0.5).suffix("%")), - ParamUi::Angle => ui.add(egui::DragValue::new(&mut y).speed(0.5).suffix("°")), - ParamUi::Pixels => ui.add(egui::DragValue::new(&mut y).speed(0.25).suffix(" px")), - _ => ui.add(egui::DragValue::new(&mut y).speed(0.1)), + ParamUi::Percent => ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut y).speed(0.5).suffix("%"))), + ParamUi::Angle => ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut y).speed(0.5).suffix("°"))), + ParamUi::Pixels => ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut y).speed(0.25).suffix(" px"))), + _ => ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut y).speed(0.1))), }; (r.changed() && y != *x).then(|| json!(y)) } diff --git a/crates/ui-egui/src/panels/markers_ui.rs b/crates/ui-egui/src/panels/markers_ui.rs index 89a428a6..c3631604 100644 --- a/crates/ui-egui/src/panels/markers_ui.rs +++ b/crates/ui-egui/src/panels/markers_ui.rs @@ -128,11 +128,11 @@ pub fn dialog(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { super::dialogs::modal(ctx, &title, vec2(520.0, 560.0), t, |ui| { egui::Grid::new("marker-grid").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { ui.label(tr("Time:")); - let r = ui.add(egui::DragValue::new(&mut d.time).speed(0.01).range(0.0..=1e6).suffix(" s").max_decimals(3)); + let r = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut d.time).speed(0.01).range(0.0..=1e6).suffix(" s").max_decimals(3))); app.auto.add("dialog.marker.time", r.rect, "Time"); ui.end_row(); ui.label(tr("Duration:")); - let r = ui.add(egui::DragValue::new(&mut d.duration).speed(0.01).range(0.0..=1e6).suffix(" s").max_decimals(3)); + let r = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut d.duration).speed(0.01).range(0.0..=1e6).suffix(" s").max_decimals(3))); app.auto.add("dialog.marker.duration", r.rect, "Duration"); ui.end_row(); ui.label(tr("Comment:")); diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index ac79d8f0..11350e07 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -108,7 +108,7 @@ fn page_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, page: &Page, cur: &Value let r = match kind { Kind::Int(lo, hi, unit) => { let mut x = v.as_i64().unwrap_or(0); - let r = ui.add(egui::DragValue::new(&mut x).range(lo..=hi).speed(0.25)); + let r = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut x).range(lo..=hi).speed(0.25))); if !unit.is_empty() { ui.label(RichText::new(unit).color(t.text_dim)); } @@ -119,7 +119,7 @@ fn page_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, page: &Page, cur: &Value } Kind::Float(lo, hi, unit) => { let mut x = v.as_f64().unwrap_or(0.0); - let r = ui.add(egui::DragValue::new(&mut x).range(lo..=hi).speed(0.1).max_decimals(3)); + let r = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut x).range(lo..=hi).speed(0.1).max_decimals(3))); if !unit.is_empty() { ui.label(RichText::new(unit).color(t.text_dim)); } diff --git a/crates/ui-egui/src/panels/tracker.rs b/crates/ui-egui/src/panels/tracker.rs index 63ab06e8..13a5fa13 100644 --- a/crates/ui-egui/src/panels/tracker.rs +++ b/crates/ui-egui/src/panels/tracker.rs @@ -508,7 +508,8 @@ pub fn options_dialog(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) ui.horizontal(|ui| { let r = ui.checkbox(&mut d.blur_on, crate::i18n::tr("Blur")); app.auto.add("dialog.trackOptions.blurOn", r.rect, "Blur"); - let r = ui.add_enabled(d.blur_on, egui::DragValue::new(&mut d.opts.blur).speed(0.1).range(0.5..=50.0).suffix(" pixels")); + let r = + ui.add_enabled(d.blur_on, crate::widgets::drag_value(egui::DragValue::new(&mut d.opts.blur).speed(0.1).range(0.5..=50.0).suffix(" pixels"))); app.auto.add("dialog.trackOptions.blur", r.rect, "Blur pixels"); let r = ui.checkbox(&mut d.opts.enhance, crate::i18n::tr("Enhance")); app.auto.add("dialog.trackOptions.enhance", r.rect, "Enhance"); @@ -539,7 +540,7 @@ pub fn options_dialog(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) .response; app.auto.add("dialog.trackOptions.action", r.rect, "If Confidence is Below action"); ui.label(crate::i18n::tr("If Confidence is Below")); - let r = ui.add(egui::DragValue::new(&mut d.opts.threshold).speed(0.5).range(0.0..=100.0).suffix(" %")); + let r = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut d.opts.threshold).speed(0.5).range(0.0..=100.0).suffix(" %"))); app.auto.add("dialog.trackOptions.threshold", r.rect, "Confidence threshold"); }); (ok, cancel) = ok_cancel(ui, app, t, "dialog.trackOptions"); diff --git a/crates/ui-egui/src/panels/viewer_tools.rs b/crates/ui-egui/src/panels/viewer_tools.rs index 8d41eb73..c4f79ed4 100644 --- a/crates/ui-egui/src/panels/viewer_tools.rs +++ b/crates/ui-egui/src/panels/viewer_tools.rs @@ -675,11 +675,13 @@ pub(crate) fn bottom_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui, bar: Rect, let mut ev = vo.exposure as f64; let resp = ui.put( er, - egui::DragValue::new(&mut ev) - .speed(0.05) - .range(-40.0..=40.0) - .fixed_decimals(1) - .custom_formatter(|v, _| if v == 0.0 { "0.0".into() } else { format!("{v:+.1}") }), + crate::widgets::drag_value( + egui::DragValue::new(&mut ev) + .speed(0.05) + .range(-40.0..=40.0) + .fixed_decimals(1) + .custom_formatter(|v, _| if v == 0.0 { "0.0".into() } else { format!("{v:+.1}") }), + ), ); let resp = resp.on_hover_text(crate::i18n::tr("Adjust Exposure (stops)")); app.auto.add("viewer.exposure", er, "Adjust Exposure"); diff --git a/crates/ui-egui/src/pointer_wrap.rs b/crates/ui-egui/src/pointer_wrap.rs new file mode 100644 index 00000000..bb36adca --- /dev/null +++ b/crates/ui-egui/src/pointer_wrap.rs @@ -0,0 +1,462 @@ +//! Continuous numeric scrubbing across native monitor edges. + +use egui::{Context, Event, Id, PointerButton, Pos2, RawInput, Response, Vec2}; +#[cfg(any(not(target_arch = "wasm32"), test))] +use egui::{Rect, pos2, vec2}; + +#[derive(Clone, Default)] +struct State { + numeric: Option<(Id, u64)>, + offset: Vec2, + pending: Option<(Pos2, Pos2)>, + disabled: bool, +} + +fn state_id() -> Id { + Id::new("numeric-pointer-wrap") +} + +/// Opt in only number scrubs, never object, keyframe, panel or scrollbar drags. +pub(crate) fn track(response: &Response) { + if response.dragged_by(PointerButton::Primary) { + let pass = response.ctx.cumulative_pass_nr(); + response.ctx.data_mut(|d| { + let state = d.get_temp_mut_or_default::(state_id()); + state.numeric = Some((response.id, pass)); + }); + } +} + +/// Keep egui's drag coordinates continuous while the visible native pointer wraps. +/// Rebase only when the OS acknowledges a successful warp: queued events from before +/// it must not be interpreted as another monitor-width movement. A coalesced event +/// may include a little real movement beyond the requested destination. +/// Returns whether a numeric gesture ended, so the app can close its Undo group. +pub(crate) fn prepare_input(ctx: &Context, raw: &mut RawInput) -> bool { + let lost_focus = !raw.focused || raw.events.iter().any(|e| matches!(e, Event::WindowFocused(false))); + let (down, last_pos, modifiers) = ctx.input(|i| (i.pointer.primary_down(), i.pointer.latest_pos(), i.modifiers)); + ctx.data_mut(|d| { + let state = d.get_temp_mut_or_default::(state_id()); + if lost_focus + && down + && state.numeric.is_some() + && let Some(pos) = last_pos + { + // The OS may deliver the eventual button-up to another app. Explicitly + // finish this scrub so focus returning cannot restart it or leave Undo merged. + raw.events.push(Event::PointerButton { pos: pos - state.offset, button: PointerButton::Primary, pressed: false, modifiers }); + } + let touch = raw.events.iter().any(|e| matches!(e, Event::Touch { .. })); + if lost_focus + || touch + || raw.events.iter().any(|e| matches!(e, Event::WindowFocused(false) | Event::PointerButton { button: PointerButton::Primary, .. })) + { + let ended = state.numeric.is_some(); + *state = State { disabled: touch, ..Default::default() }; + return ended; + } + if state.numeric.is_none() { + return false; + } + // Mouse capture can report leaving the client area mid-scrub. Keep the + // drag's previous coordinate until its next captured movement arrives. + raw.events.retain(|e| !matches!(e, Event::PointerGone)); + for event in &mut raw.events { + let p = match event { + Event::PointerMoved(p) | Event::PointerButton { pos: p, .. } => p, + _ => continue, + }; + if let Some((from, to)) = state.pending + && p.distance_sq(to) < p.distance_sq(from) + { + state.offset += from - to; + state.pending = None; + } + *p += state.offset; + } + false + }) +} + +#[cfg(any(not(target_arch = "wasm32"), test))] +fn wrap_target(p: Pos2, bounds: Rect) -> Option { + if !p.is_finite() || !bounds.is_finite() || bounds.width() < 32.0 || bounds.height() < 32.0 { + return None; + } + let axis = |v: f32, lo: f32, hi: f32| { + if v <= lo + 2.0 { + hi - 8.0 + } else if v >= hi - 2.0 { + lo + 8.0 + } else { + v + } + }; + let to = pos2(axis(p.x, bounds.min.x, bounds.max.x), axis(p.y, bounds.min.y, bounds.max.y)); + (to != p).then_some(to) +} + +#[cfg(any(not(target_arch = "wasm32"), test))] +fn finish_frame(ctx: &Context, bounds: Rect, warp: impl FnOnce(Pos2) -> Option) { + let state = ctx.data(|d| d.get_temp::(state_id())).unwrap_or_default(); + let active = state.numeric.is_some_and(|(id, pass)| pass == ctx.cumulative_pass_nr() && ctx.is_being_dragged(id)); + let (down, focused, virtual_pos) = ctx.input(|i| (i.pointer.primary_down(), i.focused, i.pointer.latest_pos())); + if !active || !down || !focused { + ctx.data_mut(|d| d.remove::(state_id())); + return; + } + if state.disabled || state.pending.is_some() { + return; + } + let Some(from) = virtual_pos.map(|p| p - state.offset) else { return }; + let Some(to) = wrap_target(from, bounds) else { return }; + let destination = warp(to); + ctx.data_mut(|d| { + let state = d.get_temp_mut_or_default::(state_id()); + if let Some(to) = destination { + state.pending = Some((from, to)); + } else { + // Unsupported backends keep ordinary dragging; do not retry/log every frame. + state.disabled = true; + } + }); + if destination.is_some() { + ctx.request_repaint(); + } +} + +/// Native monitor coordinates are physical pixels; egui input uses UI points. +/// Use the monitor containing the original press, including negative desktop origins. +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn finish_native(ctx: &Context, frame: &eframe::Frame) { + let pass = ctx.cumulative_pass_nr(); + if !ctx.data(|d| d.get_temp::(state_id()).is_some_and(|s| s.numeric.is_some_and(|(_, p)| p == pass))) { + ctx.data_mut(|d| d.remove::(state_id())); + return; + } + let Some(window) = frame.winit_window() else { return }; + let Ok(origin) = window.inner_position() else { return }; + let ppp = ctx.pixels_per_point(); + if !ppp.is_finite() || ppp <= 0.0 { + return; + } + let Some(press) = ctx.input(|i| i.pointer.press_origin()) else { return }; + let screen_press = press * ppp + vec2(origin.x as f32, origin.y as f32); + let monitor = window + .available_monitors() + .find(|m| { + let p = m.position(); + let s = m.size(); + Rect::from_min_size(pos2(p.x as f32, p.y as f32), vec2(s.width as f32, s.height as f32)).contains(screen_press) + }) + .or_else(|| window.current_monitor()); + let Some(monitor) = monitor else { return }; + let p = monitor.position(); + let s = monitor.size(); + let bounds = Rect::from_min_size( + pos2((p.x as f64 - origin.x as f64) as f32, (p.y as f64 - origin.y as f64) as f32) / ppp, + vec2(s.width as f32, s.height as f32) / ppp, + ); + finish_frame(ctx, bounds, |to| { + // Reuse winit's physical position type obtained above, now client-relative. + let mut target = origin; + target.x = (to.x * ppp).round() as i32; + target.y = (to.y * ppp).round() as i32; + match window.set_cursor_position(target) { + // Remember the actual device pixel to avoid fractional-DPI drift on every wrap. + Ok(()) => Some(pos2(target.x as f32 / ppp, target.y as f32 / ppp)), + Err(e) => { + log::warn!("numeric cursor wrap is unavailable: {e}"); + None + } + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + use egui::{Event, Id, Modifiers, PointerButton, pos2, vec2}; + + struct Scrub { + ctx: Context, + value: f64, + bounds: Rect, + time: f64, + accept_warp: bool, + warps: Vec, + standard: bool, + ordinary: bool, + modifiers: Modifiers, + } + + impl Scrub { + fn new() -> Self { + Self { + ctx: Context::default(), + value: 0.0, + bounds: Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0)), + time: 0.0, + accept_warp: true, + warps: Vec::new(), + standard: false, + ordinary: false, + modifiers: Modifiers::NONE, + } + } + + fn frame(&mut self, events: Vec, focused: bool) { + self.time += 0.02; + let mut raw = RawInput { + screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), + time: Some(self.time), + events, + focused, + ..Default::default() + }; + raw.events.push(Event::ModifiersChanged(self.modifiers)); + prepare_input(&self.ctx, &mut raw); + let _ = self.ctx.run_ui(raw, |ui| { + if self.standard { + ui.put( + Rect::from_min_size(pos2(100.0, 100.0), vec2(60.0, 20.0)), + crate::widgets::drag_value(egui::DragValue::new(&mut self.value).speed(1.0).max_decimals(3)), + ); + } else if self.ordinary { + ui.interact(Rect::from_min_size(pos2(100.0, 100.0), vec2(60.0, 20.0)), Id::new("ordinary"), egui::Sense::drag()); + } else { + let (_, value, _) = crate::widgets::hot_number_at( + ui, + pos2(100.0, 100.0), + Id::new("scrub"), + self.value, + 1.0, + (-1e9, 1e9), + 1, + "", + &crate::theme::Tokens::for_kind(crate::theme::ThemeKind::Dark), + ); + if let Some(v) = value { + self.value = v; + } + } + finish_frame(ui.ctx(), self.bounds, |p| { + self.warps.push(p); + self.accept_warp.then_some(p) + }); + }); + } + + fn move_to(&mut self, p: Pos2) { + self.frame(vec![Event::PointerMoved(p)], true); + } + + fn begin(&mut self) { + self.frame(vec![], true); + let p = pos2(105.0, 108.0); + self.frame( + vec![Event::PointerMoved(p), Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }], + true, + ); + self.move_to(p + vec2(10.0, 0.0)); + } + } + + #[test] + fn numeric_drag_wraps_repeatedly_without_counting_the_teleport() { + let mut s = Scrub::new(); + s.begin(); + for _ in 0..5 { + s.move_to(pos2(799.0, 108.0)); + let target = *s.warps.last().expect("numeric drag must wrap at the monitor edge"); + assert!(target.x < 16.0); + let before = s.value; + s.move_to(target); + assert_eq!(s.value, before, "the warp itself must not change the value"); + s.move_to(target + vec2(20.0, 0.0)); + assert_eq!(s.value, before + 20.0); + } + assert_eq!(s.warps.len(), 5); + s.move_to(pos2(0.0, 108.0)); + let target = *s.warps.last().unwrap(); + assert!(target.x > 780.0); + let before = s.value; + s.move_to(target - vec2(3.0, 0.0)); + assert_eq!(s.value, before - 3.0, "coalesced movement after a left-edge warp is preserved"); + } + + #[test] + fn normal_pointer_movement_does_not_warp() { + let mut s = Scrub::new(); + s.move_to(pos2(799.0, 108.0)); + assert!(s.warps.is_empty()); + } + + #[test] + fn standard_editor_wraps_at_top_bottom_and_corner_without_a_value_jump() { + let mut s = Scrub::new(); + s.standard = true; + s.begin(); + for edge in [pos2(200.0, 0.0), pos2(200.0, 599.0), pos2(799.0, 599.0)] { + s.move_to(edge); + let target = *s.warps.last().unwrap(); + assert_ne!(target, edge); + let before = s.value; + s.move_to(target); + assert_eq!(s.value, before); + s.move_to(target + vec2(5.0, -5.0)); + assert_eq!(s.value, before + 10.0); + } + } + + #[test] + fn release_and_focus_loss_end_wrapping_and_restore_normal_hit_testing() { + for lose_focus in [false, true] { + let mut s = Scrub::new(); + s.begin(); + s.move_to(pos2(799.0, 108.0)); + let target = *s.warps.last().unwrap(); + s.move_to(target); + let value = s.value; + let events = if lose_focus { + vec![Event::WindowFocused(false)] + } else { + vec![Event::PointerButton { pos: target, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }] + }; + s.frame(events, !lose_focus); + s.move_to(pos2(799.0, 108.0)); + assert_eq!(s.warps.len(), 1); + assert_eq!(s.value, value); + assert_eq!(s.ctx.input(|i| i.pointer.latest_pos()), Some(pos2(799.0, 108.0))); + s.begin(); + s.move_to(pos2(799.0, 108.0)); + assert_eq!(s.warps.len(), 2, "the next independent drag still wraps"); + } + } + + #[test] + fn object_drag_does_not_wrap_and_failed_warp_is_not_retried() { + let mut s = Scrub::new(); + s.ordinary = true; + s.begin(); + s.move_to(pos2(799.0, 108.0)); + assert!(s.warps.is_empty()); + + let mut s = Scrub::new(); + s.accept_warp = false; + s.begin(); + s.move_to(pos2(799.0, 108.0)); + for _ in 0..3 { + s.move_to(pos2(799.0, 108.0)); + } + assert_eq!(s.warps.len(), 1); + let value = s.value; + s.move_to(pos2(779.0, 108.0)); + assert_eq!(s.value, value - 20.0); + } + + #[test] + fn modifiers_keep_their_sensitivity_after_wrapping() { + for (modifiers, multiplier) in [(Modifiers::SHIFT, 10.0), (Modifiers::COMMAND, 0.1)] { + let mut s = Scrub::new(); + s.modifiers = modifiers; + s.begin(); + s.move_to(pos2(799.0, 108.0)); + let target = *s.warps.last().unwrap(); + let value = s.value; + s.move_to(target + vec2(10.0, 0.0)); + assert!((s.value - value - 10.0 * multiplier).abs() < 1e-8); + } + } + + #[test] + fn monitor_rect_can_extend_beyond_the_app_and_have_a_negative_origin() { + let mut s = Scrub::new(); + s.bounds = Rect::from_min_size(pos2(-200.0, -100.0), vec2(1200.0, 800.0)); + s.begin(); + s.move_to(pos2(999.0, 108.0)); + let target = *s.warps.last().unwrap(); + assert_eq!(target, pos2(-192.0, 108.0)); + let value = s.value; + s.frame(vec![Event::PointerGone, Event::PointerMoved(target)], true); + assert_eq!(s.value, value); + s.move_to(target + vec2(10.0, 0.0)); + assert_eq!(s.value, value + 10.0); + } + + #[test] + fn real_properties_and_textbox_drags_keep_each_gesture_in_one_undo_step() { + use crate::{EffectcraftApp, dock::PanelKind}; + use effectcraft_engine::project::LayerId; + use eframe::App; + use serde_json::json; + + for textbox in [false, true] { + let mut session = effectcraft_host::session(); + // This patch also works without the optional TextBox plugin. + if textbox && effectcraft_engine::effects::plugin::plugin("org.effectcraft.text-box").is_none() { + continue; + } + session.execute("comp.new", json!({"width": 32, "height": 16})).unwrap(); + let lid = session.execute("layer.newSolid", json!({"width": 8, "height": 8})).unwrap()["layer"].as_u64().unwrap(); + if textbox { + session.execute("effect.apply", json!({"layer": lid, "effect": "TextBox"})).unwrap(); + } + let path = if textbox { "effects/#1/paddingX" } else { "transform/position" }; + let prop = session.active_comp().unwrap().layer(LayerId(lid)).unwrap().props.prop(path).unwrap(); + let uid = prop.uid; + let original = prop.value.clone(); + let undo = session.history.undo.len(); + let panel = if textbox { PanelKind::EffectControls } else { PanelKind::Properties }; + let id = if textbox { format!("effectControls.prop.{uid}.value") } else { format!("properties.prop.{uid}.value.0") }; + let mut app = EffectcraftApp::new(session); + app.show_panel(panel); + app.toggle_maximize(panel); + let ctx = Context::default(); + let bounds = Rect::from_min_size(Pos2::ZERO, vec2(1600.0, 1000.0)); + let mut frame = eframe::Frame::_new_kittest(); + let mut time = 0.0; + let mut step = |app: &mut EffectcraftApp, events| { + time += 1.0 / 60.0; + let mut raw = RawInput { screen_rect: Some(bounds), time: Some(time), events, ..Default::default() }; + app.raw_input_hook(&ctx, &mut raw); + let mut warp = None; + let _ = ctx.run_ui(raw, |ui| { + app.logic(ui.ctx(), &mut frame); + app.ui(ui, &mut frame); + finish_frame(ui.ctx(), bounds, |p| { + warp = Some(p); + Some(p) + }); + }); + warp + }; + for _ in 0..4 { + step(&mut app, vec![]); + } + for gesture in 1..=2 { + let r = app.auto.find(&id).unwrap().rect; + let p = pos2(r[0] + r[2] / 2.0, r[1] + r[3] / 2.0); + step( + &mut app, + vec![Event::PointerMoved(p), Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }], + ); + step(&mut app, vec![Event::PointerMoved(p + vec2(10.0, 0.0))]); + let mut last = p; + for _ in 0..3 { + let target = step(&mut app, vec![Event::PointerMoved(pos2(1599.0, p.y))]).expect("real property scrubs wrap"); + let before = app.session.active_comp().unwrap().layer(LayerId(lid)).unwrap().props.find(uid).unwrap().value.clone(); + step(&mut app, vec![Event::PointerMoved(target)]); + assert_eq!(app.session.active_comp().unwrap().layer(LayerId(lid)).unwrap().props.find(uid).unwrap().value, before); + last = target + vec2(10.0, 0.0); + step(&mut app, vec![Event::PointerMoved(last)]); + } + step(&mut app, vec![Event::PointerButton { pos: last, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }]); + assert_eq!(app.session.history.undo.len(), undo + gesture, "each drag is one undo step: {panel:?}"); + } + app.session.execute("edit.undo", json!({})).unwrap(); + app.session.execute("edit.undo", json!({})).unwrap(); + assert_eq!(app.session.active_comp().unwrap().layer(LayerId(lid)).unwrap().props.find(uid).unwrap().value, original); + } + } +} diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 8f12e52b..7b271caf 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -104,6 +104,7 @@ pub fn hot_number_at( return (er, out, out.is_some()); } let resp = ui.interact(rect, id, Sense::click_and_drag()); + crate::pointer_wrap::track(&resp); let col = if resp.hovered() || resp.dragged() { t.accent_hover } else { t.hot_text }; ui.painter().galley_with_override_text_color(rect.min + vec2(2.0, 2.0), galley, col); if resp.hovered() || resp.dragged() { @@ -154,6 +155,15 @@ pub fn hot_int_at(ui: &mut Ui, rect_min: egui::Pos2, id: egui::Id, value: i64, s (rect, (step != 0.0).then(|| value.saturating_add(step as i64)), done) } +/// Standard numeric editors use the same continuous native scrubbing as hot text. +pub fn drag_value(value: egui::DragValue<'_>) -> impl egui::Widget { + move |ui: &mut Ui| { + let response = ui.add(value); + crate::pointer_wrap::track(&response); + response + } +} + /// Keep the keyboard on an inline editor that is open until it loses the focus. The frame it /// opens (it has no focus yet) its whole `text` is selected, so typing replaces the value. /// (Only then is the focus requested: a request resets what keys the field keeps for itself, From abd44404f87a51fb1d1cec478a1804c5db853544 Mon Sep 17 00:00:00 2001 From: kikikikier <140857557+kikikikier@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:14:06 +0300 Subject: [PATCH 012/107] UI: add inline font family search in Character and Properties --- crates/ui-egui/src/panels/properties.rs | 8 +- crates/ui-egui/src/panels/text_panels.rs | 193 ++++++++++++++++++----- crates/ui-egui/src/widgets.rs | 9 +- crates/ui-egui/tests/ui_font_search.rs | 107 +++++++++++++ 4 files changed, 269 insertions(+), 48 deletions(-) create mode 100644 crates/ui-egui/tests/ui_font_search.rs diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 2dcecc1d..db879fe2 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -318,13 +318,7 @@ fn text_section( // Font family and style. let fr = Rect::from_min_size(pos2(x0, y), vec2(w, 22.0)); let pop = egui::Id::new("props-font-pop"); - if widgets::dropdown(ui, fr, &doc.font, &t, egui::Id::new("props-font")).clicked() { - widgets::open_popup(ui, pop); - } - app.auto.add("properties.text.font", fr, "Font family"); - // Every installed family (built only while the menu is open: it can be long). - let fams: Vec = if widgets::popup_is_open(ui, pop) { effectcraft_engine::text_families() } else { vec![] }; - if let Some(f) = widgets::popup_menu(ui, pop, fr.left_bottom(), &fams, fams.iter().position(|f| *f == doc.font)).and_then(|i| fams.get(i)) { + if let Some(f) = super::text_panels::font_picker(app, ui, pop, fr, &doc.font, "properties.text.font", true) { set(actions, json!({"font": f})); } y += 28.0; diff --git a/crates/ui-egui/src/panels/text_panels.rs b/crates/ui-egui/src/panels/text_panels.rs index 8e24a083..2617ee85 100644 --- a/crates/ui-egui/src/panels/text_panels.rs +++ b/crates/ui-egui/src/panels/text_panels.rs @@ -96,11 +96,7 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { // Font family + style. let fr = Rect::from_min_size(pos2(x0, y), vec2(w, 22.0)); let pop = egui::Id::new("char-font-pop"); - if widgets::dropdown(ui, fr, &doc.font, &t, egui::Id::new("char-font")).clicked() && enabled { - widgets::open_popup(ui, pop); - } - app.auto.add("character.font", fr, "Font family"); - if let Some(f) = font_popup(app, ui, pop, fr.left_bottom(), &doc.font) { + if let Some(f) = font_picker(app, ui, pop, fr, &doc.font, "character.font", enabled) { actions.push(json!({"font": f})); } y += 28.0; @@ -551,47 +547,166 @@ pub fn paragraph(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } -/// The font menu: recent fonts first, names in English or the fonts' own language, and a -/// "Sample" preview in each font (Settings ▸ Type). -fn font_popup(app: &EffectcraftApp, ui: &mut egui::Ui, id: egui::Id, pos: egui::Pos2, current: &str) -> Option { - if !widgets::popup_is_open(ui, id) { +#[derive(Clone, Default)] +struct FontSearch { + query: String, + selected: usize, +} + +/// Shared font menu for Character and Properties: search, recent fonts and previews. +pub(super) fn font_picker(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: egui::Id, rect: Rect, current: &str, auto: &str, enabled: bool) -> Option { + let state_id = id.with("search-state"); + app.auto.add(auto, rect, "Font family"); + if !widgets::popup_is_open(ui, id) || !enabled { + ui.data_mut(|d| d.remove::(state_id)); + if widgets::dropdown(ui, rect, current, &app.tokens, id.with("field")).clicked() && enabled { + widgets::open_popup(ui, id); + } return None; } let rows = effectcraft_engine::font_menu(&app.session.prefs); + let saved = ui.data(|d| d.get_temp::(state_id)); + let fresh = saved.is_none(); + let mut state = saved.unwrap_or_default(); + let search_id = id.with("search"); + let (up, down, enter, escape) = ui.input_mut(|i| { + ( + i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowUp), + i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowDown), + i.consume_key(egui::Modifiers::NONE, egui::Key::Enter), + i.consume_key(egui::Modifiers::NONE, egui::Key::Escape), + ) + }); let preview = app.session.prefs.type_.font_preview; let t = app.tokens; - let seps = rows.iter().filter(|r| r.family.is_empty()).count(); - widgets::popup_list(ui, id, pos, Rect::NOTHING, rows.len().saturating_sub(seps), seps, |ui| { - ui.set_min_width(if preview { 300.0 } else { 180.0 }); - let mut chosen = None; - for r in &rows { - if r.family.is_empty() { - ui.separator(); - continue; + // The font field itself becomes editable. The popup contains only results. + let arrow = Rect::from_min_max(pos2(rect.max.x - 22.0, rect.min.y), rect.max); + let edit_rect = Rect::from_min_max(rect.min, pos2(arrow.min.x, rect.max.y)); + let search = ui.put(edit_rect, egui::TextEdit::singleline(&mut state.query).id(search_id).hint_text(current).font(Tokens::ui(12.0))); + app.auto.add(&format!("{auto}.search"), search.rect, "Search fonts"); + if fresh { + search.request_focus(); + } + ui.memory_mut(|m| m.set_focus_lock_filter(search_id, egui::EventFilter { vertical_arrows: true, escape: true, ..Default::default() })); + if widgets::icon_button(ui, arrow, crate::icons::Icon::ChevronDown, false, &t, id.with("arrow")).clicked() { + ui.data_mut(|d| { + d.insert_temp(id.with("open"), false); + d.remove::(state_id); + }); + ui.memory_mut(|m| m.surrender_focus(search_id)); + return None; + } + app.auto.add(&format!("{auto}.toggle"), arrow, "Close font list"); + let mut chosen = None; + let screen = ui.ctx().content_rect(); + let below = screen.bottom() - rect.bottom() - 16.0; + let above = below < 100.0 && rect.top() - screen.top() > below; + let max_height = (if above { rect.top() - screen.top() - 16.0 } else { below }).clamp(28.0, 380.0); + let anchor = if above { rect.left_top() } else { rect.left_bottom() }; + let pivot = if above { Align2::LEFT_BOTTOM } else { Align2::LEFT_TOP }; + widgets::note_open_popup_list(ui.ctx()); + let area = egui::Area::new(id.with("area")).order(egui::Order::Foreground).pivot(pivot).fixed_pos(anchor).show(ui.ctx(), |ui| { + egui::Frame::popup(ui.style()).show(ui, |ui| { + ui.set_width(rect.width().max(180.0)); + let query = state.query.trim().to_lowercase(); + let mut seen = std::collections::HashSet::new(); + let filtered: Vec<_> = rows + .iter() + .filter(|r| { + !r.family.is_empty() + && (query.is_empty() + || ((r.family.to_lowercase().contains(&query) || r.display.to_lowercase().contains(&query)) && seen.insert(&r.family))) + }) + .collect(); + if fresh { + state.selected = filtered.iter().position(|r| r.family == current).unwrap_or(0); } - let resp = ui.selectable_label(r.family == current, &r.display); - if preview && ui.is_rect_visible(resp.rect) { - let key = egui::Id::new(("font-preview", &r.family)); - let lines: std::sync::Arc>> = match ui.data(|d| d.get_temp(key)) { - Some(l) => l, - None => { - let l = std::sync::Arc::new(effectcraft_engine::font_preview(&r.family, 14.0)); - ui.data_mut(|d| d.insert_temp(key, l.clone())); - l - } - }; - let o = pos2(resp.rect.max.x - 120.0, resp.rect.center().y + 5.0); - for poly in lines.iter() { - let pts: Vec = poly.iter().map(|p| o + vec2(p[0], p[1])).collect(); - ui.painter().add(egui::Shape::line(pts, egui::Stroke::new(1.0, t.text_dim))); + if search.changed() { + state.selected = 0; + } + let last = filtered.len().saturating_sub(1); + state.selected = state.selected.min(last); + if down { + state.selected = state.selected.saturating_add(1).min(last); + } + if up { + state.selected = state.selected.saturating_sub(1); + } + if enter { + chosen = filtered.get(state.selected).map(|r| r.family.clone()); + } + // Give the result list its actual height even when the previous query had + // only one row. A cached tiny Area must not collapse a reopened long list. + let height = (filtered.len() as f32 * (24.0 + ui.spacing().item_spacing.y) + 16.0).clamp(28.0, max_height); + ui.set_height(height); + let mut scroll = egui::ScrollArea::vertical().id_salt(id.with("results")).max_height(height).min_scrolled_height(height); + if search.changed() { + scroll = scroll.vertical_scroll_offset(0.0); + } + scroll.show(ui, |ui| { + if filtered.is_empty() { + let r = ui.label("No matching fonts"); + app.auto.add(&format!("{auto}.empty"), r.rect, "No matching fonts"); } - } - if resp.clicked() { - chosen = Some(r.family.clone()); - } - } - chosen - }) + let mut was_recent = false; + for (index, r) in filtered.iter().enumerate() { + if query.is_empty() && was_recent && !r.recent { + ui.separator(); + } + was_recent = r.recent; + let row_width = ui.available_width(); + let label_width = if preview { row_width - 100.0 } else { row_width }; + let resp = ui.add_sized([row_width, 24.0], egui::Button::selectable(index == state.selected, "")); + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, 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())); + ui.painter().with_clip_rect(label_rect.intersect(ui.clip_rect())).text( + label_rect.left_center(), + Align2::LEFT_CENTER, + &r.display, + Tokens::ui(12.0), + t.text, + ); + if ui.is_rect_visible(resp.rect) { + app.auto.add(&format!("{auto}.option.{}", r.family), resp.rect, &r.display); + } + if index == state.selected && (fresh || up || down) { + resp.scroll_to_me(None); + } + if preview && ui.is_rect_visible(resp.rect) { + let key = egui::Id::new(("font-preview", &r.family)); + let lines: std::sync::Arc>> = match ui.data(|d| d.get_temp(key)) { + Some(l) => l, + None => { + let l = std::sync::Arc::new(effectcraft_engine::font_preview(&r.family, 14.0)); + ui.data_mut(|d| d.insert_temp(key, l.clone())); + l + } + }; + let o = pos2(resp.rect.max.x - 90.0, resp.rect.center().y + 5.0); + for poly in lines.iter() { + let pts: Vec = poly.iter().map(|p| o + vec2(p[0], p[1])).collect(); + ui.painter().add(egui::Shape::line(pts, egui::Stroke::new(1.0, t.text_dim))); + } + } + if resp.clicked() { + chosen = Some(r.family.clone()); + } + resp.on_hover_text(&r.display); + } + }); + }); + }); + let outside = widgets::pressed_outside(ui.ctx(), &area.response) && !ui.input(|i| i.pointer.interact_pos().is_some_and(|p| rect.contains(p))); + if chosen.is_some() || outside || escape { + ui.data_mut(|d| { + d.insert_temp(id.with("open"), false); + d.remove::(state_id); + }); + ui.memory_mut(|m| m.surrender_focus(search_id)); + } else { + ui.data_mut(|d| d.insert_temp(state_id, state)); + } + chosen } /// Align panel: Align Layers to Selection / Composition, the six align buttons and the six diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 8f12e52b..42ec7360 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -520,8 +520,7 @@ pub fn popup_list(ui: &mut Ui, id: egui::Id, pos: egui::Pos2, anchor: Rect, r egui::ScrollArea::vertical().max_height(max_h).min_scrolled_height((est - 16.0).min(max_h)).show(ui, |ui| chosen = body(ui)); }); }); - let pass = ui.ctx().cumulative_pass_nr(); - ui.ctx().data_mut(|d| d.insert_temp(popup_list_pass_id(), pass)); + note_open_popup_list(ui.ctx()); let outside = pressed_outside(ui.ctx(), &area.response) && !ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| anchor.contains(p)); if chosen.is_some() || outside || ui.input(|i| i.key_pressed(egui::Key::Escape)) { ui.data_mut(|d| d.insert_temp(id.with("open"), false)); @@ -529,6 +528,12 @@ pub fn popup_list(ui: &mut Ui, id: egui::Id, pos: egui::Pos2, anchor: Rect, r chosen } +/// Custom popup lists also hold the app's keyboard shortcuts while open. +pub(crate) fn note_open_popup_list(ctx: &egui::Context) { + let pass = ctx.cumulative_pass_nr(); + ctx.data_mut(|d| d.insert_temp(popup_list_pass_id(), pass)); +} + /// The pass a [`popup_list`] was last drawn in. fn popup_list_pass_id() -> egui::Id { egui::Id::new("popup-list-pass") diff --git a/crates/ui-egui/tests/ui_font_search.rs b/crates/ui-egui/tests/ui_font_search.rs new file mode 100644 index 00000000..e3da6ef9 --- /dev/null +++ b/crates/ui-egui/tests/ui_font_search.rs @@ -0,0 +1,107 @@ +//! Real keyboard/pointer coverage for the font menus in both text panels. +use effectcraft_engine::{Session, commands::text_edit::layer_doc, project::LayerId}; +use effectcraft_ui_egui::{EffectcraftApp, dock::PanelKind}; +use egui::{Event, Key, Modifiers, PointerButton}; +use egui_kittest::Harness; +use serde_json::json; + +fn click(h: &mut Harness<'_, EffectcraftApp>, id: &str) { + let e = h.state().auto.find(id).unwrap_or_else(|| panic!("missing {id}")).clone(); + let pos = egui::pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0); + h.input_mut().events.push(Event::PointerMoved(pos)); + for pressed in [true, false] { + h.input_mut().events.push(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + h.run_steps(2); + } +} + +fn key(h: &mut Harness<'_, EffectcraftApp>, key: Key) { + for pressed in [true, false] { + h.input_mut().events.push(Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE }); + } + h.run_steps(3); +} + +fn type_text(h: &mut Harness<'_, EffectcraftApp>, text: &str) { + h.input_mut().events.push(Event::Text(text.into())); + h.run_steps(3); +} + +#[test] +fn font_search_filters_selects_and_undoes_in_both_panels() { + for (panel, prefix) in [(PanelKind::Character, "character.font"), (PanelKind::Properties, "properties.text.font")] { + let mut s = Session::default(); + s.execute("comp.new", json!({"width": 320, "height": 180})).unwrap(); + let lid = s.execute("layer.newText", json!({"text": "Keep this text", "font": "Inter"})).unwrap()["layer"].as_u64().unwrap(); + let builder = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)); + let mut h = if std::env::var_os("EC_SNAPSHOT_DIR").is_some() { + builder.wgpu().build_eframe(move |_| EffectcraftApp::new(s)) + } else { + builder.build_eframe(move |_| EffectcraftApp::new(s)) + }; + h.state_mut().show_panel(panel); + h.run_steps(3); + click(&mut h, prefix); + assert!(h.state().auto.find(&format!("{prefix}.search")).is_some()); + assert!(effectcraft_ui_egui::widgets::any_menu_open(&h.ctx), "font search must hold app shortcuts like other popup lists"); + let field = h.state().auto.find(prefix).unwrap(); + let search = h.state().auto.find(&format!("{prefix}.search")).unwrap(); + assert!( + (field.rect[1] + field.rect[3] / 2.0 - search.rect[1] - search.rect[3] / 2.0).abs() < 1.0, + "font selection and search must occupy the same row" + ); + // Search gets focus on open. Mixed case and surrounding whitespace are accepted. + type_text(&mut h, " jEtBrAiNs "); + let rows = h.state().auto.query(&format!("{prefix}.option.")); + assert!(!rows.is_empty()); + assert!(rows.iter().all(|r| r.label.to_lowercase().contains("jetbrains"))); + if let Ok(dir) = std::env::var("EC_SNAPSHOT_DIR") { + h.render().unwrap().save(format!("{dir}/{prefix}-search.png")).unwrap(); + } + assert_eq!(layer_doc(&h.state().session, LayerId(lid)).unwrap().font, "Inter", "typing only filters"); + key(&mut h, Key::Enter); + assert_eq!(layer_doc(&h.state().session, LayerId(lid)).unwrap().font, "JetBrains Mono"); + assert_eq!(layer_doc(&h.state().session, LayerId(lid)).unwrap().text, "Keep this text"); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + assert_eq!(layer_doc(&h.state().session, LayerId(lid)).unwrap().font, "Inter"); + click(&mut h, prefix); + type_text(&mut h, "no_such_font_123456789"); + assert!(h.state().auto.query(&format!("{prefix}.option.")).is_empty()); + assert!(h.state().auto.find(&format!("{prefix}.empty")).is_some()); + key(&mut h, Key::Enter); + assert!(h.state().auto.find(&format!("{prefix}.search")).is_some(), "no result keeps menu open"); + key(&mut h, Key::Escape); + assert_eq!(layer_doc(&h.state().session, LayerId(lid)).unwrap().font, "Inter"); + click(&mut h, prefix); + // Reopening clears the previous query, and mouse selection still works. + h.run_steps(2); + if let Ok(dir) = std::env::var("EC_SNAPSHOT_DIR") { + h.render().unwrap().save(format!("{dir}/{prefix}-reopened.png")).unwrap(); + } + assert!(h.state().auto.query(&format!("{prefix}.option.")).len() > 1); + type_text(&mut h, "JetBrains"); + click(&mut h, &format!("{prefix}.option.JetBrains Mono")); + assert_eq!(layer_doc(&h.state().session, LayerId(lid)).unwrap().font, "JetBrains Mono"); + } +} + +#[test] +fn font_search_preserves_edited_text_and_applies_only_to_selected_characters() { + let mut s = Session::default(); + s.execute("comp.new", json!({"width": 320, "height": 180})).unwrap(); + let lid = s.execute("layer.newText", json!({"text": "First second", "font": "Inter"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("text.edit", json!({"layer": lid})).unwrap(); + s.execute("text.setSelection", json!({"start": 0, "end": 5})).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, "noto serif"); + key(&mut h, Key::ArrowDown); + key(&mut h, Key::ArrowUp); + key(&mut h, Key::Enter); + let doc = layer_doc(&h.state().session, LayerId(lid)).unwrap(); + assert_eq!(doc.text, "First second"); + assert_eq!(doc.style_at(0).font, "Noto Serif"); + assert_eq!(doc.style_at(8).font, "Inter"); +} From 1c2bf2f157a2d853312559143303d4754db6471a Mon Sep 17 00:00:00 2001 From: kikikikier <140857557+kikikikier@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:26:29 +0300 Subject: [PATCH 013/107] Fix signed angle scrubbing across zero and monitor wraps --- crates/ui-egui/src/panels/fx_widgets.rs | 5 +- crates/ui-egui/src/pointer_wrap.rs | 85 +++++++++++++++++++++++-- 2 files changed, 82 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/panels/fx_widgets.rs b/crates/ui-egui/src/panels/fx_widgets.rs index 036ae070..7ea2c3b5 100644 --- a/crates/ui-egui/src/panels/fx_widgets.rs +++ b/crates/ui-egui/src/panels/fx_widgets.rs @@ -51,7 +51,10 @@ pub fn angle_field(ui: &mut egui::Ui, at: Pos2, id: egui::Id, v: f64, decimals: let (dr, new_deg, _) = widgets::hot_number_at(ui, pos2(sign.max.x - 2.0, at.y), id, deg.abs(), 0.5, (-1e9, 1e9), decimals, "°", t); let out = match (new_rev, new_deg) { (Some(r), _) => Some(r as f64 * 360.0 + deg), - // The degrees field shows the remainder's size; its sign stays. + // Scrubbing changes the full signed angle. Reapplying the displayed + // remainder's sign would reverse the next delta after crossing zero. + (None, Some(d)) if ui.ctx().is_being_dragged(id) => Some(v + (d - deg.abs())), + // Typing replaces the remainder's size and keeps its displayed sign. (None, Some(d)) => Some(rev as f64 * 360.0 + if deg < 0.0 || (deg == 0.0 && v < 0.0) { -d } else { d }), (None, None) => None, }; diff --git a/crates/ui-egui/src/pointer_wrap.rs b/crates/ui-egui/src/pointer_wrap.rs index bb36adca..fa4f09c4 100644 --- a/crates/ui-egui/src/pointer_wrap.rs +++ b/crates/ui-egui/src/pointer_wrap.rs @@ -187,6 +187,8 @@ mod tests { warps: Vec, standard: bool, ordinary: bool, + angle: bool, + angle_rects: Option<(Rect, Rect)>, modifiers: Modifiers, } @@ -201,6 +203,8 @@ mod tests { warps: Vec::new(), standard: false, ordinary: false, + angle: false, + angle_rects: None, modifiers: Modifiers::NONE, } } @@ -224,6 +228,19 @@ mod tests { ); } else if self.ordinary { ui.interact(Rect::from_min_size(pos2(100.0, 100.0), vec2(60.0, 20.0)), Id::new("ordinary"), egui::Sense::drag()); + } else if self.angle { + let (rr, dr, value) = crate::panels::fx_widgets::angle_field( + ui, + pos2(100.0, 100.0), + Id::new("scrub"), + self.value, + 1, + &crate::theme::Tokens::for_kind(crate::theme::ThemeKind::Dark), + ); + self.angle_rects = Some((rr, dr)); + if let Some(v) = value { + self.value = v; + } } else { let (_, value, _) = crate::widgets::hot_number_at( ui, @@ -262,6 +279,42 @@ mod tests { } } + #[test] + fn angle_degrees_continue_across_zero_and_repeated_monitor_wraps() { + for initial in [0.0, 359.5, -359.5, 720.0, -720.0] { + for direction in [-1.0, 1.0] { + let mut s = Scrub::new(); + s.angle = true; + s.value = initial; + s.frame(vec![], true); + let p = s.angle_rects.unwrap().1.center(); + s.frame( + vec![Event::PointerMoved(p), Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }], + true, + ); + let mut last = p; + let mut expected = initial; + // Feed small real movements, including zero/whole-turn crossings, then + // the synthetic OS warp. Every real pixel contributes exactly once. + for step in 0..800 { + let direction = if step < 400 { direction } else { -direction }; + let next = pos2((last.x + direction * 13.0).clamp(1.0, 799.0), last.y); + expected += f64::from(next.x - last.x) * 0.5; + let count = s.warps.len(); + s.move_to(next); + assert!((s.value - expected).abs() < 1e-6, "initial={initial}, direction={direction}, actual={}, expected={expected}", s.value); + last = next; + if s.warps.len() > count { + last = *s.warps.last().unwrap(); + s.move_to(last); + assert!((s.value - expected).abs() < 1e-6, "the teleport must not reset the angle"); + } + } + assert!(s.warps.len() >= 5); + } + } + } + #[test] fn numeric_drag_wraps_repeatedly_without_counting_the_teleport() { let mut s = Scrub::new(); @@ -385,13 +438,13 @@ mod tests { } #[test] - fn real_properties_and_textbox_drags_keep_each_gesture_in_one_undo_step() { + fn real_properties_angles_and_textbox_drags_keep_each_gesture_in_one_undo_step() { use crate::{EffectcraftApp, dock::PanelKind}; use effectcraft_engine::project::LayerId; use eframe::App; use serde_json::json; - for textbox in [false, true] { + for (textbox, angle) in [(false, false), (false, true), (true, false)] { let mut session = effectcraft_host::session(); // This patch also works without the optional TextBox plugin. if textbox && effectcraft_engine::effects::plugin::plugin("org.effectcraft.text-box").is_none() { @@ -402,13 +455,25 @@ mod tests { if textbox { session.execute("effect.apply", json!({"layer": lid, "effect": "TextBox"})).unwrap(); } - let path = if textbox { "effects/#1/paddingX" } else { "transform/position" }; + let path = if textbox { + "effects/#1/paddingX" + } else if angle { + "transform/rotation" + } else { + "transform/position" + }; let prop = session.active_comp().unwrap().layer(LayerId(lid)).unwrap().props.prop(path).unwrap(); let uid = prop.uid; let original = prop.value.clone(); let undo = session.history.undo.len(); let panel = if textbox { PanelKind::EffectControls } else { PanelKind::Properties }; - let id = if textbox { format!("effectControls.prop.{uid}.value") } else { format!("properties.prop.{uid}.value.0") }; + let id = if textbox { + format!("effectControls.prop.{uid}.value") + } else if angle { + format!("properties.prop.{uid}.value") + } else { + format!("properties.prop.{uid}.value.0") + }; let mut app = EffectcraftApp::new(session); app.show_panel(panel); app.toggle_maximize(panel); @@ -435,21 +500,27 @@ mod tests { step(&mut app, vec![]); } for gesture in 1..=2 { + let direction = if gesture == 1 { -1.0 } else { 1.0 }; let r = app.auto.find(&id).unwrap().rect; let p = pos2(r[0] + r[2] / 2.0, r[1] + r[3] / 2.0); step( &mut app, vec![Event::PointerMoved(p), Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }], ); - step(&mut app, vec![Event::PointerMoved(p + vec2(10.0, 0.0))]); + step(&mut app, vec![Event::PointerMoved(p + vec2(direction * 10.0, 0.0))]); let mut last = p; for _ in 0..3 { - let target = step(&mut app, vec![Event::PointerMoved(pos2(1599.0, p.y))]).expect("real property scrubs wrap"); + let edge = if direction < 0.0 { 1.0 } else { 1599.0 }; + let target = step(&mut app, vec![Event::PointerMoved(pos2(edge, p.y))]).expect("real property scrubs wrap"); let before = app.session.active_comp().unwrap().layer(LayerId(lid)).unwrap().props.find(uid).unwrap().value.clone(); step(&mut app, vec![Event::PointerMoved(target)]); assert_eq!(app.session.active_comp().unwrap().layer(LayerId(lid)).unwrap().props.find(uid).unwrap().value, before); - last = target + vec2(10.0, 0.0); + last = target + vec2(direction * 10.0, 0.0); step(&mut app, vec![Event::PointerMoved(last)]); + if angle { + let value = app.session.active_comp().unwrap().layer(LayerId(lid)).unwrap().props.find(uid).unwrap().value.as_f64(); + assert!((value - before.as_f64() - f64::from(direction) * 5.0).abs() < 1e-6); + } } step(&mut app, vec![Event::PointerButton { pos: last, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }]); assert_eq!(app.session.history.undo.len(), undo + gesture, "each drag is one undo step: {panel:?}"); From 0a265131ce81f0654dcf87b2e009fffc0b06a554 Mon Sep 17 00:00:00 2001 From: sanyamsingh001 <314051937+sanyamsingh001@users.noreply.github.com> Date: Sun, 11 Oct 2026 01:23:47 +1000 Subject: [PATCH 014/107] G3: preserve reachable history during trimming Co-authored-by: Codex --- crates/effects/src/ocio.rs | 2 +- crates/engine/src/history.rs | 17 +++++++---- crates/engine/src/tests_history.rs | 47 ++++++++++++++++++++++++++++++ 3 files changed, 60 insertions(+), 6 deletions(-) diff --git a/crates/effects/src/ocio.rs b/crates/effects/src/ocio.rs index d00f6e9e..c8fcf848 100644 --- a/crates/effects/src/ocio.rs +++ b/crates/effects/src/ocio.rs @@ -372,7 +372,7 @@ pub fn parse_spimtx(text: &str) -> Option { } let mut m = [0.0; 16]; let mut offset = [0.0; 4]; - for (r, row) in v.chunks_exact(4).enumerate() { + for (r, row) in v.as_chunks::<4>().0.iter().enumerate() { m[r * 4..r * 4 + 3].copy_from_slice(&row[..3]); offset[r] = row[3] / 65535.0; } diff --git a/crates/engine/src/history.rs b/crates/engine/src/history.rs index 36630969..6d905a83 100644 --- a/crates/engine/src/history.rs +++ b/crates/engine/src/history.rs @@ -206,19 +206,26 @@ impl History { self.branches.push(Branch { parent: from.clone(), steps }); } - /// Apply the undo-levels limit: drop the oldest working-line states, the branches that grew - /// from them, and the oldest branches when branches hold more states than `levels`. + /// Apply the undo-levels limit: keep the nearest past and future states on the working + /// line, drop branches whose parents were removed, and bound branch states separately. pub fn trim(&mut self, levels: usize, current: &Arc) { let levels = levels.max(1); if self.undo.len() > levels { let extra = self.undo.len() - levels; self.undo.drain(..extra); } - let mut total: usize = self.branches.iter().map(|b| b.steps.len()).sum(); - while total > levels && !self.branches.is_empty() { - total -= self.branches.remove(0).steps.len(); + if self.redo.len() > levels { + // The next redo is last: discard the farthest future states first. + let extra = self.redo.len() - levels; + self.redo.drain(..extra); } + // Orphans from truncated line states must not consume the reachable branch budget. self.prune(current); + while self.branch_states() > levels && !self.branches.is_empty() { + self.branches.remove(0); + // Removing a parent branch also removes its descendants before counting again. + self.prune(current); + } } /// Drop branches whose parent state is no longer in the history (`current` = the current diff --git a/crates/engine/src/tests_history.rs b/crates/engine/src/tests_history.rs index c67e7c14..d53f6ef3 100644 --- a/crates/engine/src/tests_history.rs +++ b/crates/engine/src/tests_history.rs @@ -1,9 +1,13 @@ //! Branching history: undo then a new step keeps the undone line as a branch; the tree lists //! every state; jumping to any state (on any branch) restores it and keeps everything else. +use std::sync::Arc; + +use effectcraft_project::Project; use serde_json::{Value, json}; use crate::Session; +use crate::history::{Branch, History}; fn names(s: &Session) -> Vec { s.project.comps().next().map(|(_, c)| c.layers.iter().map(|l| l.name.clone()).collect()).unwrap_or_default() @@ -190,3 +194,46 @@ fn a_session_restored_with_unsaved_changes_stays_modified() { s.execute("edit.undo", json!({})).unwrap(); assert!(s.is_dirty(), "undo doesn't reach a saved state"); } + +#[test] +fn dropped_redo_parent_does_not_discard_reachable_branches() { + let state = || Arc::new(Project::default()); + let current = state(); + let futures: Vec<_> = (0..5).map(|_| state()).collect(); + let reachable = state(); + let mut history = History { + redo: futures.iter().rev().map(|p| ("future".into(), p.clone())).collect(), + branches: vec![ + Branch { parent: current.clone(), steps: vec![("reachable".into(), reachable.clone())] }, + Branch { parent: futures[4].clone(), steps: vec![("orphan1".into(), state()), ("orphan2".into(), state())] }, + ], + ..Default::default() + }; + history.trim(2, ¤t); + assert_eq!(history.redo.len(), 2); + assert_eq!(history.branches.len(), 1); + assert!(Arc::ptr_eq(&history.branches[0].steps[0].1, &reachable)); + assert_eq!(history.branch_states(), 1); + assert!(history.tree(¤t).iter().any(|node| node.label == "reachable")); +} + +#[test] +fn evicted_branch_parent_does_not_discard_other_reachable_branches() { + let state = || Arc::new(Project::default()); + let current = state(); + let parent = state(); + let reachable = state(); + let mut history = History { + branches: vec![ + Branch { parent: current.clone(), steps: vec![("parent".into(), parent.clone())] }, + Branch { parent: current.clone(), steps: vec![("reachable".into(), reachable.clone())] }, + Branch { parent, steps: vec![("orphan1".into(), state()), ("orphan2".into(), state())] }, + ], + ..Default::default() + }; + history.trim(2, ¤t); + assert_eq!(history.branches.len(), 1); + assert!(Arc::ptr_eq(&history.branches[0].steps[0].1, &reachable)); + assert_eq!(history.branch_states(), 1); + assert_eq!(history.tree(¤t).len(), 2); +} From bb08a6acc970e7e1419867000c8f7529725b3f19 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 16:28:38 +0000 Subject: [PATCH 015/107] Work area: an edit never clamps with crossed bounds (#605 follow-up) A project saved with an inverted work area, or a comp shorter than a frame, made Tick::clamp panic on the next start or end edit. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f --- crates/engine/src/commands/comp.rs | 5 +++-- crates/engine/src/tests_menu_cmds.rs | 14 ++++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/commands/comp.rs b/crates/engine/src/commands/comp.rs index 2d7dafa8..bf1fd530 100644 --- a/crates/engine/src/commands/comp.rs +++ b/crates/engine/src/commands/comp.rs @@ -182,10 +182,11 @@ fn work_area(s: &mut Session, p: &Value) -> Result { _ => {} } if let Some(b) = begin { - c.work_area.0 = b.clamp(Tick::ZERO, c.duration - fd); + // min/max, not clamp: the bounds cross on a comp shorter than a frame or an inverted area. + c.work_area.0 = b.min(c.duration - fd).max(Tick::ZERO); } if let Some(e) = end { - c.work_area.1 = e.clamp(c.work_area.0 + fd, c.duration); + c.work_area.1 = e.max(c.work_area.0 + fd).min(c.duration); } // A start on its own can land after the current end. Pull it back so the interval // still covers one frame, the same rule as `set: "begin"`. diff --git a/crates/engine/src/tests_menu_cmds.rs b/crates/engine/src/tests_menu_cmds.rs index c20e0159..df0af369 100644 --- a/crates/engine/src/tests_menu_cmds.rs +++ b/crates/engine/src/tests_menu_cmds.rs @@ -196,6 +196,7 @@ fn separated_position_follows_canvas_resize_and_crop() { placed(&separated, b, 1.0, 50.0, 30.0); assert_eq!(opaque_bounds(&separated, Tick::ZERO), (70, 70, 25, 25, 34, 34, 100)); assert_eq!(opaque_bounds(&separated, Tick::from_seconds_f64(1.0)), (70, 70, 45, 25, 54, 34, 100)); +} /// Pixel-centre of the opaque samples, so a 10×10 solid centred on (11, 22) reports (11, 22). fn opaque_centroid(s: &Session) -> (f64, f64, u32) { @@ -399,6 +400,19 @@ fn lift_and_extract_work_area() { assert!((spans[1].0 - 2.0).abs() < 1e-6 && (spans[1].1 - 8.0).abs() < 1e-6, "{spans:?}"); } +/// A project saved with an inverted work area (by an older build) is edited without a panic: +/// `clamp` with crossed bounds would abort. +#[test] +fn an_inverted_saved_work_area_takes_an_end_edit() { + let mut s = comp(); + let cid = s.active_comp_id().unwrap(); + std::sync::Arc::make_mut(&mut s.project).comp_mut(cid).unwrap().work_area = (Tick::from_seconds_f64(9.0), Tick::from_seconds_f64(2.0)); + s.execute("comp.workArea", json!({"end": 1.0})).unwrap(); + s.execute("comp.workArea", json!({"start": 50.0})).unwrap(); + let c = s.active_comp().unwrap(); + assert!(c.work_area.0 >= Tick::ZERO && c.work_area.0 + c.frame_duration() <= c.work_area.1 && c.work_area.1 <= c.duration); +} + #[test] fn work_area_stays_ordered_when_the_start_passes_the_end() { let ordered = |s: &Session| { From 8cb8a416acdeb9706c5f5a653d639ab0753c5311 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Sat, 10 Oct 2026 10:52:20 -0400 Subject: [PATCH 016/107] Packaging: the legacy-folder fallback covers every release before this installer change (#466) v0.7.0 has no App Paths Path value either, so the comments no longer name a release. (cherry picked from commit 4ae87f1e8351fca3fb7b2d3395affa30203d838f) --- packaging/windows/effectcraft.wxs | 4 ++-- packaging/windows/test-msi.ps1 | 7 ++++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/packaging/windows/effectcraft.wxs b/packaging/windows/effectcraft.wxs index f4710add..6757c446 100644 --- a/packaging/windows/effectcraft.wxs +++ b/packaging/windows/effectcraft.wxs @@ -31,8 +31,8 @@ diff --git a/packaging/windows/test-msi.ps1 b/packaging/windows/test-msi.ps1 index 0bed8c43..7cdaef86 100644 --- a/packaging/windows/test-msi.ps1 +++ b/packaging/windows/test-msi.ps1 @@ -3,8 +3,8 @@ Install-test the EffectCraft MSI's folder handling with stub binaries (no Rust build). .DESCRIPTION - Builds the MSI around two stand-in executables as 0.0.1 the way 0.6.0 and earlier were (no App - Paths "Path" value), then as 0.0.2 and 0.0.3, validates them (ICE), and, silently and per-machine: + Builds the MSI around two stand-in executables as 0.0.1 the way releases before this installer + change are (no App Paths "Path" value), then as 0.0.2 and 0.0.3, validates them (ICE), and, silently and per-machine: 1. installs 0.0.1 into a folder with spaces and a non-ASCII name (INSTALLFOLDER=...), 2. upgrades to 0.0.2 without naming a folder: it must stay in that folder (found from the installed effectcraft.exe, as there is no Path value yet), @@ -60,7 +60,8 @@ New-Item -ItemType Directory -Force -Path $Bin | Out-Null Copy-Item (Join-Path $env:SystemRoot 'System32\notepad.exe') (Join-Path $Bin 'effectcraft.exe') [IO.File]::WriteAllText((Join-Path $Bin 'effectcraft-cli.exe'), 'effectcraft-cli stand-in') -# 0.0.1 stands in for 0.6.0 and earlier: the same package without the App Paths "Path" value. +# 0.0.1 stands in for releases before this installer change: the same package without the App +# Paths "Path" value. $Wxs = Join-Path $PSScriptRoot 'effectcraft.wxs' $LegacyWxs = Join-Path $Work 'effectcraft-legacy.wxs' $pathValue = '(?m)^.*]*Name="Path"[^>]*/>\r?\n' From 2500719969c8b0ffd84b70e4011e89b5b6b8c38a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 16:30:09 +0000 Subject: [PATCH 017/107] UI: an agent's injected drag never warps the real cursor (#592 follow-up) Control-channel pointer events (ui.drag) scrub numbers like a mouse but turn off the edge wrap, so an agent never moves the user's cursor; the TextBox case of the drag test goes (that plug-in isn't in-tree). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f --- crates/ui-egui/src/lib.rs | 5 +-- crates/ui-egui/src/pointer_wrap.rs | 52 ++++++++++++++---------------- 2 files changed, 27 insertions(+), 30 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index c16b3fdb..7cd7a2a5 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1627,7 +1627,8 @@ impl eframe::App for EffectcraftApp { } fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) { - if !self.synthetic.is_empty() { + let injected = !self.synthetic.is_empty(); + if injected { // Pointer events go one per frame so egui sees press → moves → release as a real drag. let pointer = |e: &egui::Event| matches!(e, egui::Event::PointerMoved(_) | egui::Event::PointerButton { .. } | egui::Event::MouseWheel { .. }); let n = if pointer(&self.synthetic[0]) { @@ -1640,7 +1641,7 @@ impl eframe::App for EffectcraftApp { }; raw_input.events.extend(self.synthetic.drain(..n)); } - if pointer_wrap::prepare_input(ctx, raw_input) { + if pointer_wrap::prepare_input(ctx, raw_input, injected) { self.session.history.merge_key = None; } } diff --git a/crates/ui-egui/src/pointer_wrap.rs b/crates/ui-egui/src/pointer_wrap.rs index fa4f09c4..d4c29b17 100644 --- a/crates/ui-egui/src/pointer_wrap.rs +++ b/crates/ui-egui/src/pointer_wrap.rs @@ -31,8 +31,10 @@ pub(crate) fn track(response: &Response) { /// Rebase only when the OS acknowledges a successful warp: queued events from before /// it must not be interpreted as another monitor-width movement. A coalesced event /// may include a little real movement beyond the requested destination. -/// Returns whether a numeric gesture ended, so the app can close its Undo group. -pub(crate) fn prepare_input(ctx: &Context, raw: &mut RawInput) -> bool { +/// Returns whether a numeric gesture ended, so the app can close its Undo group. `injected`: +/// this frame carries pointer events from the control channel (an agent's `ui.drag`), whose +/// gesture must never move the user's real cursor. +pub(crate) fn prepare_input(ctx: &Context, raw: &mut RawInput, injected: bool) -> bool { let lost_focus = !raw.focused || raw.events.iter().any(|e| matches!(e, Event::WindowFocused(false))); let (down, last_pos, modifiers) = ctx.input(|i| (i.pointer.primary_down(), i.pointer.latest_pos(), i.modifiers)); ctx.data_mut(|d| { @@ -46,7 +48,7 @@ pub(crate) fn prepare_input(ctx: &Context, raw: &mut RawInput) -> bool { // finish this scrub so focus returning cannot restart it or leave Undo merged. raw.events.push(Event::PointerButton { pos: pos - state.offset, button: PointerButton::Primary, pressed: false, modifiers }); } - let touch = raw.events.iter().any(|e| matches!(e, Event::Touch { .. })); + let touch = injected || raw.events.iter().any(|e| matches!(e, Event::Touch { .. })); if lost_focus || touch || raw.events.iter().any(|e| matches!(e, Event::WindowFocused(false) | Event::PointerButton { button: PointerButton::Primary, .. })) @@ -190,6 +192,7 @@ mod tests { angle: bool, angle_rects: Option<(Rect, Rect)>, modifiers: Modifiers, + injected: bool, } impl Scrub { @@ -206,6 +209,7 @@ mod tests { angle: false, angle_rects: None, modifiers: Modifiers::NONE, + injected: false, } } @@ -219,7 +223,7 @@ mod tests { ..Default::default() }; raw.events.push(Event::ModifiersChanged(self.modifiers)); - prepare_input(&self.ctx, &mut raw); + prepare_input(&self.ctx, &mut raw, self.injected); let _ = self.ctx.run_ui(raw, |ui| { if self.standard { ui.put( @@ -338,6 +342,17 @@ mod tests { assert_eq!(s.value, before - 3.0, "coalesced movement after a left-edge warp is preserved"); } + /// An agent's `ui.drag` over the control channel scrubs the value but never moves the user's + /// real cursor, even at the window's (monitor's) edge. + #[test] + fn injected_drags_never_warp_the_real_cursor() { + let mut s = Scrub::new(); + s.injected = true; + s.begin(); + s.move_to(pos2(799.0, 108.0)); + assert!(s.warps.is_empty()); + } + #[test] fn normal_pointer_movement_does_not_warp() { let mut s = Scrub::new(); @@ -438,42 +453,23 @@ mod tests { } #[test] - fn real_properties_angles_and_textbox_drags_keep_each_gesture_in_one_undo_step() { + fn real_properties_and_angle_drags_keep_each_gesture_in_one_undo_step() { use crate::{EffectcraftApp, dock::PanelKind}; use effectcraft_engine::project::LayerId; use eframe::App; use serde_json::json; - for (textbox, angle) in [(false, false), (false, true), (true, false)] { + for angle in [false, true] { let mut session = effectcraft_host::session(); - // This patch also works without the optional TextBox plugin. - if textbox && effectcraft_engine::effects::plugin::plugin("org.effectcraft.text-box").is_none() { - continue; - } session.execute("comp.new", json!({"width": 32, "height": 16})).unwrap(); let lid = session.execute("layer.newSolid", json!({"width": 8, "height": 8})).unwrap()["layer"].as_u64().unwrap(); - if textbox { - session.execute("effect.apply", json!({"layer": lid, "effect": "TextBox"})).unwrap(); - } - let path = if textbox { - "effects/#1/paddingX" - } else if angle { - "transform/rotation" - } else { - "transform/position" - }; + let path = if angle { "transform/rotation" } else { "transform/position" }; let prop = session.active_comp().unwrap().layer(LayerId(lid)).unwrap().props.prop(path).unwrap(); let uid = prop.uid; let original = prop.value.clone(); let undo = session.history.undo.len(); - let panel = if textbox { PanelKind::EffectControls } else { PanelKind::Properties }; - let id = if textbox { - format!("effectControls.prop.{uid}.value") - } else if angle { - format!("properties.prop.{uid}.value") - } else { - format!("properties.prop.{uid}.value.0") - }; + let panel = PanelKind::Properties; + let id = if angle { format!("properties.prop.{uid}.value") } else { format!("properties.prop.{uid}.value.0") }; let mut app = EffectcraftApp::new(session); app.show_panel(panel); app.toggle_maximize(panel); From 362d299642afb11b23336a41a30e6d93a2556490 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 16:30:10 +0000 Subject: [PATCH 018/107] Fonts: No matching fonts goes through the interface catalog (#593 follow-up) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f --- crates/ui-egui/src/i18n/ui.rs | 4 ++++ crates/ui-egui/src/panels/text_panels.rs | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index fdcaa27e..9cb9d736 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -320,6 +320,7 @@ const JAPANESE: &[(&str, &str)] = &[ ("No device list from this host; the system default output is used.", "このホストからはデバイス一覧を取得できないため、システムの既定の出力を使用します。"), ("No layer selected", "レイヤーが選択されていません"), ("No matching effects or presets", "一致するエフェクトまたはプリセットはありません"), + ("No matching fonts", "一致するフォントはありません"), ("No model credits were built into this copy.", "このビルドにはモデルのクレジットが含まれていません。"), ("No nested compositions", "ネストされたコンポジションはありません"), ("No shortcut", "ショートカットなし"), @@ -751,6 +752,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("No device list from this host; the system default output is used.", "此主机无法获取设备列表;将使用系统默认输出。"), ("No layer selected", "未选择图层"), ("No matching effects or presets", "没有匹配的效果或预设"), + ("No matching fonts", "没有匹配的字体"), ("No model credits were built into this copy.", "此副本未内置模型致谢。"), ("No nested compositions", "没有嵌套合成"), ("No shortcut", "无快捷键"), @@ -1242,6 +1244,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("No device list from this host; the system default output is used.", "此主機無法獲取設備清單;將使用系統預設輸出。"), ("No layer selected", "未選取圖層"), ("No matching effects or presets", "沒有匹配的效果或預設集"), + ("No matching fonts", "沒有符合的字體"), ("No model credits were built into this copy.", "此副本未內置模型致謝。"), ("No nested compositions", "沒有嵌套合成"), ("No shortcut", "無快速鍵"), @@ -1729,6 +1732,7 @@ const UKRAINIAN: &[(&str, &str)] = &[ ), ("No layer selected", "Шар не виділено"), ("No matching effects or presets", "Не знайдено відповідних ефектів або наборів"), + ("No matching fonts", "Не знайдено відповідних шрифтів"), ("No model credits were built into this copy.", "Ця копія не містить відомостей про моделі."), ("No nested compositions", "Немає вкладених композицій"), ("No shortcut", "Немає клавіатурного скорочення"), diff --git a/crates/ui-egui/src/panels/text_panels.rs b/crates/ui-egui/src/panels/text_panels.rs index 2617ee85..fa64860a 100644 --- a/crates/ui-egui/src/panels/text_panels.rs +++ b/crates/ui-egui/src/panels/text_panels.rs @@ -645,7 +645,7 @@ pub(super) fn font_picker(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: egui: } scroll.show(ui, |ui| { if filtered.is_empty() { - let r = ui.label("No matching fonts"); + let r = ui.label(tr("No matching fonts")); app.auto.add(&format!("{auto}.empty"), r.rect, "No matching fonts"); } let mut was_recent = false; @@ -655,7 +655,7 @@ pub(super) fn font_picker(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: egui: } was_recent = r.recent; let row_width = ui.available_width(); - let label_width = if preview { row_width - 100.0 } else { row_width }; + let label_width = if preview { (row_width - 100.0).max(40.0) } else { row_width }; let resp = ui.add_sized([row_width, 24.0], egui::Button::selectable(index == state.selected, "")); resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, 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())); From 9ae7788f8df1ca12a05a4d2118a2680e27f148e6 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sun, 11 Oct 2026 03:32:01 +1100 Subject: [PATCH 019/107] Keep the playhead on the frame shown in the current-time field. Timecode and Frames entries were parsed as absolute counts without the composition Start Timecode or Start Numbering Frames At offset, so committing the displayed text jumped to another frame. --- crates/engine/src/commands/time.rs | 14 +++++++++++++- crates/engine/src/tests_timeline.rs | 23 +++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/commands/time.rs b/crates/engine/src/commands/time.rs index 9ed41f47..81197602 100644 --- a/crates/engine/src/commands/time.rs +++ b/crates/engine/src/commands/time.rs @@ -38,7 +38,19 @@ pub fn parse_display_time(s: &Session, comp: &Comp, text: &str, current: i64) -> let start = fr.frame_at(comp.display_start) + st.frame_start; effectcraft_time::parse_feet_frames(text, pf, current + start).map(|f| f - start).map_err(|e| e.0) } - None => effectcraft_time::parse_timecode(text, fr, fr.supports_drop_frame(), current).map_err(|e| e.0), + None => { + let parsed = effectcraft_time::parse_timecode(text, fr, fr.supports_drop_frame(), current).map_err(|e| e.0)?; + let relative = text.trim().starts_with('+') || text.trim().starts_with('-'); + if relative { + Ok(parsed) + } else if st.time_display == TimeDisplayStyle::Frames { + // Displayed digits are composition frame plus Start Numbering Frames At. + Ok(parsed - st.frame_start) + } else { + // Displayed timecode includes the composition Start Timecode offset. + Ok(parsed - fr.frame_at(comp.display_start)) + } + } } } diff --git a/crates/engine/src/tests_timeline.rs b/crates/engine/src/tests_timeline.rs index cdfbf8dd..eacf3da3 100644 --- a/crates/engine/src/tests_timeline.rs +++ b/crates/engine/src/tests_timeline.rs @@ -375,6 +375,29 @@ fn time_set_snaps_to_the_nearest_frame_and_ae_timecode() { assert_eq!(effectcraft_time::format_timecode_ae(75, fr, true), "0;00;02;15"); } +#[test] +fn time_set_keeps_the_frame_shown_in_the_current_time_field() { + let mut s = Session::default(); + s.execute( + "comp.new", + json!({"name": "Example", "width": 64, "height": 64, "frameRate": 30, "duration": 0.6, "startTimecode": "0:00:00:05"}), + ) + .unwrap(); + let r = s.execute("time.set", json!({"frame": 0})).unwrap(); + assert_eq!(r["display"], "0:00:00:05"); + let r = s.execute("time.set", json!({"timecode": "0:00:00:05"})).unwrap(); + assert_eq!((r["frame"].as_i64(), r["time"].as_f64()), (Some(0), Some(0.0))); + + s.execute("file.projectSettings", json!({"timeDisplay": "frames"})).unwrap(); + std::sync::Arc::make_mut(&mut s.project).settings.frame_start = 1; + let r = s.execute("time.set", json!({"frame": 12})).unwrap(); + assert_eq!(r["display"], "00013"); + let r = s.execute("time.set", json!({"timecode": "00013"})).unwrap(); + assert_eq!((r["frame"].as_i64(), r["time"].as_f64()), (Some(12), Some(0.4))); + let r = s.execute("time.set", json!({"timecode": "+1"})).unwrap(); + assert_eq!(r["frame"], 13); +} + #[test] fn separate_dimensions_split_and_rejoin() { let (mut s, l) = setup(); From 4df4d9185bf53ecaedb60cfc964385f3d81cd1cd Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Sat, 10 Oct 2026 23:41:07 +0700 Subject: [PATCH 020/107] fix(effect): expose parameter metadata in effect.list (#441) --- crates/engine/src/commands/effect.rs | 51 ++++++++++++++++++++++++++-- 1 file changed, 49 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/commands/effect.rs b/crates/engine/src/commands/effect.rs index 68684650..3515f508 100644 --- a/crates/engine/src/commands/effect.rs +++ b/crates/engine/src/commands/effect.rs @@ -415,7 +415,24 @@ fn list(_: &mut Session, p: &Value) -> Result { filter.as_ref().is_none_or(|f| effectcraft_effects::name_matches(e, f) || e.id.contains(f.as_str()) || e.category.to_ascii_lowercase().contains(f)) }) .map(|e| { - json!({"id": e.id, "name": e.name, "category": e.category, "gpu": e.gpu, "float": e.float, "params": e.params.iter().map(|p| json!({"id": p.id, "name": p.name, "default": p.default.to_json()})).collect::>()}) + json!({"id": e.id, "name": e.name, "category": e.category, "gpu": e.gpu, "float": e.float, "params": e.params.iter().map(|p| { + let mut out = json!({"id": p.id, "name": p.name, "type": p.default.kind_name(), "default": p.default.to_json()}); + match &p.ui { + effectcraft_project::ParamUi::Popup { options } => { + // Engine/MCP popup values are zero-based; JSX exposes one-based values. + out["options"] = json!(options.iter().enumerate().map(|(value, label)| json!({"value": value, "label": label})).collect::>()); + } + effectcraft_project::ParamUi::Slider { min, max, slider_min, slider_max, decimals } => { + out["min"] = json!(min); + out["max"] = json!(max); + out["sliderMin"] = json!(slider_min); + out["sliderMax"] = json!(slider_max); + out["decimals"] = json!(decimals); + } + _ => {} + } + out + }).collect::>()}) }) .collect(); Ok(json!(v)) @@ -469,7 +486,7 @@ pub fn specs() -> Vec { cmd!("effect.copy", "Copy Effects", [], None, "{layer?, effect? | effects?: [uid|name|index]} (default: the selected effects)", has_layers, copy), cmd!("effect.paste", "Paste Effects", [], None, "{layers?} — adds the copied effects to the layers", has_layers, paste), cmd!("effect.reset", "Reset Effect", [], None, "{layer?, effect}", has_layers, reset), - crate::query!("effect.list", "List Effects", "{filter?}", list), + crate::query!("effect.list", "List Effects", "{filter?} → [{params: [{id, name, type, default, options?, min?, max?, sliderMin?, sliderMax?, decimals?}]}]; popup indices are zero-based (JSX uses one-based values)", list), crate::query!( "effect.warning", "Effect Warning", @@ -493,3 +510,33 @@ pub fn specs() -> Vec { ), ] } + +#[cfg(test)] +mod list_metadata_tests { + use super::*; + + #[test] + fn effect_list_exposes_popup_labels_and_numeric_slider_limits() { + let mut s = Session::default(); + let menus = s.execute("effect.list", json!({"filter": "ec.control.dropdown"})).unwrap(); + assert_eq!(menus.as_array().map(Vec::len), Some(1)); + let menu = &menus[0]["params"][0]; + assert_eq!(menu["type"], "enum"); + assert_eq!(menu["default"], 0); + assert_eq!(menu["options"], json!([ + {"value": 0, "label": "Item 1"}, + {"value": 1, "label": "Item 2"}, + {"value": 2, "label": "Item 3"} + ])); + let sliders = s.execute("effect.list", json!({"filter": "ec.control.slider"})).unwrap(); + assert_eq!(sliders.as_array().map(Vec::len), Some(1)); + let slider = &sliders[0]["params"][0]; + assert_eq!(slider["type"], "scalar"); + assert_eq!(slider["min"], -1_000_000.0); + assert_eq!(slider["max"], 1_000_000.0); + assert_eq!(slider["sliderMin"], 0.0); + assert_eq!(slider["sliderMax"], 100.0); + assert_eq!(slider["decimals"], 2); + assert!(slider["options"].is_null()); + } +} From 139ff9a4e3b48c5c9c8b2e52380dd263af353fb7 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sun, 11 Oct 2026 03:50:22 +1100 Subject: [PATCH 021/107] Start Linux audio preview after the first mixed samples, on a short device buffer. Pulse and PipeWire were opening a large default period and playing silence until the feeder caught up, so the picture ran while the sound started late. The stream now waits for queued samples and requests about 10 ms of buffering. --- apps/effectcraft/src/audio_out.rs | 88 +++++++++++++++++++++++++++---- 1 file changed, 79 insertions(+), 9 deletions(-) diff --git a/apps/effectcraft/src/audio_out.rs b/apps/effectcraft/src/audio_out.rs index a7929b6d..7c26db51 100644 --- a/apps/effectcraft/src/audio_out.rs +++ b/apps/effectcraft/src/audio_out.rs @@ -10,11 +10,32 @@ use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::mpsc; use std::thread::JoinHandle; +use std::time::Duration; use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; use cpal::{FromSample, SizedSample}; use effectcraft_ui_egui::audio::{AudioDevice, AudioFeed, AudioOutput}; +/// Device callback period: about 10 ms, clamped so Pulse/PipeWire/ALSA does not open a +/// multi-hundred-millisecond default buffer that plays as silence at the start of preview (#554). +fn preferred_buffer_frames(rate: u32) -> u32 { + (rate / 100).clamp(256, 2048) +} + +/// Wait until the feeder has mixed a block, or until preview is stopped. Starting the stream +/// before that fills the device with silence, so the first real samples are heard late (#554). +fn wait_to_play(feed: &AudioFeed, stop: &mpsc::Receiver<()>) -> bool { + loop { + if feed.queued_frames() > 0 { + return true; + } + match stop.recv_timeout(Duration::from_millis(2)) { + Ok(()) | Err(mpsc::RecvTimeoutError::Disconnected) => return false, + Err(mpsc::RecvTimeoutError::Timeout) => {} + } + } +} + pub struct CpalOut { out: AudioOutput, rate: u32, @@ -131,21 +152,36 @@ impl AudioDevice for CpalOut { }; let mut cfg: cpal::StreamConfig = sup.config(); cfg.sample_rate = cpal::SampleRate(rate); - let stream = match sup.sample_format() { - cpal::SampleFormat::F32 => run::(&dev, &cfg, feed, latency, map), - cpal::SampleFormat::I16 => run::(&dev, &cfg, feed, latency, map), - cpal::SampleFormat::U16 => run::(&dev, &cfg, feed, latency, map), - cpal::SampleFormat::I32 => run::(&dev, &cfg, feed, latency, map), + cfg.buffer_size = cpal::BufferSize::Fixed(preferred_buffer_frames(rate)); + let build = |cfg: &cpal::StreamConfig| match sup.sample_format() { + cpal::SampleFormat::F32 => run::(&dev, cfg, feed.clone(), latency.clone(), map), + cpal::SampleFormat::I16 => run::(&dev, cfg, feed.clone(), latency.clone(), map), + cpal::SampleFormat::U16 => run::(&dev, cfg, feed.clone(), latency.clone(), map), + cpal::SampleFormat::I32 => run::(&dev, cfg, feed.clone(), latency.clone(), map), f => Err(format!("unsupported sample format {f:?}")), }; - let stream = match stream.and_then(|s| s.play().map(|_| s).map_err(|e| e.to_string())) { + let stream = match build(&cfg) { Ok(s) => s, - Err(e) => { - let _ = ready_tx.send(Err(e)); - return; + Err(_) => { + // Some hosts reject a fixed period; a large default still plays, just later. + cfg.buffer_size = cpal::BufferSize::Default; + match build(&cfg) { + Ok(s) => s, + Err(e) => { + let _ = ready_tx.send(Err(e)); + return; + } + } } }; let _ = ready_tx.send(Ok(())); + if !wait_to_play(&feed, &stop_rx) { + return; + } + if let Err(e) = stream.play() { + eprintln!("effectcraft: audio output: {e}"); + return; + } let _ = stop_rx.recv(); drop(stream); }) @@ -174,3 +210,37 @@ impl Drop for CpalOut { self.stop(); } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn preferred_buffer_is_about_ten_milliseconds() { + assert_eq!(preferred_buffer_frames(48_000), 480); + assert_eq!(preferred_buffer_frames(8_000), 256); + assert_eq!(preferred_buffer_frames(192_000), 1920); + } + + #[test] + fn wait_to_play_returns_once_the_feeder_has_samples() { + let feed = Arc::new(AudioFeed::default()); + let (_tx, rx) = mpsc::channel(); + let f = feed.clone(); + let h = std::thread::spawn(move || wait_to_play(&f, &rx)); + assert_eq!(feed.queued_frames(), 0); + std::thread::sleep(Duration::from_millis(20)); + feed.push(&[0.1, 0.2]); + assert!(h.join().unwrap()); + assert_eq!(feed.queued_frames(), 1); + } + + #[test] + fn wait_to_play_stops_without_samples() { + let feed = AudioFeed::default(); + let (tx, rx) = mpsc::channel(); + let h = std::thread::spawn(move || wait_to_play(&feed, &rx)); + tx.send(()).unwrap(); + assert!(!h.join().unwrap()); + } +} From b28485cb87c54095baf17190e75cf256779c070d Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:08:09 +0200 Subject: [PATCH 022/107] Interface: probe Hangul so Korean text isn't drawn as boxes (#599) --- crates/ui-egui/src/theme.rs | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index a37bead6..b12251ce 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -339,8 +339,9 @@ const SCRIPT_PROBES: &[(char, &str)] = &[ /// Japanese-locale machine would be drawn with the Japanese face. The interface language is the /// better signal, so the probe for its script comes first — kana for Japanese, a Han character for /// the Chinese catalogs. Both scripts are registered, so file, layer and template names in the -/// other one still render; then [`SCRIPT_PROBES`]. The web build has no system fonts and installs -/// nothing. +/// other one still render. Hangul comes next, since the Japanese and Chinese faces may lack it +/// (Hiragino Sans on macOS has none); then [`SCRIPT_PROBES`]. The web build has no system fonts +/// and installs nothing. fn install_script_fallbacks(family_fonts: &mut FontDefinitions, language: &str) -> Vec { #[cfg(target_arch = "wasm32")] { @@ -354,10 +355,13 @@ fn install_script_fallbacks(family_fonts: &mut FontDefinitions, language: &str) // language's face draws them. const KANA: (char, &str) = ('あ', "japanese-system"); const HAN: (char, &str) = ('文', "chinese-system"); + // Hangul is probed after kana and Han so their order is unchanged; a face that already covers it + // is skipped as seen. + const HANGUL: (char, &str) = ('한', "korean-system"); // Han text follows the interface language, not the operating system's locale. fonts::set_cjk_locale(cjk_locale(language)); let base = fonts::resolve("Inter", "Regular").face; - let cjk = if is_chinese(language) { [HAN, KANA] } else { [KANA, HAN] }; + let cjk = if is_chinese(language) { [HAN, KANA, HANGUL] } else { [KANA, HAN, HANGUL] }; let mut names = Vec::new(); let mut seen = Vec::new(); for &(probe, name) in cjk.iter().chain(SCRIPT_PROBES) { @@ -597,6 +601,24 @@ mod tests { install_script_fallbacks(&mut english, "en"); } + #[test] + fn hangul_gets_a_face_when_one_is_installed() { + use effectcraft_text::fonts; + let mut fonts_def = FontDefinitions::default(); + install_script_fallbacks(&mut fonts_def, "en"); + let base = fonts::resolve("Inter", "Regular").face; + let id = fonts::fallback_for('한', base); + let face = fonts::face(id); + if !face.has_char('한') { + eprintln!("no Hangul font is installed: nothing to check here"); + return; + } + let Some(font) = face.font() else { return }; + let bytes = font.data().as_bytes(); + let covered = fonts_def.font_data.values().any(|d| d.font.as_ref() == bytes && d.index == face.info.index); + assert!(covered, "a face covering Hangul must be registered with egui"); + } + #[test] fn a_fallback_face_is_registered_at_most_once() { let mut fonts = FontDefinitions::default(); From bdae2f0fe32fe9b3343369ab45d89deac4831aee Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:10:42 +0200 Subject: [PATCH 023/107] Rove Across Time keeps roving keys off the neighbouring fixed keys' times (#532) --- crates/keyframe/src/lib.rs | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/crates/keyframe/src/lib.rs b/crates/keyframe/src/lib.rs index 13814c53..80537149 100644 --- a/crates/keyframe/src/lib.rs +++ b/crates/keyframe/src/lib.rs @@ -426,7 +426,13 @@ pub fn retime_roving(keys: &mut [Keyframe], spatial: bool) -> bool { for (o, i) in (a + 1..b).enumerate() { let f = if total > 1e-9 { acc[o + 1] / total } else { (o + 1) as f64 / (b - a) as f64 }; let span = i128::from(t1.0) - i128::from(t0.0); - let retimed = i128::from(t0.0) + (span as f64 * f).round() as i128; + let mut retimed = i128::from(t0.0) + (span as f64 * f).round() as i128; + // Keep every roving key strictly between its neighbours: a zero-length segment would otherwise + // put it on a fixed key's time, and two keys can't share a time. + let (lo, hi) = (i128::from(keys[i - 1].time.0) + 1, i128::from(t1.0) - (b - i) as i128); + if lo <= hi { + retimed = retimed.clamp(lo, hi); + } let nt = Tick(retimed.clamp(i128::from(i64::MIN), i128::from(i64::MAX)) as i64); if nt != keys[i].time { keys[i].time = nt; @@ -817,6 +823,21 @@ mod tests { assert!(!keys[0].roving); } + #[test] + fn roving_keys_never_share_a_time_with_a_fixed_key() { + // Zero-length last segment (issue #532) and zero-length first segment. + for xs in [[0.0, 40.0, 120.0, 120.0], [0.0, 0.0, 40.0, 120.0]] { + let mut keys: Vec = xs.iter().enumerate().map(|(i, x)| Keyframe::new(s(i as f64), Value::Vec2([*x, 64.0]))).collect(); + for k in &mut keys { + k.spatial_auto = false; + } + keys[1].roving = true; + keys[2].roving = true; + retime_roving(&mut keys, true); + assert!(keys.windows(2).all(|w| w[0].time < w[1].time), "{xs:?}: {:?}", keys.iter().map(|k| k.time).collect::>()); + } + } + #[test] fn side_ease_reports_linear_and_eased_sides() { let keys = vec![Keyframe::new(s(0.0), Value::Scalar(0.0)), Keyframe::new(s(2.0), Value::Scalar(100.0)).eased()]; From d5f8fb54cb3c52a22f8a3e65c8b91f2a5f37727d Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:11:00 +0200 Subject: [PATCH 024/107] Viewer: passive Composition viewers apply the monitor color conversion (#547) --- crates/ui-egui/src/panels/viewer_tools.rs | 13 +++-- crates/ui-egui/src/panels/viewers.rs | 36 +++++++++++- .../ui-egui/tests/viewer_passive_display.rs | 58 +++++++++++++++++++ 3 files changed, 101 insertions(+), 6 deletions(-) create mode 100644 crates/ui-egui/tests/viewer_passive_display.rs diff --git a/crates/ui-egui/src/panels/viewer_tools.rs b/crates/ui-egui/src/panels/viewer_tools.rs index 06679783..e9a39943 100644 --- a/crates/ui-egui/src/panels/viewer_tools.rs +++ b/crates/ui-egui/src/panels/viewer_tools.rs @@ -349,6 +349,14 @@ fn transformed(img: &egui::ColorImage, ch: Channel, colorized: bool, stops: f32, egui::ColorImage::new(img.size, px.into_iter().map(|a| Color32::from_rgba_premultiplied(a[0], a[1], a[2], a[3])).collect()) } +/// The display conversion's inputs, for the texture caches. +pub(crate) fn display_key(app: &EffectcraftApp, has_conversion: bool) -> String { + let opts = &app.session.state.viewer; + let custom = if opts.simulation.profile == vw::SimProfile::MyCustom { format!("{:?}", app.session.prefs.custom_rgb) } else { String::new() }; + format!("{:?}{:?}{}{custom}", has_conversion, opts.simulation, app.session.prefs.previews.display_profile) + + &format!("{:?}{:?}{}", app.session.project.settings.working_space, app.session.project.settings.output_space, opts.display_color_management) +} + /// Draw the frame into `comp_rect`: the rendered frame (covering the region of interest when one /// is set), or the snapshot while Show Snapshot is on / F5 is held, through Show Channel and the /// exposure. Fast Previews ▸ Wireframe draws layer outlines instead. @@ -378,10 +386,7 @@ pub(crate) fn draw_frame(app: &mut EffectcraftApp, ctx: &egui::Context, painter: return; } let dc = vw::DisplayColor::of(&app.session); - // The display conversion's inputs, for the texture caches. - let custom = if opts.simulation.profile == vw::SimProfile::MyCustom { format!("{:?}", app.session.prefs.custom_rgb) } else { String::new() }; - let dc_key = format!("{:?}{:?}{}{custom}", dc.is_some(), opts.simulation, app.session.prefs.previews.display_profile) - + &format!("{:?}{:?}{}", app.session.project.settings.working_space, app.session.project.settings.output_space, opts.display_color_management); + let dc_key = display_key(app, dc.is_some()); let plain = opts.channel == Channel::Rgb && opts.exposure == 0.0 && dc.is_none(); let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)); if snap { diff --git a/crates/ui-egui/src/panels/viewers.rs b/crates/ui-egui/src/panels/viewers.rs index f23fd511..8d235133 100644 --- a/crates/ui-egui/src/panels/viewers.rs +++ b/crates/ui-egui/src/panels/viewers.rs @@ -102,7 +102,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: u32, rect: Rect) { app.request_frame(cid, frame, scale); let key = app.frame_key(cid, frame, scale); if let Some((key, img)) = app.frames.get_for_viewer(&key, app.passive_tex.get(&id).map(|(k, _)| k)) { - set_texture(app, &ctx, id, key, img); + let dc = effectcraft_engine::viewer::DisplayColor::of(&app.session); + let dkey = display_hash(&super::viewer_tools::display_key(app, dc.is_some())); + set_texture(app, &ctx, id, key, img, dc, dkey); } match app.passive_tex.get(&id).filter(|(k, _)| k.comp == cid.0) { Some((_, tex)) => { @@ -126,16 +128,38 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: u32, rect: Rect) { } /// Keep the frame viewer `id` shows in its texture. -fn set_texture(app: &mut EffectcraftApp, ctx: &egui::Context, id: u32, key: FrameKey, img: FrameImage) { +fn set_texture( + app: &mut EffectcraftApp, + ctx: &egui::Context, + id: u32, + key: FrameKey, + img: FrameImage, + dc: Option, + dkey: u64, +) { + let dc_id = egui::Id::new(("passive-viewer-display", id)); + let same_display = ctx.data(|d| d.get_temp::(dc_id)) == Some(dkey); if let Some((k, _)) = app.passive_tex.get_mut(&id) && *k == key + && same_display { // An undo or an edit elsewhere can reuse this content at a newer revision. Keep that // revision as the lower bound for subsequent intermediate edit frames. k.revision = key.revision; return; } + ctx.data_mut(|d| d.insert_temp(dc_id, dkey)); let opts = super::viewer::zoom_texture_options(app.session.prefs.viewer_zoom_smooth()); + // The monitor conversion the active viewer applies (Use Display Color Management). + let img = match (img, &dc) { + (FrameImage::Cpu(img), Some(dc)) => { + let mut px: Vec<[u8; 4]> = img.pixels.iter().map(|c| c.to_array()).collect(); + dc.apply(&mut px); + let pixels = px.into_iter().map(|a| Color32::from_rgba_premultiplied(a[0], a[1], a[2], a[3])).collect(); + FrameImage::Cpu(Arc::new(egui::ColorImage::new(img.size, pixels))) + } + (img, _) => img, + }; let tex = match (img, app.passive_tex.remove(&id)) { (FrameImage::Cpu(img), Some((_, PassiveTexture::Cpu(mut h)))) if h.size() == crate::frames::fitted_size(img.size, crate::frames::max_texture_side(ctx)) => @@ -165,6 +189,14 @@ fn set_texture(app: &mut EffectcraftApp, ctx: &egui::Context, id: u32, key: Fram app.passive_tex.insert(id, (key, tex)); } +/// A hash of the display conversion's inputs (see `viewer_tools::display_key`). +fn display_hash(key: &str) -> u64 { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + key.hash(&mut h); + h.finish() +} + /// Give a passive viewer's texture back (GPU textures are registered with the renderer). fn free(app: &EffectcraftApp, tex: Option) { if let (Some(PassiveTexture::Gpu(id, _)), Some(rs)) = (tex, &app.wgpu) { diff --git a/crates/ui-egui/tests/viewer_passive_display.rs b/crates/ui-egui/tests/viewer_passive_display.rs new file mode 100644 index 00000000..797689af --- /dev/null +++ b/crates/ui-egui/tests/viewer_passive_display.rs @@ -0,0 +1,58 @@ +//! A passive Composition viewer applies the same monitor conversion as the active one (#547). +use effectcraft_engine::Session; +use effectcraft_ui_egui::{EffectcraftApp, menus, panels::viewers}; +use egui::{Color32, Context, ImageData, RawInput, Rect, pos2, vec2}; +use serde_json::json; +use std::collections::HashMap; + +fn frames(app: &mut EffectcraftApp, ctx: &Context, textures: &mut HashMap>) { + for _ in 0..4 { + let mut output = ctx.run_ui(RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(256.0, 256.0))), ..Default::default() }, |ui| { + viewers::show(app, ui, 0, Rect::from_min_size(pos2(0.0, 0.0), vec2(128.0, 256.0))); + viewers::show(app, ui, 1, Rect::from_min_size(pos2(128.0, 0.0), vec2(128.0, 256.0))); + }); + for (&id, deltas) in &output.textures_delta.set { + let Some(name) = ctx.tex_manager().read().meta(id).map(|m| m.name.clone()) else { continue }; + for delta in deltas { + let ImageData::Color(image) = &delta.image; + if delta.pos.is_none() { + textures.insert(name.clone(), image.pixels.clone()); + } + } + } + output.textures_delta.clear(); + std::thread::sleep(std::time::Duration::from_millis(100)); + } +} + +fn pixel(textures: &HashMap>, name: &str) -> [u8; 4] { + let p = &textures[name]; + p[p.len() / 2].to_array() +} + +#[test] +fn passive_viewer_matches_the_monitor_conversion() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Display test", "width": 16, "height": 16, "duration": 1})).unwrap(); + s.execute("layer.newSolid", json!({"name": "Color", "color": "#406080", "width": 16, "height": 16})).unwrap(); + s.execute("edit.deselectAll", json!({})).unwrap(); + let mut app = EffectcraftApp::new(s); + let ctx = Context::default(); + menus::invoke(&mut app, &ctx, "view.newViewer", json!({})).unwrap(); + let mut textures = HashMap::new(); + app.session.execute("file.projectSettings", json!({"workingSpace": "rec2020"})).unwrap(); + app.session.execute("prefs.set", json!({"key": "previews.displayProfile", "value": "p3"})).unwrap(); + frames(&mut app, &ctx, &mut textures); + assert_eq!(pixel(&textures, "viewer-display"), [37, 89, 122, 255]); + assert_eq!(pixel(&textures, "viewer-0"), [37, 89, 122, 255]); + // Changing which viewer is active keeps the conversion in both. + viewers::activate(&mut app, &ctx, 0); + frames(&mut app, &ctx, &mut textures); + assert_eq!(pixel(&textures, "viewer-display"), [37, 89, 122, 255]); + assert_eq!(pixel(&textures, "viewer-1"), [37, 89, 122, 255]); + // No output-to-monitor conversion needed: both show the raw frame. + app.session.execute("prefs.set", json!({"key": "previews.displayProfile", "value": "srgb"})).unwrap(); + frames(&mut app, &ctx, &mut textures); + assert_eq!(pixel(&textures, "viewer-frame"), [0, 90, 125, 255]); + assert_eq!(pixel(&textures, "viewer-1"), [0, 90, 125, 255]); +} From ad0fc4da094f48fcfd7676c14b203da3cb62d6ca Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:10:41 +0200 Subject: [PATCH 025/107] Locked viewer: re-convert pixels when the display profile or output simulation changes (#546) --- crates/ui-egui/src/panels/viewer.rs | 6 +- crates/ui-egui/tests/ui_locked_color.rs | 81 +++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 crates/ui-egui/tests/ui_locked_color.rs diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index 9adf007f..88995483 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -522,10 +522,14 @@ fn locked_pane(app: &mut EffectcraftApp, ui: &mut egui::Ui, full: Rect, bg: Colo let cam = (lv.view != View3D::ActiveCamera && comp.has_3d()) .then(|| app.session.state.views3d.get(&lv.comp).cloned().unwrap_or_default().cam(lv.view, comp.width as f64, comp.height as f64).state()); let dc = effectcraft_engine::viewer::DisplayColor::of(&app.session); + // The display conversion's inputs, keyed as in the main viewer's texture caches. + let sim = app.session.state.viewer.simulation; + let custom = if sim.profile == effectcraft_engine::viewer::SimProfile::MyCustom { format!("{:?}", app.session.prefs.custom_rgb) } else { String::new() }; + let dc_key = format!("{cam:?}{}{sim:?}{}{custom}", dc.is_some(), app.session.prefs.previews.display_profile); let key = { use std::hash::{Hash, Hasher}; let mut h = std::collections::hash_map::DefaultHasher::new(); - (app.session.revision, lv.comp.0, t.0, scale.to_bits(), format!("{cam:?}{}", dc.is_some())).hash(&mut h); + (app.session.revision, lv.comp.0, t.0, scale.to_bits(), dc_key).hash(&mut h); h.finish() }; let id = egui::Id::new("viewer-locked"); diff --git a/crates/ui-egui/tests/ui_locked_color.rs b/crates/ui-egui/tests/ui_locked_color.rs new file mode 100644 index 00000000..d942da11 --- /dev/null +++ b/crates/ui-egui/tests/ui_locked_color.rs @@ -0,0 +1,81 @@ +//! The split locked viewer re-converts its pixels when the display profile or the output +//! simulation changes (egui_kittest, texture uploads captured). + +use effectcraft_engine::{Session, render::RenderOpts, viewer::DisplayColor}; +use effectcraft_ui_egui::EffectcraftApp; +use egui_kittest::{Harness, TestRenderer}; +use serde_json::{Value, json}; +use std::{cell::RefCell, collections::HashMap, rc::Rc}; + +type Images = Rc>>; + +struct Capture(Images); + +impl TestRenderer for Capture { + fn handle_delta(&mut self, delta: &mut egui::TexturesDelta) { + for (id, ops) in std::mem::take(&mut delta.set) { + for op in ops { + let egui::ImageData::Color(image) = op.image; + if op.pos.is_none() && image.size[0] <= 256 && image.size[1] <= 256 { + self.0.borrow_mut().insert(id, (*image).clone()); + } + } + } + for id in std::mem::take(&mut delta.free) { + self.0.borrow_mut().remove(&id); + } + } + + fn render(&mut self, _: &egui::Context, _: &egui::FullOutput) -> Result { + Err("Texture inspection only".into()) + } +} + +fn invoke(h: &mut Harness<'_, EffectcraftApp>, command: &str, params: Value) { + let ctx = h.ctx.clone(); + effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, command, params).unwrap(); + h.run_steps(3); +} + +fn locked_center(h: &Harness<'_, EffectcraftApp>, images: &Images) -> [u8; 4] { + let (_, texture) = h.ctx.data(|d| d.get_temp::<(u64, egui::TextureHandle)>(egui::Id::new("viewer-locked"))).unwrap(); + let images = images.borrow(); + let image = images.get(&texture.id()).unwrap(); + image.pixels[(image.size[1] / 2) * image.size[0] + image.size[0] / 2].to_array() +} + +fn expected_center(h: &mut Harness<'_, EffectcraftApp>) -> [u8; 4] { + let s = &mut h.state_mut().session; + let comp = s.active_comp_id().unwrap(); + let image = s.render(comp, s.time(), RenderOpts::default()); + let ci = effectcraft_ui_egui::frames::to_color_image(&image); + let mut pixels = vec![ci.pixels[(ci.size[1] / 2) * ci.size[0] + ci.size[0] / 2].to_array()]; + DisplayColor::of(s).unwrap().apply(&mut pixels); + pixels[0] +} + +#[test] +fn locked_viewer_follows_display_profile_and_simulation() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Color", "width": 64, "height": 64, "duration": 1})).unwrap(); + s.execute("layer.newSolid", json!({"name": "Plate", "color": "#406080", "width": 64, "height": 64})).unwrap(); + s.execute("file.projectSettings", json!({"workingSpace": "srgb", "outputSpace": "rec2020", "renderer": "software"})).unwrap(); + s.execute("prefs.set", json!({"key": "previews.displayProfile", "value": "srgb"})).unwrap(); + s.execute("view.displayColorManagement", json!({"value": true})).unwrap(); + let images = Images::default(); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).renderer(Capture(images.clone())).build_eframe(|_| EffectcraftApp::new(s)); + h.run_steps(3); + invoke(&mut h, "view.splitLockedViewer", json!({})); + let first = expected_center(&mut h); + assert_eq!(locked_center(&h, &images), first); + + invoke(&mut h, "prefs.set", json!({"key": "previews.displayProfile", "value": "p3"})); + let after_display = expected_center(&mut h); + assert_ne!(after_display, first); + assert_eq!(locked_center(&h, &images), after_display); + + invoke(&mut h, "view.simulateOutput", json!({"profile": "rec709", "preserveRgb": true})); + let after_sim = expected_center(&mut h); + assert_ne!(after_sim, after_display); + assert_eq!(locked_center(&h, &images), after_sim); +} From 9cae69a3666991ec9f758295d9577d05ee5a990f Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:11:04 +0200 Subject: [PATCH 026/107] Effects: CC Power Pin Unstretch reverses the pin mapping (#534) --- crates/effects/src/catalog.rs | 3 +++ crates/effects/src/distort3.rs | 36 +++++++++++++++++++++++++++++++--- crates/gpu/src/fx_stylize.rs | 3 +++ 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index 009220c8..82678eb8 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -86,6 +86,7 @@ const CPU_ONLY_CONTROLS: &[(&str, &[&str])] = &[ ("ec.blur.cameralens", &["irisProperties/diffractionFringe", "blurMap/blurMapLayer"]), ("ec.noise.removegrain", &["temporalFiltering/enabled"]), ("ec.distort.liquify", &["viewFreezeAreaMask", "viewMesh"]), + ("ec.distort.ccpowerpin", &["unstretch"]), ("ec.matte.refinesoft", &["reduceChatter", "useMotionBlur", "invert"]), ("ec.matte.refinehard", &["reduceChatter", "useMotionBlur", "invert"]), ]; @@ -201,6 +202,8 @@ mod tests { assert!(ok("ec.generate.cellpattern", &[("cellPattern", Value::Enum(3))])); assert!(!ok("ec.generate.cellpattern", &[("cellPattern", Value::Enum(9))])); assert!(!ok("ec.distort.turbulentdisplace", &[("pinning", Value::Enum(9))])); + assert!(ok("ec.distort.ccpowerpin", &[])); + assert!(!ok("ec.distort.ccpowerpin", &[("unstretch", Value::Bool(true))])); assert!(ok("ec.blur.gaussian", &[])); } diff --git a/crates/effects/src/distort3.rs b/crates/effects/src/distort3.rs index cbba856c..27da970f 100644 --- a/crates/effects/src/distort3.rs +++ b/crates/effects/src/distort3.rs @@ -182,8 +182,9 @@ fn cc_power_pin(ctx: &EffectCtx, mut b: Buf) -> Buf { let (x0, y0) = (-ex[1] / 100.0 * lw, -ex[0] / 100.0 * lh); let (x1, y1) = (lw + ex[2] / 100.0 * lw, lh + ex[3] / 100.0 * lh); let corners_layer = [ctx.params.v2("topLeft"), ctx.params.v2("topRight"), ctx.params.v2("bottomRight"), ctx.params.v2("bottomLeft")]; - // Grow the buffer to hold the pinned quad. - if !ctx.adjustment { + let unstretch = ctx.params.b("unstretch"); + // Grow the buffer to hold the pinned quad (Unstretch writes inside the layer rectangle, so it needs no room). + if !ctx.adjustment && !unstretch { let mut need = 0.0f64; for c in &corners_layer { let (px, py) = b.to_px(*c); @@ -198,7 +199,8 @@ fn cc_power_pin(ctx: &EffectCtx, mut b: Buf) -> Buf { vec2(p.0, p.1) }); let persp = (ctx.params.f("perspective") / 100.0).clamp(0.0, 1.0); - let Some(inv) = Mat3::square_to_quad(q).inverse() else { return b }; + let fwd = Mat3::square_to_quad(q); + let Some(inv) = fwd.inverse() else { return b }; let src = b.img.clone(); let (sc, off) = (b.scale, b.offset); let bil = move |u: f64, v: f64| -> (f64, f64) { @@ -208,6 +210,17 @@ fn cc_power_pin(ctx: &EffectCtx, mut b: Buf) -> Buf { }; b.img = crate::util::gen_image(src.width, src.height, |x, y| { let (px, py) = (x as f64 + 0.5, y as f64 + 0.5); + if unstretch { + // Reverse pinning: the layer rectangle is the target, and the pinned quad is the region that fills it. + let u = ((px - off[0]) / sc - x0) / (x1 - x0); + let v = ((py - off[1]) / sc - y0) / (y1 - y0); + if !(-1e-9..=1.0 + 1e-9).contains(&u) || !(-1e-9..=1.0 + 1e-9).contains(&v) { + return [0.0; 4]; + } + let m = fwd.apply(vec2(u, v)); + let (bx, by) = bil(u, v); + return src.sample_bilinear(bx + (m.x - bx) * persp, by + (m.y - by) * persp); + } let m = inv.apply(vec2(px, py)); let (mut u, mut v) = (m.x, m.y); if persp < 1.0 { @@ -1127,6 +1140,23 @@ mod tests { assert!((px[0] - 0.5).abs() < 0.1, "{px:?}"); } + #[test] + fn power_pin_unstretch_expands_pinned_region() { + let im = crate::util::gen_image(24, 16, |_, _| [1.0, 0.0, 0.0, 1.0]); + let inset = [("topLeft", pt(6.0, 4.0)), ("topRight", pt(18.0, 4.0)), ("bottomRight", pt(18.0, 12.0)), ("bottomLeft", pt(6.0, 12.0))]; + let opaque = |o: &Buf| o.img.data.iter().filter(|p| p[3] > 0.99).count(); + let off = run("ec.distort.ccpowerpin", &inset, im.clone(), &[]); + assert_eq!(opaque(&off), 96); + let mut set = inset.to_vec(); + set.push(("unstretch", Value::Bool(true))); + let on = run("ec.distort.ccpowerpin", &set, im.clone(), &[]); + assert_eq!(opaque(&on), 24 * 16); + assert!(on.img.get(0, 0)[3] > 0.99); + // Default corners are the identity either way. + let id = run("ec.distort.ccpowerpin", &[("unstretch", Value::Bool(true))], im.clone(), &[]); + assert!(maxd(&id.img, &im) < 1e-4); + } + #[test] fn split_opens_a_gap() { let im = img(40, 30); diff --git a/crates/gpu/src/fx_stylize.rs b/crates/gpu/src/fx_stylize.rs index c39b8d16..8aee71ed 100644 --- a/crates/gpu/src/fx_stylize.rs +++ b/crates/gpu/src/fx_stylize.rs @@ -486,6 +486,9 @@ fn ripple_pulse(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { } fn power_pin(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { + if ctx.params.b("unstretch") { + return None; // Unstretch (reverse pinning) renders on the CPU (catalog CPU_ONLY_CONTROLS). + } let persp = (ctx.params.f("perspective") / 100.0).clamp(0.0, 1.0); let (lw, lh) = (ctx.layer_size[0].max(1.0), ctx.layer_size[1].max(1.0)); let ex = [ctx.params.f("expandTop"), ctx.params.f("expandLeft"), ctx.params.f("expandRight"), ctx.params.f("expandBottom")]; From cbe740dce8974476a05cba4d2baabeabceb2d0c9 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:10:57 +0200 Subject: [PATCH 027/107] CC Simple Wire Removal: Frame Offset takes the strip from the previous or next frame (#533) --- crates/effects/src/keying2.rs | 65 ++++++++++++++++++++++++++++++- crates/gpu/src/fx_pixel2.rs | 4 ++ crates/gpu/src/tests_fx_pixel2.rs | 1 + 3 files changed, 69 insertions(+), 1 deletion(-) diff --git a/crates/effects/src/keying2.rs b/crates/effects/src/keying2.rs index 07d14c6e..f594bfbb 100644 --- a/crates/effects/src/keying2.rs +++ b/crates/effects/src/keying2.rs @@ -340,6 +340,24 @@ fn wire_removal(ctx: &EffectCtx, mut b: Buf) -> Buf { let (nx, ny) = (-ty, tx); let src = b.img.clone(); let soft_w = half * slope as f64; + // Frame Offset: the same pixels from the previous or next frame, resampled into this grid. + let offset = ctx.params.f("frameOffset").round(); + let neighbour = if style == 1 && offset != 0.0 { + ctx.env.host.and_then(|h| h.self_at(ctx.time + offset / ctx.fps(), ctx.env.effect_index)).map(|nb| { + if nb.img.width == b.img.width && nb.img.height == b.img.height && nb.offset == b.offset && nb.scale == b.scale { + nb.img + } else { + let k = nb.scale / b.scale.max(1e-9); + crate::util::gen_image(b.img.width, b.img.height, |x, y| { + let lx = (x as f64 + 0.5 - b.offset[0]) * k + nb.offset[0]; + let ly = (y as f64 + 0.5 - b.offset[1]) * k + nb.offset[1]; + nb.img.sample_bilinear(lx, ly) + }) + } + }) + } else { + None + }; b.img.rows_mut().for_each(|(y, row)| { for (x, px) in row.iter_mut().enumerate() { let (qx, qy) = (x as f64 + 0.5 - ax, y as f64 + 0.5 - ay); @@ -357,7 +375,9 @@ fn wire_removal(ctx: &EffectCtx, mut b: Buf) -> Buf { let t = ((d + edge) / (2.0 * edge)) as f32; lerp4(pos(-edge), pos(edge), t.clamp(0.0, 1.0)) } - // Displace: shift the image across the wire from the nearer side. + 1 if neighbour.is_some() => neighbour.as_ref().map_or(*px, |n| n.get(x as i64, y as i64)), + // Displace: shift the image across the wire from the nearer side (also Frame Offset + // when no other frame is available). 2 | 1 => { let s = if d >= 0.0 { d + 2.0 * (half - ad) + 1.0 } else { d - 2.0 * (half - ad) - 1.0 }; let sh = pos(s); @@ -560,6 +580,49 @@ mod tests { } } + #[test] + fn wire_removal_frame_offset_uses_neighbour_frame() { + use crate::{EffectHost, LayerPixels}; + struct Frames; + impl EffectHost for Frames { + fn layer(&self, _: u64, _: bool) -> Option { + None + } + fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> { + None + } + fn self_at(&self, t: f64, _: usize) -> Option { + // Frame n (at 10 fps) is a flat grey of n / 10. + let v = (t * 10.0).round() as f32 / 10.0; + Some(Buf { img: Image::filled(20, 20, [v, v, v, 1.0]), offset: [0.0; 2], scale: 1.0 }) + } + } + let mut img = Image::filled(20, 20, [0.5, 0.5, 0.5, 1.0]); + for y in 0..20 { + img.set(10, y, [0.0, 0.0, 0.0, 1.0]); + } + let host = Frames; + let run = |style: u32, off: f64| { + let env = EffectEnv { host: Some(&host), frame_rate: 10.0, ..Default::default() }; + let vals = [ + ("pointA", Value::Vec2([10.5, 0.0])), + ("pointB", Value::Vec2([10.5, 20.0])), + ("removalStyle", Value::Enum(style)), + ("thickness", num(3.0)), + ("frameOffset", num(off)), + ]; + run_fx("ec.key.ccsimplewireremoval", &vals, img.clone(), 0.5, env).img + }; + // At 0.5 s (frame 5) offset -1 shows frame 4 and +1 shows frame 6 inside the strip. + let (prev, next) = (run(1, -1.0), run(1, 1.0)); + assert!((prev.get(10, 10)[0] - 0.4).abs() < 1e-4, "{:?}", prev.get(10, 10)); + assert!((next.get(10, 10)[0] - 0.6).abs() < 1e-4, "{:?}", next.get(10, 10)); + // Outside the strip the current frame stays. + assert_eq!(prev.get(2, 10), img.get(2, 10)); + // Displace ignores Frame Offset. + assert_eq!(run(2, -1.0).data, run(2, 1.0).data); + } + #[test] fn inner_outer_key_more_masks_and_cleanup_strokes() { let sq = |x0: f64, x1: f64| MaskShape { name: String::new(), points: vec![[x0, 2.0], [x1, 2.0], [x1, 8.0], [x0, 8.0]], closed: true, inverted: false }; diff --git a/crates/gpu/src/fx_pixel2.rs b/crates/gpu/src/fx_pixel2.rs index 1b1b42be..9de26f01 100644 --- a/crates/gpu/src/fx_pixel2.rs +++ b/crates/gpu/src/fx_pixel2.rs @@ -476,6 +476,10 @@ fn wire_removal(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { if half <= 0.0 { return Some(b); } + // Frame Offset reads another frame from the effect host: that renders on the CPU. + if ctx.params.e("removalStyle") == 1 && ctx.params.f("frameOffset").round() != 0.0 && ctx.env.host.is_some() { + return None; + } let slope = (ctx.params.f("slope") / 100.0).clamp(0.0, 1.0) as f32; let mirror = (ctx.params.f("mirrorBlend") / 100.0).clamp(0.0, 1.0) as f32; let a = b.to_px(ctx.params.v2("pointA")); diff --git a/crates/gpu/src/tests_fx_pixel2.rs b/crates/gpu/src/tests_fx_pixel2.rs index abd0c655..899955b8 100644 --- a/crates/gpu/src/tests_fx_pixel2.rs +++ b/crates/gpu/src/tests_fx_pixel2.rs @@ -243,6 +243,7 @@ fn keys_and_3d() { effect_case("ec.key.ccsimplewireremoval", &[]); effect_case("ec.key.ccsimplewireremoval", &[("removalStyle", e(0)), ("thickness", n(8.0)), ("slope", n(50.0))]); effect_case("ec.key.ccsimplewireremoval", &[("removalStyle", e(1)), ("mirrorBlend", n(60.0)), ("pointB", pt(60.0, 10.0))]); + effect_case("ec.key.ccsimplewireremoval", &[("removalStyle", e(1)), ("frameOffset", n(-2.0)), ("thickness", n(6.0))]); effect_case("ec.key.ccsimplewireremoval", &[("removalStyle", e(3)), ("mirrorBlend", n(30.0)), ("slope", n(20.0))]); effect_case("ec.obsolete.basic3d", &[("swivel", n(25.0))]); effect_case("ec.obsolete.basic3d", &[("tilt", n(-30.0)), ("swivel", n(10.0)), ("specularHighlight", on())]); From 60a85e97d41f1d665737fc65969faae6522ce9c4 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:11:11 +0200 Subject: [PATCH 028/107] Convert Expression to Keyframes: sort the keys by layer time on a time-reversed layer (#531) --- crates/engine/src/commands/prop.rs | 2 ++ crates/engine/src/tests_timeline.rs | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/crates/engine/src/commands/prop.rs b/crates/engine/src/commands/prop.rs index cf459393..cb8c3ad2 100644 --- a/crates/engine/src/commands/prop.rs +++ b/crates/engine/src/commands/prop.rs @@ -981,6 +981,8 @@ fn convert_expr_to_keys(s: &mut Session, p: &Value) -> Result { keys.push(Keyframe::new(layer.layer_time(t), ctx.value(&layer, &pr))); t += fd; } + // A time-reversed layer maps ascending comp times to descending layer times; keys must stay ordered by layer time. + keys.sort_by_key(|k| k.time); let n = keys.len(); with_prop(s, "Convert Expression to Keyframes", None, cid, lid, uid, move |pr, _| { pr.keys = keys; diff --git a/crates/engine/src/tests_timeline.rs b/crates/engine/src/tests_timeline.rs index de746f66..1babd835 100644 --- a/crates/engine/src/tests_timeline.rs +++ b/crates/engine/src/tests_timeline.rs @@ -242,6 +242,25 @@ fn graph_editor_key_and_handle_edits() { assert_eq!(prop(&s, l, "transform/opacity").keys[0].out_interp, Interp::Linear); } +#[test] +fn convert_expression_to_keyframes_on_a_reversed_layer_keeps_values() { + let (mut s, l) = setup(); + // These tests run without an expression host, so the conversion bakes the keyed ramp under the expression. + animate(&mut s, l, "transform/rotation", &[(0.0, json!(0)), (3.0, json!(30))]); + s.execute("prop.setExpression", json!({"layer": l, "path": "transform/rotation", "expression": "value"})).unwrap(); + s.execute("layer.timeReverse", json!({"layers": [l]})).unwrap(); + let at = |t: f64| json!({"layer": l, "path": "transform/rotation", "time": t}); + let sample = |s: &mut Session| [0.5, 1.0, 2.0].map(|t| s.execute("prop.get", at(t)).unwrap()["value"].as_f64().unwrap()); + let before = sample(&mut s); + assert!(before[0] > before[1] && before[1] > before[2], "{before:?}"); + s.execute("prop.convertExpressionToKeyframes", json!({"layer": l, "path": "transform/rotation"})).unwrap(); + let keys = prop(&s, l, "transform/rotation").keys; + assert!(keys.len() > 1); + assert!(keys.windows(2).all(|w| w[0].time < w[1].time)); + let after = sample(&mut s); + assert!(before.iter().zip(after).all(|(b, a)| (b - a).abs() < 1e-6), "{before:?} -> {after:?}"); +} + #[test] fn time_reverse_keyframes() { let (mut s, l) = setup(); From 4ea35a63a26faeddeacf12708cfd514b53b4120d Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:22:56 +0200 Subject: [PATCH 029/107] Fix recording a shortcut: the field no longer types the key too, and Cmd++ parses (#467) --- crates/ui-egui/src/menus.rs | 11 +++++- crates/ui-egui/src/panels/shortcut_editor.rs | 37 ++++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 7851779f..9283dce2 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1391,7 +1391,16 @@ pub fn shortcut_text(s: &str) -> String { pub fn parse_shortcut(s: &str) -> Option<(egui::Modifiers, egui::Key)> { let mut m = egui::Modifiers::NONE; let mut key = None; - let parts: Vec<&str> = if s == "+" { vec!["+"] } else { s.split('+').collect() }; + // The plus key itself is written `+` or `Cmd++`. + let (head, plus) = match s.strip_suffix("++") { + Some(h) => (h, true), + None if s == "+" => ("", true), + None => (s, false), + }; + let mut parts: Vec<&str> = if head.is_empty() { vec![] } else { head.split('+').collect() }; + if plus { + parts.push("+"); + } for p in parts { match p { "Cmd" => { diff --git a/crates/ui-egui/src/panels/shortcut_editor.rs b/crates/ui-egui/src/panels/shortcut_editor.rs index c0893c5b..021dffe7 100644 --- a/crates/ui-egui/src/panels/shortcut_editor.rs +++ b/crates/ui-egui/src/panels/shortcut_editor.rs @@ -204,6 +204,8 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { _ => None, }) }); + // The shortcut field must not also type the key it just recorded (Alt+R came out as `rAlt+R`). + ctx.input_mut(|i| i.events.retain(|e| !matches!(e, egui::Event::Text(_) | egui::Event::Key { .. }))); if let Some((k, m)) = got { st.recording = false; if k != egui::Key::Escape @@ -586,6 +588,41 @@ mod tests { } assert_eq!(split("Cmd+Shift+K"), ([false, true, false, true], "K".into())); assert_eq!(split("Cmd++"), ([false, true, false, false], "+".into())); + // The plus key, with and without modifiers, parses back (typed by hand or recorded). + for txt in ["+", "Cmd++", "Alt++"] { + assert_eq!(crate::menus::parse_shortcut(txt).map(|(_, k)| k), Some(egui::Key::Plus), "{txt}"); + } + assert_eq!(crate::menus::parse_shortcut("Alt+R").map(|(m, k)| (m.alt, k)), Some((true, egui::Key::R))); assert_eq!(split("F9"), ([false; 4], "F9".into())); } + + fn frame(app: &mut EffectcraftApp, ctx: &egui::Context, events: Vec) { + app.auto.begin_frame(); + let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| crate::panels::dialogs::show(app, ui.ctx())); + out.textures_delta.clear(); + } + + #[test] + fn recording_a_shortcut_does_not_also_type_the_key_into_the_field() { + let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); + let ctx = egui::Context::default(); + crate::theme::install(&ctx, &app.tokens, crate::i18n::language(&app)); + open(&mut app); + app.dialog_state.shortcuts.selected = app.session.shortcuts().bindables.first().map(|b| b.key.clone()); + for _ in 0..3 { + frame(&mut app, &ctx, vec![]); + } + let [x, y, w, h] = app.auto.find("shortcuts.record").expect("shortcut field").rect; + let at = pos2(x + w / 2.0, y + h / 2.0); + let click = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at), click(true)]); + frame(&mut app, &ctx, vec![click(false)]); + frame(&mut app, &ctx, vec![]); + assert!(app.dialog_state.shortcuts.recording); + // Alt+R arrives as the key and as the typed text `r`. + let key = egui::Event::Key { key: egui::Key::R, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::ALT }; + frame(&mut app, &ctx, vec![key, egui::Event::Text("r".into())]); + frame(&mut app, &ctx, vec![]); + assert_eq!(app.dialog_state.shortcuts.pending, "Alt+R"); + } } From 0dc4ba381219cef1bb8462b9f5c79418b1e8c2c8 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:27:23 +0200 Subject: [PATCH 030/107] Reopen panels from the Window menu when the dock is empty (#529) --- crates/ui-egui/src/dock.rs | 41 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 65bca880..8785aeb2 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -501,8 +501,27 @@ impl DockNode { }, } } - if !add(self, p, near) && !add(self, p, PanelKind::EffectsPresets) { - add(self, p, PanelKind::Project); + fn add_first(n: &mut DockNode, p: PanelKind) -> bool { + match n { + DockNode::Split { a, b, .. } => add_first(a, p) || add_first(b, p), + DockNode::Tabs { panels, active } if !panels.is_empty() => { + panels.push(p); + *active = panels.len() - 1; + true + } + DockNode::Stack { entries } if !entries.is_empty() => { + entries.push(StackEntry { panel: p, open: true, height: None }); + true + } + _ => false, + } + } + if self.panel_count() == 0 { + // Every panel was closed: there is no anchor, so the panel becomes the whole dock. + *self = tabs(&[p], 0); + } else if !add(self, p, near) && !add(self, p, PanelKind::EffectsPresets) && !add(self, p, PanelKind::Project) { + // None of the anchors is docked (e.g. Minimal with Timeline closed): use the first group. + add_first(self, p); } } } @@ -1181,6 +1200,24 @@ pub fn placeholder(ui: &mut egui::Ui, rect: Rect, t: &Tokens, text: &str) { mod tests { use super::*; + #[test] + fn open_near_in_an_empty_dock_makes_the_panel_the_dock() { + for p in [PanelKind::Composition, PanelKind::Timeline, PanelKind::Project] { + let mut d = tabs(&[], 0); + d.open_near(p, PanelKind::EffectsPresets); + assert!(d.is_visible(p)); + assert_eq!(d.panel_count(), 1); + } + } + + #[test] + fn open_near_without_any_anchor_uses_the_first_group() { + let mut d = tabs(&[PanelKind::Composition], 0); + d.open_near(PanelKind::Timeline, PanelKind::EffectsPresets); + assert!(d.is_visible(PanelKind::Timeline)); + assert_eq!(d.panel_count(), 2); + } + #[test] fn stacked_panels_toggle_open_close() { use PanelKind::*; From 576d386d55a1b446ad42a9881eb012c82f7a5498 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:26:42 +0200 Subject: [PATCH 031/107] Render queue: unsaved project outputs go to ~/Movies or home, not the working directory (#446) --- crates/engine/src/render_queue.rs | 14 +++++++++++++- crates/engine/src/tests_m145.rs | 10 ++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/render_queue.rs b/crates/engine/src/render_queue.rs index 1bf95577..666a56cc 100644 --- a/crates/engine/src/render_queue.rs +++ b/crates/engine/src/render_queue.rs @@ -157,6 +157,14 @@ struct Work { nested_switches: bool, } +/// Where a relative render output goes when there is no default folder and the project is unsaved: +/// `~/Movies` when it exists, else the home folder itself. +pub(crate) fn default_output_base(home: Option) -> Option { + let home = home.filter(|h| !h.as_os_str().is_empty())?; + let movies = home.join("Movies"); + Some(if movies.is_dir() { movies } else { home }) +} + /// Bits per channel an output format writes for a project bit depth (named in the file when /// Settings ▸ Export ▸ Append Bit Depth to File Name is on). pub fn output_bits(format: OutputFormat, depth: effectcraft_project::BitDepth) -> u32 { @@ -324,7 +332,7 @@ impl Session { /// The output path of a queue item: template tokens expanded, ending in the format's /// extension exactly once ([`OutputFormat::with_extension`]), relative paths resolved against - /// the project's folder (or the working directory). `None` when the comp is gone or the + /// the project's folder (an unsaved project's against ~/Movies or home). `None` when the comp is gone or the /// output is empty ("Needs Output"). pub fn resolve_output(&self, item: &RenderQueueItem) -> Option { let comp = self.project.comp(item.comp)?; @@ -351,6 +359,10 @@ impl Session { let base = (!folder.is_empty()).then(|| std::path::PathBuf::from(folder)); let base = base.or_else(|| self.path.as_deref().and_then(|s| std::path::Path::new(s).parent().map(|d| d.to_path_buf())).filter(|d| !d.as_os_str().is_empty())); + // Unsaved project: ~/Movies, else the home folder (as FilmCraft's exports do). The working directory + // is `/` for an app started from the macOS Finder, where nothing can be written (#446). + let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")).map(std::path::PathBuf::from); + let base = base.or_else(|| default_output_base(home)); // (the web has no working directory: relative outputs land in its virtual root) let base = base.or_else(|| std::env::current_dir().ok()).or_else(|| cfg!(target_arch = "wasm32").then(|| "/".into()))?; Some(base.join(path).to_string_lossy().to_string()) diff --git a/crates/engine/src/tests_m145.rs b/crates/engine/src/tests_m145.rs index b55442eb..972bcdc3 100644 --- a/crates/engine/src/tests_m145.rs +++ b/crates/engine/src/tests_m145.rs @@ -682,3 +682,13 @@ fn dynamic_menus_and_window_viewer_labels() { assert!(labels.iter().any(|x| x == l), "{l}"); } } + +#[test] +fn unsaved_project_renders_to_movies_or_home_not_the_working_directory() { + // A Finder-launched app has `/` as its working directory, where renders fail (#446). + let home = tmp("home446"); + assert_eq!(crate::render_queue::default_output_base(Some(home.clone())), Some(home.clone())); + std::fs::create_dir_all(home.join("Movies")).unwrap(); + assert_eq!(crate::render_queue::default_output_base(Some(home.clone())), Some(home.join("Movies"))); + assert_eq!(crate::render_queue::default_output_base(None), None); +} From 068db1f36bb5d47f87fd206e0e1270277cc1c216 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:28:22 +0200 Subject: [PATCH 032/107] Let pan and zoom work while 3D Camera Tracker points are shown (#505) --- .../ui-egui/src/panels/camera_tracker_ui.rs | 16 +++++- crates/ui-egui/src/panels/viewer.rs | 4 +- crates/ui-egui/tests/ui_camera_tracker.rs | 54 +++++++++++++++++++ 3 files changed, 72 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels/camera_tracker_ui.rs b/crates/ui-egui/src/panels/camera_tracker_ui.rs index 18e24813..71cea155 100644 --- a/crates/ui-egui/src/panels/camera_tracker_ui.rs +++ b/crates/ui-egui/src/panels/camera_tracker_ui.rs @@ -215,8 +215,22 @@ pub fn viewer_hook( return; } // Interaction over the image. - let resp = ui.interact(map.area, egui::Id::new("viewer-camera-tracker"), Sense::click_and_drag()); + // While Space (Hand tool) is held the overlay only hovers, so the viewer underneath gets the pan drag. + let sense = if super::space_hand(&ctx) { Sense::hover() } else { Sense::click_and_drag() }; + let resp = ui.interact(map.area, egui::Id::new("viewer-camera-tracker"), sense); app.auto.add("viewer.cameraTracker", map.area, "3D Camera Tracker points"); + // The overlay is on top of the viewer, so a middle-button drag lands here: pan the viewer like the Hand tool. + if resp.dragged_by(egui::PointerButton::Middle) { + let pid = egui::Id::new("viewer-camera-tracker-pan"); + if resp.drag_started() { + let start_pan = app.ui.viewer.pan; + ui.data_mut(|d| d.insert_temp(pid, start_pan)); + } + if let (Some(start_pan), Some(d)) = (ui.data(|m| m.get_temp::<[f32; 2]>(pid)), resp.total_drag_delta()) { + app.ui.viewer.pan = [start_pan[0] + d.x, start_pan[1] + d.y]; + } + return; + } for (id, sp, r) in hits.iter().take(400) { app.auto.add(&format!("viewer.cameraTracker.point.{id}"), Rect::from_center_size(*sp, vec2(r * 2.0 + 4.0, r * 2.0 + 4.0)), "track point"); } diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index 9adf007f..f76c47ac 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -1044,7 +1044,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } let gid = egui::Id::new("viewer-gesture"); - if let Some(hp) = resp.hover_pos() { + // An overlay on top of the viewer (e.g. the camera tracker points) takes the hover, but the pointer is still over the viewer: keep wheel zoom working. + let hover_pos = resp.hover_pos().or_else(|| if resp.contains_pointer() { ui.input(|i| i.pointer.hover_pos()) } else { None }); + if let Some(hp) = hover_pos { app.pointer_comp = Some({ let c = map.to_comp(hp); [c[0] as f32, c[1] as f32] diff --git a/crates/ui-egui/tests/ui_camera_tracker.rs b/crates/ui-egui/tests/ui_camera_tracker.rs index 9e0bede6..a90c3fa4 100644 --- a/crates/ui-egui/tests/ui_camera_tracker.rs +++ b/crates/ui-egui/tests/ui_camera_tracker.rs @@ -145,6 +145,60 @@ fn viewer_points_selection_and_effect_controls() { assert!(h.state().session.is_enabled("track.camera")); } +/// Drag with `button` from `from` to `to` in steps, then release. +fn drag_with(h: &mut Harness<'_, EffectcraftApp>, button: egui::PointerButton, from: egui::Pos2, to: egui::Pos2) { + h.input_mut().events.push(egui::Event::PointerMoved(from)); + h.input_mut().events.push(egui::Event::PointerButton { pos: from, button, pressed: true, modifiers: Default::default() }); + h.step(); + for i in 1..=8 { + h.input_mut().events.push(egui::Event::PointerMoved(from + (to - from) * (i as f32 / 8.0))); + h.step(); + } + h.input_mut().events.push(egui::Event::PointerButton { pos: to, button, pressed: false, modifiers: Default::default() }); + h.run_steps(3); +} + +/// #505: with the track points shown, the wheel still zooms the viewer, and a middle-button drag +/// and a Spacebar (Hand tool) drag over the points still pan it. +#[test] +fn viewer_zooms_and_pans_over_the_track_points() { + let (a, ..) = app(); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| a); + h.state_mut().show_panel(PanelKind::Composition); + h.run_steps(4); + // Wheel over a point: zooms. + let at = rect(&h, "viewer.cameraTracker.point.7").center(); + let zoom = h.state().ui.viewer.zoom; + h.input_mut().events.push(egui::Event::PointerMoved(at)); + h.input_mut().events.push(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::vec2(0.0, 120.0), + modifiers: Default::default(), + phase: egui::TouchPhase::Move, + }); + h.run_steps(20); + assert_ne!(h.state().ui.viewer.zoom, zoom, "the wheel zooms over the track points"); + // Middle-button drag from a point: pans, and selects nothing. + let at = rect(&h, "viewer.cameraTracker.point.7").center(); + let before = h.state().ui.viewer.pan; + drag_with(&mut h, egui::PointerButton::Middle, at, at + egui::vec2(120.0, 60.0)); + let after = h.state().ui.viewer.pan; + assert!((after[0] - before[0] - 120.0).abs() < 1.0 && (after[1] - before[1] - 60.0).abs() < 1.0, "{before:?} → {after:?}"); + assert!(h.state().session.state.camera_points.is_empty(), "a pan selects no points"); + // Spacebar held: a primary drag from a point pans instead of drawing a marquee. + let at = rect(&h, "viewer.cameraTracker.point.7").center(); + let space = |h: &mut Harness<'_, EffectcraftApp>, pressed: bool| { + h.input_mut().events.push(egui::Event::Key { key: egui::Key::Space, physical_key: None, pressed, repeat: false, modifiers: Default::default() }); + h.step(); + }; + space(&mut h, true); + drag_with(&mut h, egui::PointerButton::Primary, at, at + egui::vec2(80.0, 40.0)); + space(&mut h, false); + let end = h.state().ui.viewer.pan; + assert!((end[0] - after[0] - 80.0).abs() < 1.0 && (end[1] - after[1] - 40.0).abs() < 1.0, "{after:?} → {end:?}"); + assert!(h.state().session.state.camera_points.is_empty(), "a Spacebar pan selects no points"); +} + /// Step the UI until background frame renders have landed. fn settle(h: &mut Harness<'_, EffectcraftApp>) { for _ in 0..600 { From 1922e621c22be3125fdb8d03282c52645f8152b6 Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:58:35 +0700 Subject: [PATCH 033/107] fix(ui): drop all selected Project items into compositions (#351) Preserve Project multi-selection when dropping onto the Timeline or Composition viewer. Keep stack order and drop position, skip non-layer project items, and add pointer-drag regression coverage. --- crates/ui-egui/src/panels/mod.rs | 24 +++++++++++++++ crates/ui-egui/src/panels/timeline.rs | 9 ++++-- crates/ui-egui/src/panels/viewer_drop.rs | 26 +++++++++------- crates/ui-egui/tests/ui_drag_drop.rs | 38 ++++++++++++++++++++++++ 4 files changed, 85 insertions(+), 12 deletions(-) diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index ab5a1c8f..76691dce 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -78,6 +78,30 @@ pub enum DragPayload { Files(Vec), } +/// Layers carried by a Project item drag. Dragging a selected item uses the +/// complete selection, while dragging an unselected item uses only that item. +/// Folders and data files are not composition layers. +pub(crate) fn project_drop_items(app: &EffectcraftApp, dragged: u64) -> Vec { + use effectcraft_engine::project::{FootageKind, ItemId, ItemKind}; + + let selected = &app.session.state.project_selection; + let candidates: Vec = if selected.iter().any(|id| id.0 == dragged) { + selected.iter().map(|id| id.0).collect() + } else { + vec![dragged] + }; + candidates + .into_iter() + .filter(|id| { + app.session.project.item(ItemId(*id)).is_some_and(|item| match &item.kind { + ItemKind::Comp(_) | ItemKind::Solid(_) => true, + ItemKind::Footage(f) => f.kind != FootageKind::Data, + ItemKind::Folder => false, + }) + }) + .collect() +} + /// The current time formatted per project settings (timecode with `;` for drop-frame, frames /// or Feet + Frames). pub fn timecode(session: &Session, comp: &Comp, t: Tick) -> String { diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 7df3439b..878a577f 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -2959,9 +2959,14 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { rp.galley(tag.min, g, Color32::WHITE); } if released { - let (index, time) = (json!(index), json!(start.map(Tick::seconds))); + let time = json!(start.map(Tick::seconds)); match payload.as_ref() { - crate::panels::DragPayload::Item(id) => actions.push(("layer.addItem".into(), json!({"item": id, "index": index, "time": time}))), + crate::panels::DragPayload::Item(id) => { + // Insert each selected item at the drop point, in selection order. + for (offset, item) in crate::panels::project_drop_items(app, *id).into_iter().enumerate() { + actions.push(("layer.addItem".into(), json!({"item": item, "index": index + offset, "time": time.clone()}))); + } + } crate::panels::DragPayload::Files(paths) => { actions.push(("mediaBrowser.import".into(), json!({"paths": paths, "addToComp": true, "index": index, "time": time}))) } diff --git a/crates/ui-egui/src/panels/viewer_drop.rs b/crates/ui-egui/src/panels/viewer_drop.rs index 31d426b3..dec8d380 100644 --- a/crates/ui-egui/src/panels/viewer_drop.rs +++ b/crates/ui-egui/src/panels/viewer_drop.rs @@ -23,32 +23,38 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &egui::Ui, painter: &egui::Pain let Some(ptr) = ctx.input(|i| i.pointer.hover_pos()).filter(|_| ui.rect_contains_pointer(map.area)) else { return }; let stroke = Stroke::new(2.0, app.tokens.accent); let at = map.to_comp(ptr); - let action: Option<(&str, Value)> = match payload.as_ref() { + let actions: Vec<(&str, Value)> = match payload.as_ref() { DragPayload::Effect(effect) => layer_at(ectx, at).map(|l| { if let Some((_, q, _)) = layer_quad(ectx, l) { painter.add(egui::Shape::closed_line(q.iter().map(|c| map.to_screen(*c)).collect(), stroke)); } ("effect.apply", json!({"effect": effect, "layers": [l.id.0]})) - }), + }).into_iter().collect(), DragPayload::Item(item) => { // The layer's frame, centred on the pointer. if let Some([w, h]) = app.session.project.item(ItemId(*item)).and_then(|it| frame_size(&it.kind, ectx.comp.pixel_aspect)) { painter.rect_stroke(Rect::from_center_size(ptr, vec2(w, h) * map.zoom), 0.0, Stroke::new(1.0, app.tokens.accent), StrokeKind::Middle); } - Some(("layer.addItem", json!({"item": item, "position": at}))) + // New layers go above the current selection, so insert in reverse + // to preserve the order of the dragged Project items. + crate::panels::project_drop_items(app, *item) + .into_iter() + .rev() + .map(|item| ("layer.addItem", json!({"item": item, "position": at}))) + .collect() } - DragPayload::Files(paths) => Some(("mediaBrowser.import", json!({"paths": paths, "addToComp": true, "position": at}))), - DragPayload::Property { .. } => None, + DragPayload::Files(paths) => vec![("mediaBrowser.import", json!({"paths": paths, "addToComp": true, "position": at}))], + DragPayload::Property { .. } => vec![], }; - if action.as_ref().is_some_and(|(id, _)| *id != "effect.apply") { + if actions.first().is_some_and(|(id, _)| *id != "effect.apply") { painter.rect_stroke(map.area, 0.0, stroke, StrokeKind::Inside); } if ctx.input(|i| i.pointer.any_released()) { egui::DragAndDrop::clear_payload(&ctx); - if let Some((id, params)) = action - && let Err(e) = crate::menus::invoke(app, &ctx, id, params) - { - app.ui.status = e; + for (id, params) in actions { + if let Err(e) = crate::menus::invoke(app, &ctx, id, params) { + app.ui.status = e; + } } } } diff --git a/crates/ui-egui/tests/ui_drag_drop.rs b/crates/ui-egui/tests/ui_drag_drop.rs index 9738fef2..56f9ffd7 100644 --- a/crates/ui-egui/tests/ui_drag_drop.rs +++ b/crates/ui-egui/tests/ui_drag_drop.rs @@ -123,6 +123,44 @@ fn project_items_dropped_on_the_viewer_land_under_the_pointer() { assert!((p[0] - 80.0).abs() <= tol && (p[1] - 45.0).abs() <= tol, "{p:?}"); } +/// #351: dragging one of several selected Project items inserts all the items, +/// in selection order, instead of only the item the drag started on. +#[test] +fn dragging_selected_project_items_adds_every_item_to_the_timeline() { + let (mut h, clip) = harness(); + let main = h.state().session.active_comp_id().unwrap().0; + let other = h.state_mut().session.execute("comp.new", json!({"name": "Other", "width": 80, "height": 60, "frameRate": 30, "duration": 1})).unwrap()["comp"].as_u64().unwrap(); + h.state_mut().session.execute("comp.open", json!({"comp": main})).unwrap(); + h.state_mut().session.state.project_selection = + vec![effectcraft_engine::project::ItemId(clip), effectcraft_engine::project::ItemId(other)]; + h.run_steps(3); + let from = rect(&h, &format!("project.item.{clip}.name")).center(); + let top = rect(&h, &format!("timeline.layer.{}.row", layer_id(&h, "Top"))); + drag(&mut h, from, pos2(top.center().x, top.max.y - 2.0), Modifiers::NONE); + assert_eq!(stack(&h), ["Top", "Clip", "Other", "Middle", "Bottom"]); + assert_eq!(in_point(&h, 1), in_point(&h, 2), "both layers use the drop time"); +} + +#[test] +fn dragging_selected_project_items_adds_every_item_to_the_viewer() { + let (mut h, clip) = harness(); + let main = h.state().session.active_comp_id().unwrap().0; + let other = h.state_mut().session.execute("comp.new", json!({"name": "Other", "width": 80, "height": 60, "frameRate": 30, "duration": 1})).unwrap()["comp"].as_u64().unwrap(); + h.state_mut().session.execute("comp.open", json!({"comp": main})).unwrap(); + h.state_mut().session.state.project_selection = + vec![effectcraft_engine::project::ItemId(clip), effectcraft_engine::project::ItemId(other)]; + h.run_steps(3); + let from = rect(&h, &format!("project.item.{clip}.name")).center(); + let to = comp_to_screen(&h.ctx, [80.0, 45.0]).unwrap(); + drag(&mut h, from, to, Modifiers::NONE); + assert_eq!(stack(&h), ["Clip", "Other", "Top", "Middle", "Bottom"]); + for layer in &h.state().session.active_comp().unwrap().layers[..2] { + let Some(KV::Vec3(p)) = layer.props.prop("transform/position").map(|p| p.value.clone()) else { panic!("no position") }; + let tol = 1.0 / last_fit(&h.ctx) as f64 + 1e-6; + assert!((p[0] - 80.0).abs() <= tol && (p[1] - 45.0).abs() <= tol, "{p:?}"); + } +} + /// #88: an effect dragged from Effects & Presets onto the viewer goes on the layer under the /// pointer, not the selected one. #[test] From 1e61b4e04bbce3e6ed34ce91027970ad9d4b7ff4 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 10 Oct 2026 14:09:10 -0400 Subject: [PATCH 034/107] Progress docs: target-app parity, gaps, alpha gate, readiness by audience (#617) Progress docs per craftrules standards/progress-docs.md: target-app parity, gaps, roadmap with alpha gate, readiness by audience (full / mainstream / essentials) with Opus 5.5 hours, and feature, format, hardware, UI and localization parity docs. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 4 +- CLAUDE.md | 2 +- README.md | 6 +- ROADMAP.md | 198 ++++++++---- crates/engine/src/commands/query.rs | 6 +- crates/engine/src/commands/vpe.rs | 2 +- docs/architecture.md | 12 +- docs/effects-parity.md | 67 +++++ docs/file-format-parity.md | 117 ++++++++ docs/gaps.md | 147 ++++++--- docs/hardware-parity.md | 42 +++ docs/localization-parity.md | 89 ++++++ docs/roadmap.md | 87 ++++++ docs/scripting-parity.md | 43 +++ docs/{parity.md => target-app-parity.md} | 365 ++++++++++++++++++++--- docs/ui-parity.md | 47 +++ 16 files changed, 1076 insertions(+), 158 deletions(-) create mode 100644 docs/effects-parity.md create mode 100644 docs/file-format-parity.md create mode 100644 docs/hardware-parity.md create mode 100644 docs/localization-parity.md create mode 100644 docs/roadmap.md create mode 100644 docs/scripting-parity.md rename docs/{parity.md => target-app-parity.md} (76%) create mode 100644 docs/ui-parity.md diff --git a/AGENTS.md b/AGENTS.md index 6ccc5686..aad29c94 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -114,7 +114,9 @@ as PhotoCraft does with `cargo xtask corpus`. ## See also -- [docs/gaps.md](docs/gaps.md): where EffectCraft falls short of After Effects and the prioritised workstreams (G1–G9); read it before choosing work +- [ROADMAP.md](ROADMAP.md): stage, headline numbers and what's next +- [docs/gaps.md](docs/gaps.md): every known shortfall against After Effects, ranked, and the prioritised workstreams (G1–G9); read it before choosing work +- [docs/target-app-parity.md](docs/target-app-parity.md): the parity numbers and how they were measured, with the per-area docs it links (UI, file formats, hardware, localization, effects, scripting); progress docs follow [craftrules `standards/progress-docs.md`](https://github.com/storytold/craftrules/blob/main/standards/progress-docs.md) - [CONTRIBUTING.md](CONTRIBUTING.md): setup, gates, commits, how to add things - [docs/architecture.md](docs/architecture.md): layers, data model, commands, pipeline - [docs/testing.md](docs/testing.md): oracle tests, criteria, benchmarks diff --git a/CLAUDE.md b/CLAUDE.md index da31c48f..a7c47fe3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ EffectCraft is a clean-room, open-source, pure-Rust motion graphics and visual e `plan/` is maintainer-local (gitignored). Public equivalents: [`ROADMAP.md`](ROADMAP.md), [`docs/`](docs/). 1. Read `plan/STATUS.md` (current milestone, next task, running agents, blockers). 2. Read the task in `plan/execution-plan.md` §3, the relevant section of `plan/architecture.md`, and the crate README/docs you touch. Feature ids: `plan/aftereffects/feature-catalog.md`; effects: `plan/aftereffects/effects-list.md`; UI reference: `plan/aftereffects/README.md`. -3. Read [`docs/gaps.md`](docs/gaps.md): the honest assessment of where we fall short and the prioritised workstreams. Fidelity, reliability and stability (G1–G3) come before new features. +3. Read [`docs/gaps.md`](docs/gaps.md): every known shortfall, ranked, and the prioritised workstreams; the numbers are in [`docs/target-app-parity.md`](docs/target-app-parity.md). Fidelity, reliability and stability (G1–G3) come before new features. 4. Follow the autonomous operation protocol (`plan/execution-plan.md` §7). Don't stop to ask. ## Non-negotiables diff --git a/README.md b/README.md index 616994ae..96aa9a17 100644 --- a/README.md +++ b/README.md @@ -234,8 +234,10 @@ what breaks. It isn't yet a replacement for After Effects on client work. In par Settings ▸ Roto Brush) and face tracking can use Google's MediaPipe Face Landmarker (an optional 3.8 MB download in Settings ▸ Face Tracking). Neither is compared with After Effects yet. -The [ROADMAP](ROADMAP.md) has the plan and [docs/gaps.md](docs/gaps.md) the full, honest -assessment. The web build ([docs/web.md](docs/web.md)) runs the full app in the browser. +EffectCraft is in **alpha**: about 91% of After Effects' features exist, and we estimate it is +about 45% of the way to replacing After Effects for professional work. The [ROADMAP](ROADMAP.md) +has the plan, [docs/target-app-parity.md](docs/target-app-parity.md) the measurement and +[docs/gaps.md](docs/gaps.md) every known shortfall. The web build ([docs/web.md](docs/web.md)) runs the full app in the browser. [Bug reports](https://github.com/storytold/effectcraft/issues) are the most useful thing you can send us right now. diff --git a/ROADMAP.md b/ROADMAP.md index 48d5b56e..26c39b79 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,74 +1,144 @@ -# Roadmap +# EffectCraft Roadmap +**Stage: alpha** · next: beta, ~30 points of "ready for real work" and ~350–620 h away + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (full re-measure against After Effects 2026 26.5; standard progress-docs layout) · **Target:** Adobe After Effects 2026 (26.5.0.89) + +EffectCraft aims to do what After Effects does, with the same panels, menus and behaviour, written +from scratch in Rust. This page is the one-page summary; the evidence is in +[docs/target-app-parity.md](docs/target-app-parity.md), the work list in +[docs/gaps.md](docs/gaps.md), and the plan in [docs/roadmap.md](docs/roadmap.md). + +## Headline numbers + +| | Value | Kind | +|---|---|---| +| **Feature breadth** | **≈ 91%** | Estimated, weighted by area; the self-graded 92-item checklist says ≈ 99% | +| **Ready for real work** | **≈ 45%** (40–50%) | Estimated; one live fidelity measurement so far | +| **Mainstream practitioner** | **≈ 47%** | Estimated: the typical motion designer's weekly work ([how](docs/target-app-parity.md#mainstream-practitioner-and-essentials-user)) | +| **Essentials user** | **≈ 52%** | Estimated: core features only ([how](docs/target-app-parity.md#essentials-user)) | +| Remaining to **beta** | **≈ 350–620 Opus 5.5 agent-hours** | Estimated | +| Remaining to **full parity** | **≈ 850–1,500 Opus 5.5 agent-hours** | Estimated; ≈ 70% parallelises | + +| Audience | Ready % | Hours to ~95% | Work that dominates | +|---|---:|---:|---| +| Full target | ≈ 45% | ≈ 850–1,500 h | `.aep`, fidelity corpus, bug backlog, performance, formats, hardware, localization | +| Mainstream practitioner | ≈ 47% | ≈ 280–530 h | `.aep` exchange, stability, everyday fidelity and bugs, preview speed | +| Essentials user | ≈ 52% | ≈ 150–300 h | Opening tutorial `.aep` files, start-up stability, core bugs, preview with sound | + +About 60–70% of these hours parallelise ([detail](docs/target-app-parity.md#readiness-by-audience)). + +Hours are one Opus 5.5 agent working sequentially, calibrated on this repository's history +([how](docs/target-app-parity.md#calibration-of-the-hours)). + +### Stage + +**Alpha**, because all six core workflows of the [alpha gate](docs/roadmap.md#alpha-gate) +(set up, animate, text and shapes, composite, preview, save and deliver) work end to end on macOS +and the work saves and reopens, and ready for real work (≈ 45%) is above the ≈ 40% bar. But: After Effects projects (`.aep`) can't be opened, which +alone rules out beta; fidelity against After Effects is measured for one narrow case (scalar ease, +168 samples); 134 issues are open, eight of them crash reports. Beta needs ≈ 75% ready and `.aep` +opening reliably: the `.aep` owner decision and importer (80–160 h), the fidelity harness and +P0/P1 corpus (60–100 h), the crash and bug backlog (40–80 h), performance (40–70 h), +Linux/Windows reliability (30–50 h) and depth in animation, masks, effects and text (100–160 h). + +## By dimension + +| Dimension | % | Hours | Doc | +|---|---:|---:|---| +| Features: breadth | 91% | — | [target-app-parity.md](docs/target-app-parity.md#by-feature-area) | +| Features: ready for real work | 50% | 360–660 | [target-app-parity.md](docs/target-app-parity.md#by-feature-area) | +| UI/UX fidelity | 65% | 60–120 | [ui-parity.md](docs/ui-parity.md) | +| File formats | 50% | 130–230 | [file-format-parity.md](docs/file-format-parity.md) | +| Hardware | 40% | 60–120 | [hardware-parity.md](docs/hardware-parity.md) | +| Localization | 12% | 60–110 + review | [localization-parity.md](docs/localization-parity.md) | +| Performance | 35% | 50–100 | [gaps.md](docs/gaps.md#g5-performance-at-real-world-scale) | +| Stability | 45% | 40–80 | [gaps.md](docs/gaps.md#g3-stability-and-a-green-main) | +| Platforms | 65% | 30–60 | [gaps.md](docs/gaps.md#g2-real-user-reliability-on-every-platform) | +| Ecosystem and plug-ins | 20% | 40–80 | [plugins.md](docs/plugins.md), [scripting-parity.md](docs/scripting-parity.md) | +| AI features | 35% | 40–80 | [gaps.md](docs/gaps.md) | +| Agent control (beyond After Effects) | ahead | — | [agents.md](docs/agents.md) | + +## Features + +| Area | Breadth | Ready | Hours | +|---|---:|---:|---:| +| Project, footage and import | 92% | 55% | 25–45 | +| Compositions, layers and compositing | 98% | 60% | 30–55 | +| Animation: keyframes, graph editor, motion paths, puppet | 97% | 55% | 35–60 | +| Expressions and scripting ([detail](docs/scripting-parity.md)) | 93% | 65% | 20–40 | +| Shapes, masks and roto | 93% | 45% | 30–50 | +| Text and typography | 94% | 50% | 20–35 | +| Effects and animation presets ([detail](docs/effects-parity.md)) | 85% | 40% | 70–130 | +| 3D (Classic and Advanced) | 82% | 35% | 35–65 | +| Tracking and AI-assisted tools | 80% | 35% | 35–70 | +| Preview and cache | 92% | 40% | 25–50 | +| Render queue and export | 85% | 55% | 25–45 | +| Audio | 85% | 45% | 6–12 | + +Weights and evidence per row: [target-app-parity.md](docs/target-app-parity.md#by-feature-area). + +## Languages + +UI strings translated out of ≈ 3,900 (detail: [localization-parity.md](docs/localization-parity.md)). + +| Language | Status | % | +|---|---|---:| +| English | full | 100% | +| Simplified Chinese | partial (menus, part of the panels) | 28% | +| Spanish | menus only | 16% | +| Hindi | none | 0% | +| Arabic | none | 0% | +| French | none | 0% | +| Portuguese (Brazil) | menus only | 16% | +| Indonesian | none | 0% | +| Japanese | partial (menus, part of the panels) | 26% | +| German | none | 0% | +| Korean | none | 0% | +| Vietnamese | none | 0% | + +Also shipped: Traditional Chinese (partial, 28%) and Ukrainian (partial, 26%). No language has had +native-speaker review. + +## Upcoming + +Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gaps.md). + +| | Next | Hours | +|---|---|---:| +| 1 | Fix the open crash reports; run `cargo xtask ci` on every pull request (G3) | 15–30 | +| 2 | Fidelity harness against After Effects: rendered frames, a corpus case per P0/P1 feature (G1) | 60–100 | +| 3 | Real-user reliability on Linux and Windows; the open mask, effect, text and 3D bugs (G2) | 100–190 | +| 4 | `.aep` / `.aepx` import, after the owner's clean-room decision (G4) | 80–160 | +| 5 | Performance: benchmarks at 1080p / 4K, real-time cached playback, hardware video (G5) | 50–100 | +| 6 | Localization depth: effect names, all panels, French / German / Korean (G9) | 60–110 | + +Come tell us what matters most to you on [Discord](https://discord.gg/artcraft). + +## Progress log + +- 2026-10-10: Full parity re-measure against After Effects 2026 26.5 (installed bundle, 26.2–26.5 + release notes, repository counts): breadth ≈ 91%, ready ≈ 45%, stage alpha. Progress docs + follow the shared standard: `docs/parity.md` became `docs/target-app-parity.md`; new + `docs/roadmap.md`, `localization-parity.md`, `file-format-parity.md`, `hardware-parity.md`, + `ui-parity.md`, `effects-parity.md` and `scripting-parity.md`. +- 2026-10-10: Spanish (#460) and Brazilian Portuguese (#217) menu translations. - 2026-10-10: Japanese now translates the interface below the menu bar too: `crates/ui-egui/src/i18n/ui.rs` has a Japanese table with the same 396 rows as the Chinese ones (panels, dialogs, buttons and tooltips; the shape test keeps the three catalogs listing the same source strings). Menus were already Japanese. - 2026-10-09: Ukrainian now covers all 396 current panel-catalog strings, including dialogs, buttons and tooltips, alongside its menu translation. The catalog keeps the same source keys and placeholder counts as the Chinese catalogs; missing or engine-generated text still falls back to English. - 2026-10-09: Settings ▸ General ▸ Language now translates the interface below the menu bar as well: panels, dialogs, buttons and tooltips read their text through `crates/ui-egui/src/i18n/ui.rs`, keyed by the English source string. Simplified and Traditional Chinese are complete (384 strings); Japanese rows can follow in the same file. A test fails when a converted panel gains a hard-coded string, and another keeps the catalog free of unused rows. - 2026-10-08: `effectcraft-cli render --out` resolves a relative path against the working directory, like `--project` (it used the project's folder, which doubled a path that already named it). Render Queue outputs set in a project are unchanged. - 2026-10-08: Long-press a grouped toolbar button to choose its tools, including Horizontal/Vertical Type; releasing the hold keeps the menu open. Existing point/paragraph text creation is unchanged. - - 2026-10-06: Settings ▸ General ▸ Language persists English/Japanese menu labels (`general.language`); native UI reuses installed Japanese font fallback, with no bundled CJK font. Dialog and panel contents remain English. +- 2026-10-05: Honest assessment ([docs/gaps.md](docs/gaps.md)): breadth ≈ 99% by our own checklist, real use ≈ 30–50%; workstreams G1–G9. +- 2026-10-04: Checklist audit at `d39c0e8`: 89 done / 3 partial / 0 missing of 92 features. +- 2026-10-01: First commit. -EffectCraft aims to do what After Effects does, with the same panels, menus and behaviour, written -from scratch in Rust. This is where it stands. The milestones overlap; several are worked on at -once. +## Revision history -| | Milestone | State | +| Date | Change | Summary | |---|---|---| -| M0 | Skeleton: crates, compositor, the After Effects style shell, control channel, `cargo xtask` | Done (web build: [docs/web.md](docs/web.md)) | -| M1 | Keyframes: temporal and spatial interpolation, Easy Ease, roving, velocity | Done | -| M2 | Compositing: 38 blend modes, track mattes, parenting, adjustment layers | Done | -| M3 | Project operations: settings dialogs, layer commands, `.ecproj`, undo, After Effects menu bar | Done (unsaved-changes prompts, Solid / Layer Settings, lock-aware layer commands, safe project files; View ▸ New Viewer still opens only the one locked viewer) | -| M4 | Preview: precomps, motion blur, cached playback | Done (Preserve frame rate / resolution when nested, one motion blur gate, RAM preview fixes, Cache Frames When Idle, motion blur of collapsed precomps and of animated shape / text content, content-keyed RAM preview frames) | -| M5 | Timeline depth: graph editor, keyframe clipboard and dialogs, time remapping, pick-whips, expression editor | Done | -| M6 | Shapes, masks and footage: shape operators, masks and the pen tool, video and image import | Done | -| M7 | 3D: 3D layers, cameras, lights, shadows, depth of field, 3D views and camera tools | In review | -| M8 | Expressions with the After Effects object model | Done | -| M9 | Text and effects: text animators, layer styles, 306 effects (all 298 of After Effects') incl. time and audio effects | Done (range, wiggly and expression selectors, per-character 3D, text on a path) | -| M10 | Export: render queue, H.264, ProRes, image sequences, GIF, audio | Done | -| M11 | Animation tools: audio playback, meters and waveforms, Lottie import and export, presets, Motion Sketch, Wiggler, Smoother | Done | -| M12 | Performance: layer cache, parallel and GPU compositing, disk cache, motion tracking (done); GPU versions of the remaining CPU-only effects | Mostly done | -| M13 | Puppet tools, paint, Roto Brush, motion tracking, a plug-in API, Timeline depth | Done (trained models, open source, downloaded on demand, in a swappable model module: MobileSAM for Roto Brush 2.0 / 3.0, MediaPipe Face Landmarker for face tracking) | -| M15 | The web app (WebAssembly, WebGPU) | Done: browser storage, Web Audio, renders and analyses in Web Workers, viewer frames and GPU effects in frame workers with their own WebGPU devices, the disk cache in the Origin Private File System, a storage manager, offline install ([docs/web.md](docs/web.md)) | -| M14 | Built for agents: MCP server, command-line tool, control channel; Settings, keyboard shortcut editor, auto-save and crash recovery ([docs/preferences.md](docs/preferences.md)) | Done | - -## How far from full parity - -There are two honest answers, and they are far apart. The full assessment, with the evidence, is -in [docs/gaps.md](docs/gaps.md). - -- **Breadth: ≈ 99%.** Almost every After Effects feature exists: 89 of 92 catalogued features - done and 3 partial, all 306 effects implemented, 280 of them on the GPU - ([docs/parity.md](docs/parity.md)). This is our own checklist, graded by the agents that built - the features. -- **Real use: ≈ 30–50% (estimated, not yet measured).** Whether someone who uses After Effects for - a living can do client work in EffectCraft. What holds it back: - - **Fidelity is unmeasured.** No test compares our output with After Effects itself, and users - are still finding behaviour bugs in features marked done. - - **After Effects projects can't be opened.** EffectCraft can't read `.aep` / `.aepx` files, - and third-party After Effects plug-ins can't run. - - **Reliability is uneven across platforms.** Early Linux users hit basic problems: viewer - panning, panel docking, drag-and-drop import. A Windows user found the font menus offered only - the bundled fonts (fixed 6 October: they list every installed font). Reports of 6 October - covered drag and drop between panels, a Render Queue menu that ignored clicks, angle - revolutions, and preview with audio and with little video memory (all fixed 7 October). - - **AI tools are new.** Roto Brush's trained model (MobileSAM, M13.35) and face tracking's - (MediaPipe Face Landmarker, M13.36) are optional downloads, not yet compared with After - Effects. - -## Where we're going - -In priority order (details and "done" criteria in [docs/gaps.md](docs/gaps.md)): - -| | Workstream | Why | -|---|---|---| -| G1 | Measure fidelity against After Effects: a corpus of test projects rendered and sampled in both apps, scored per feature | Turns every estimate into a measurement; a feature that behaves differently is not done | -| G2 | Real-user reliability on macOS, Windows and Linux; fix every open user issue | Basic workflows must work everywhere before more features matter | -| G3 | Stability: a green gate on the current toolchain, fuzzing of every input | A crash loses people's work | -| G4 | Open After Effects projects (`.aep` / `.aepx`, pending an owner decision on clean-room scope); relinking moved footage | The biggest barrier to switching | -| G5 | Performance benchmarks at 1080p and 4K on real-world-sized projects | Unknown today | -| G6 | Media depth: encoder efficiency, camera and phone formats | Files are larger than from mature encoders; camera formats are unverified | -| G7 | Learned models: Roto Brush (M13.35: MobileSAM) and face tracking (M13.36: MediaPipe Face Landmarker), open source, optional downloads, swappable model module; next, measure them | Quality against After Effects is unmeasured | -| G8 | Plug-in ecosystem on our WebAssembly plug-in API | After Effects plug-ins can't run here | -| G9 | Localisation, accessibility, user documentation | Reach beyond English-speaking power users | - -Come tell us what matters most to you on [Discord](https://discord.gg/artcraft). +| 2026-10-10 | minor | Hours to ≈ 95% for each audience | +| 2026-10-10 | minor | Headline adds mainstream practitioner (≈ 47%) and essentials user (≈ 52%) | +| 2026-10-10 | minor | Stage checked against the new core-workflow gate (all six pass; still alpha) | +| 2026-10-10 | major | Rewritten to the shared progress-docs standard: stage, two numbers, dimensions, features, languages, upcoming, progress log; milestones moved to docs/roadmap.md | +| 2026-10-05 | major | Two numbers (breadth vs real use) and workstreams G1–G9 | diff --git a/crates/engine/src/commands/query.rs b/crates/engine/src/commands/query.rs index 27588907..56139b6c 100644 --- a/crates/engine/src/commands/query.rs +++ b/crates/engine/src/commands/query.rs @@ -185,13 +185,13 @@ fn capabilities(s: &mut Session, _: &Value) -> Result { "parity": { "target": "Adobe After Effects 2026", "summary": PARITY, - "doc": "docs/parity.md", + "doc": "docs/target-app-parity.md", }, })) } -/// The headline of `docs/parity.md` (kept in sync by hand at each audit). -const PARITY: &str = "≈94% weighted feature parity (80 done / 12 partial / 0 missing of 92 features); 298 of 298 After Effects 2026 effects present"; +/// The headline of `docs/target-app-parity.md` (kept in sync by hand at each re-measure). +const PARITY: &str = "≈91% feature breadth and ≈45% ready for real work (estimated parity with After Effects 2026 26.5, stage alpha); all After Effects effects present (306 registered)"; fn state(s: &mut Session, _: &Value) -> Result { Ok(serde_json::to_value(&s.state).unwrap_or_default()) diff --git a/crates/engine/src/commands/vpe.rs b/crates/engine/src/commands/vpe.rs index f4932bc0..c4a348be 100644 --- a/crates/engine/src/commands/vpe.rs +++ b/crates/engine/src/commands/vpe.rs @@ -1,7 +1,7 @@ //! File ▸ Import ▸ Vanishing Point (.vpe): present in the menu like After Effects', but always //! disabled. The `.vpe` exchange format Photoshop's Vanishing Point filter writes has no public //! specification, and EffectCraft implements formats only from published specs (clean room), so -//! the entry explains that instead of importing. See docs/parity.md. +//! the entry explains that instead of importing. See docs/file-format-parity.md. use serde_json::Value; diff --git a/docs/architecture.md b/docs/architecture.md index 4fe5d2cd..d0940f0d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,5 +1,7 @@ # Architecture +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (crate table checked against `crates/`: effect count 306, VP9 inter frames) · **Target:** Adobe After Effects 2026 + EffectCraft is a stack of small Rust crates. The engine knows nothing about the user interface; the egui frontend, the command-line tool and the MCP server all sit on top of the same `Session` and drive it through the same command registry. @@ -16,7 +18,7 @@ cpal or muda. Everything in L0 to L4, the egui UI and the web app also build for | L0 | `time` | `Tick` (254 016 000 000 per second), rational frame rates incl. NTSC, SMPTE and drop-frame timecode | | L0 | `geom` | Vectors, matrices, quaternions, the layer transform (anchor, position, scale, orientation, rotation) | | L0 | `color` | sRGB and linear, HSL/HSV, luminance, the 38 blend modes, label colors | -| L0 | `vp9enc` | VP9 intra-frame encoder (profile 0, 8-bit 4:2:0; lossless at quality 100) for WebM export, from the VP9 bitstream specification | +| L0 | `vp9enc` | VP9 encoder (profile 0, 8-bit 4:2:0, key and inter frames with motion search, loop filter and rate control; lossless at quality 100) for WebM export, from the VP9 bitstream specification | | L0 | `opusenc` | Opus encoder: RFC 6716 SILK (NB/MB/WB), hybrid (SWB/FB) and CELT (FB) modes chosen by bitrate and application (audio / voice), 48 kHz 20 ms packets, mono/stereo, and the RFC 7845 `OpusHead`, for WebM export audio | | L0 | `hevcenc` | HEVC (H.265) encoder from ITU-T H.265: Main / Main 10 4:2:0, IDR + P slices (quarter-pel motion, merge/AMVP), CABAC, deblocking, bitrate or constant-QP rate control, for MP4 (`hvc1`) export | | L0 | `av1enc` | AV1 encoder from the AV1 bitstream specification: Main profile 8/10-bit 4:2:0, key + inter frames (quarter-pel motion), deblocking, for MP4 (`av01`) and WebM export | @@ -27,7 +29,7 @@ cpal or muda. Everything in L0 to L4, the egui UI and the web app also build for | L1 | `path` | Bezier paths, path operators (trim, offset, round corners, zig zag, twist, merge…), stroking, coverage masks | | L2 | `project` | The document: items, compositions, layers, the property tree, render queue model, `.ecproj` serde | | L2 | `text` | Fonts, shaping, layout, per-glyph geometry, text animators and selectors | -| L2 | `effects` | The effect registry (241 effects) and their CPU implementations | +| L2 | `effects` | The effect registry (306 effects) and their CPU implementations | | L2 | `svg` | SVG import (W3C SVG 1.1/2 static subset: shapes, paths, transforms, `use`, CSS, gradients) and rasterisation at any scale | | L2 | `pdf` | PDF, PDF-compatible Illustrator (`.ai`) and EPS (PostScript subset) vector footage into the `svg` render tree: paths, fills, strokes, shadings, tiling patterns, clipping, text (embedded TrueType / CFF / Type 1 / Type 3 fonts; the bundled `text` fonts for the standard 14), images, blend modes, soft masks, optional-content layers | | L2 | `model` | 3D models for Advanced 3D: glTF 2.0 (`.gltf`/`.glb`) and OBJ/MTL import (meshes, PBR metallic-roughness materials and textures, node hierarchy, skins, animations), parametric primitives, extruded/bevelled outline meshes and polygon triangulation | @@ -675,3 +677,9 @@ The before frame times were dominated by an O(layers²) menu build per visible t by every panel deep-cloning the active comp each frame. Left for later: per-layer structural sharing (so a one-layer edit doesn't copy its comp), a binary or compact project format (pretty JSON parsing is now the bulk of open), and rendering 5,000-layer comps faster. + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | minor | Status line added; crate table checked against `crates/` (every crate listed); effect count and VP9 encoder description corrected | diff --git a/docs/effects-parity.md b/docs/effects-parity.md new file mode 100644 index 00000000..b57b84b5 --- /dev/null +++ b/docs/effects-parity.md @@ -0,0 +1,67 @@ +# Effects parity + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first effects parity checklist: presence, GPU, fidelity and presets against After Effects 2026 26.5) · **Target:** Adobe After Effects 2026 + +Effects are the deepest single area of After Effects. This page separates the four questions +that a single "306 of 306" hides: does each effect exist, does it run fast, does it look like +After Effects', and does the preset library exist. The per-effect catalogue is the generated +[effects.md](effects.md); the overall numbers are in [target-app-parity.md](target-app-parity.md). + +## Summary + +| Question | Value | Kind | +|---|---|---| +| Effects present | **306** registered, 3,014 parameters; all of After Effects' effects by name (298 at the M9.11 count) plus Obsolete and our own | Measured: registry, [effects.md](effects.md). After Effects 26.5's `Plug-ins/Effects` holds 306 `.plugin` bundles in 231 entries (one bundle can hold several effects, so this is a cross-check, not a match) | +| Parameter names, order, popups, units, defaults | aligned in M9.12 | Agent-checked against After Effects' documentation, not against a live dump | +| On the GPU | **280** (92%) | Measured: registry flags; GPU vs CPU agreement tested per effect | +| 32-bit float, no clamping | 306 | Measured: registry flags | +| **Rendered output matches After Effects** | **unmeasured** | No effect is compared with an After Effects render yet. `effectcraft_effects::fidelity::compare_frames` exists for the corpus ([fidelity/README.md](fidelity/README.md)); simulation effects are pinned by golden hashes of our own output (M13.27) | +| Animation presets | **8** original text-animator presets against After Effects' **621** `.ffx` presets | Measured: `crates/engine/presets/text_animators.json`; the After Effects `Presets` folder | +| 26.x additions | Curl Noise (26.3) present (#463 open); effect colour labels and the rebuilt Effect Controls (26.5) missing | Release notes | + +**Breadth ≈ 85%** (presets weigh in; the effects themselves are ≈ 100%). **Ready ≈ 40%**: until a +corpus compares renders, each effect is assumed to differ somewhere, and users keep confirming it. + +## By category (from [effects.md](effects.md)) + +| Category | Effects | On the GPU | Known differences or bugs | +|---|---:|---:|---| +| 3D Channel | 8 | 8 | Cryptomatte selection can't be set in the UI and the GPU path is slow (#473); multi-layer EXRs decoded twice (#474) | +| Audio | 13 | 0 | CPU by design (applied in the mixdown) | +| Blur & Sharpen | 16 | 15 | Gaussian Blur interaction and output to be measured first ([fidelity/ae-2026-acceptance.md](fidelity/ae-2026-acceptance.md)) | +| Channel | 13 | 13 | — | +| Color Correction | 40 | 40 | Apply Color LUT (#514); 32-bpc blending differences affect looks (#494); ACES 2.0 / OCIO 2.5 (26.5) | +| Distort | 39 | 35 | Mesh Warp can't be dragged in the viewer (#303); CC Power Pin Unstretch (#534, community fix pending); Liquify has no viewer brush | +| Expression Controls | 9 | 9 | — | +| Generate | 26 | 25 | Fractal on the CPU (needs f64), Reset doesn't work (#526); Write-on drops marks when animated (#536, fix pending) | +| Immersive Video | 12 | 12 | — | +| Keying | 12 | 12 | Key Cleaner approximated; wire-removal Frame Offset (#533, fix pending) | +| Matte | 6 | 4 | Mocha shape reads our documented JSON, not Mocha's format | +| Noise & Grain | 13 | 12 | Curl Noise (#463); Match Grain on the CPU | +| Obsolete | 9 | 9 | — | +| Paint | 1 | 0 | Tablet pressure not read ([hardware-parity.md](hardware-parity.md)) | +| Perspective | 10 | 9 | Chromatic Aberrations pan/tilt crash (#580) | +| Simulation | 18 | 18 | Particle Playground lacks Particle Exploder, text particles, ephemeral mapper; CC Particle World camera fixed (#397) | +| Stylize | 25 | 25 | Motion Tile output size (#369) | +| Text | 2 | 2 | — | +| Time | 8 | 8 | Pixel Motion Blur averages frames, ignores Vector Detail (#535, fix pending) | +| Transition | 17 | 17 | — | +| Utility | 9 | 7 | — | + +Third-party bundles in After Effects (Cineware, Mocha AE) are out of scope; Keylight is +implemented under a generic name ("Key Light"). + +## Estimate + +**≈ 70–130 h**: a fidelity corpus case per effect at default and one non-default setting, compared +with After Effects renders, and the fixes they turn up (40–80 h, most of it parallel, but every +reference render needs After Effects on the owner's Mac); an original preset library covering +After Effects' preset categories (Behaviors, Backgrounds, Shapes, Synthetics, Text, Transitions, +Image effects) 20–40 h; the open effect bugs 10–20 h. Moving Fractal and the analysis effects to +the GPU is performance, not parity. + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | major | First effects parity checklist | diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md new file mode 100644 index 00000000..66f18b47 --- /dev/null +++ b/docs/file-format-parity.md @@ -0,0 +1,117 @@ +# File format parity + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first per-format table, measured against the After Effects 2026 26.5 bundle and this repository's import/export registries) · **Target:** Adobe After Effects 2026 + +Every format After Effects reads or writes, with EffectCraft's support. After Effects' list comes +from its installed bundle (26.5.0.89: `Info.plist` `CFBundleDocumentTypes`, `Plug-ins/Format`, +`Resources/usd_plugins`) and its public documentation; ours from `crates/media/src/lib.rs` +(`STILL_EXTENSIONS`, `VIDEO_EXTENSIONS`, `MODEL_EXTENSIONS`, FilmCraft's `AUDIO_EXTENSIONS`), +`crates/engine/src/commands/file.rs` (`DATA_EXTENSIONS`) and `OutputFormat::ALL` in +`crates/project/src/render_queue.rs`. Video codecs come from FilmCraft's pure-Rust crates +([architecture.md](architecture.md)). + +**Overall ≈ 50%** (estimated, weighted by use): modern delivery and interchange formats are +strong, but After Effects' own project, template and preset formats, which decide whether +existing work moves over, are 0%. + +Fidelity key: **exact** (bit-exact or within a stated tolerance of a reference), **good** (round +trips and renders, checked by tests, not against After Effects), **partial** (named limitations), +**none**. + +## After Effects' own formats + +| Format | After Effects | EffectCraft read | Write | Fidelity / notes | Tested by | +|---|---|---|---|---|---| +| Project `.aep` | R/W (native) | none | none | Main file format; needs an owner decision on clean-room scope (#190, #248: py-aep as an external converter). Blocks beta | — | +| XML project `.aepx` | R/W | none | none | Same decision as `.aep` | — | +| Templates `.aet` / `.aetx` | R/W | none | none | Own `.ectemplate` (Home ▸ Templates, Save as Template) | `templates.rs` tests | +| Motion Graphics template `.mogrt` | W (and R in Premiere) | none | none | Own `.ectemplate` Essential Graphics templates; FilmCraft rejects our `.fcgt` export (#611) | `essential.rs` tests | +| Animation preset `.ffx` | R/W, 621 bundled | none | none | Own JSON `.ecpreset`; `.ffx` is out of clean-room scope (AGENTS.md §2) | `animation.rs` tests | +| Render Settings / Output Module templates `.ars` / `.aom` | R/W | none | none | Templates are stored in the project and settings instead | `render_templates.rs` tests | +| Scripts `.jsx` | R (run) | yes | — | After Effects object model and ScriptUI ([scripting-parity.md](scripting-parity.md)) | `crates/script` tests | +| Binary scripts `.jsxbin` | R (run) | none | — | No public specification: out of scope | — | +| Motion graphics JSON `.mgjson` | R (data) | none | none | Media Browser lists it; import accepts only `.json` / `.csv` / `.tsv` data | — | +| Captions `.aecap`, graphics `.aegraphic` | R/W | none | none | — | — | +| Native `.ecproj` | — | yes | yes | Versioned JSON; schema migrations tested; absolute footage paths, no relinking (gap 18) | engine project tests | + +## Footage: stills + +| Format | After Effects | Read | Write | Fidelity / notes | +|---|---|---|---|---| +| PNG | R/W | yes | yes (sequence) | good | +| JPEG | R/W | yes | yes (sequence) | good | +| TIFF | R/W | yes | yes (sequence) | good | +| OpenEXR (multi-layer, Cryptomatte) | R/W | yes | yes (32-bit sequence) | good; HTJ2K compression fails (#482); EXRs without an unnamed RGB layer decode twice (#474) | +| Photoshop PSD / PSB | R (footage, composition) / W (layers, sequence) | yes (layers, groups, masks, styles, smart objects) | Save Frame As ▸ Photoshop Layers; no Photoshop sequence output | good, from Adobe's published spec | +| Illustrator `.ai`, PDF, EPS | R | yes (vector, Continuously Rasterize, Create Shapes) | — | partial: no JPX / JBIG2 images, no Illustrator procset EPS | +| SVG | R | yes | — | good; pasting SVG/AI as shape layers (26.3) missing | +| GIF | R/W | yes | yes (animated) | good | +| BMP | R | yes | — | good | +| WebP | — | yes | — | beyond After Effects | +| Targa `.tga` | R/W | none | none | Media Browser lists it but import refuses (gap 17) | +| DPX / Cineon `.dpx` `.cin` | R/W | none | none | Same; common in film VFX | +| Radiance `.hdr` | R/W | none | none | Same | +| SGI, PICT, ElectricImage, RLA/RPF images | R | none | none | Legacy; RPF camera data import exists (`keys.rpfCameraImport`) | +| Camera Raw (DNG, CR3, NEF…) | R (Camera Raw) | none | none | LightCraft has decoders that could be shared | +| HEIF / AVIF | R (platform) | none | none | — | + +## Footage: video and audio + +| Format | After Effects | Read | Write | Fidelity / notes | +|---|---|---|---|---| +| MP4 / MOV with H.264 | R/W | yes | yes (own encoder, + AAC) | decoder bit-exact on conformance streams (FilmCraft) | +| HEVC (H.265) | R/W | yes | yes (MP4 `hvc1`, Main / Main 10) | encoder bit-exact with ffmpeg's decode; no B-frames / SAO, larger files | +| AV1 | R | yes | yes (MP4 `av01`, WebM) | beyond After Effects' export; no B-frames / CDEF | +| Apple ProRes (422 family, 4444) | R/W | yes | yes | good; 4444 colour tag wrong (#340); no ProRes RAW | +| WebM / Matroska (VP9, AV1, Opus) | R (limited) | yes, incl. VP9 alpha | yes | beyond After Effects | +| MXF (OP1a / OP-Atom) | R/W | none (FilmCraft reads it; not wired) | none | Media Browser lists it | +| AVI | R | none | none | Media Browser lists it | +| DNxHD / DNxHR | R/W | unverified (FilmCraft decodes it; reachable only inside `.mov`) | none | — | +| BRAW, R3D, ARRIRAW | R (plug-ins / SDKs) | none | none | proprietary SDKs | +| Variable-frame-rate phone video | R | unverified | — | needs a real-media corpus | +| WAV, AIFF | R/W | yes | yes (16-bit PCM) | good | +| MP3, AAC / M4A, FLAC, Ogg, Opus | R | yes | AAC and Opus inside video only | MP3 layer can go silent (#324) | + +## 3D models + +| Format | After Effects 26 | Read | Notes | +|---|---|---|---| +| glTF / GLB | R | yes (meshes, PBR, skins, animations) | good | +| OBJ / MTL | R | yes | good | +| FBX | R (`usdFbx`) | none | gap 13 | +| USD / USDZ | R (`usd_plugins`) | none | gap 13 | +| STL | R (`usdStl`) | none | gap 13 | +| Substance 3D materials `.sbsar` | R (26.0) | none | gap 13 | + +## Interchange and data + +| Format | After Effects | Read | Write | Notes | +|---|---|---|---|---| +| Premiere Pro `.prproj` | R (import) | none | none | Out of clean-room scope pending a decision; we interchange through FCP7 XML, FCPXML, OTIO, EDL, AAF and OMF instead (beyond After Effects) | +| Lottie JSON / dotLottie | via third-party plug-ins | yes | yes | beyond After Effects; a warnings list names what Lottie can't express | +| JSON / CSV / TSV data | R | yes (`footage()`, data layers) | — | good | +| LUTs (`.cube`, `.3dl`, `.csp`, `.spi1d/3d`, CDL) | R | yes (Apply Color LUT, OCIO effects) | — | Apply Color LUT bug (#514) | +| OpenColorIO configs | R (OCIO 2.5.1, ACES 2.0 in 26.5) | yes (OCIO v2 subset, Blender configs) | — | partial: no ACES 2.0 output transforms | +| Fonts (TTF, OTF, TTC, variable) | R | yes | — | good | +| Mocha shapes | R (Mocha AE) | own documented JSON | — | Mocha's format is not public | +| Vanishing Point `.vpe` | R | none | — | no public specification; the menu entry explains why | + +## Render queue outputs (`OutputFormat::ALL`, 12) + +H.264 MP4, HEVC MP4, AV1 MP4, ProRes MOV, WebM (VP9 or AV1 + Opus), PNG / JPEG / TIFF / OpenEXR +sequences, animated GIF, WAV, AIFF. Missing against After Effects + Media Encoder: uncompressed +or lossless single-file video (#372), DPX / Targa / Photoshop sequences, MXF, DNxHR, hardware +encoders ([hardware-parity.md](hardware-parity.md)), and rendering safely over an existing file +(#325). + +## Estimate + +**≈ 130–230 h**: `.aep` / `.aepx` import 80–160 h (after the owner decision), wiring TGA / DPX / +HDR / MXF / AVI / DNx import and DPX/TGA output 10–20 h, FBX / USD / STL and Substance 25–45 h, +relinking 6–12 h, `.mogrt`-compatible templates 15–30 h (partly parallel with the rest). + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | major | First file format parity document, from the After Effects 26.5 bundle and this repository's import/export registries | diff --git a/docs/gaps.md b/docs/gaps.md index e089534e..acb22a63 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,44 +1,87 @@ # Where EffectCraft falls short +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (re-measured against After Effects 2026 26.5; ranked gap list with estimates; numbers reconciled with target-app-parity.md) · **Target:** Adobe After Effects 2026 (26.5.0.89) + An honest assessment of how far EffectCraft is from being a real replacement for After Effects, and the work that closes the gap. This document is meant for contributors and agents choosing -what to work on. The [ROADMAP](../ROADMAP.md) summarises it; [parity.md](parity.md) is the -feature-by-feature checklist it builds on. - -*Assessed 5 October 2026, user issues updated 8 October. Estimates marked "≈" are judgements from the evidence listed, not -measurements. Update this file when the evidence changes.* +what to work on. [target-app-parity.md](target-app-parity.md) holds the numbers and how they were +measured; the [ROADMAP](../ROADMAP.md) summarises both. Estimates marked "≈" are judgements from +the evidence listed, not measurements. Hours are Opus 5.5 agent wall-clock hours, calibrated in +[target-app-parity.md](target-app-parity.md#calibration-of-the-hours). Update this file when the +evidence changes. ## Two different questions -[parity.md](parity.md) reports **≈ 99%**. That number answers one question: *does each After -Effects feature exist?* It scores a 92-item catalogue we wrote ourselves, graded by the same agents -that built the features, with partial features counting half. As a measure of breadth it is fair, -and the breadth is real: every one of After Effects' effects exists by name, along with the -menus, panels, 3D, tracking, expressions, scripting and export. +**Feature breadth ≈ 91%** answers *does each After Effects feature exist?* Every one of After +Effects' effects exists by name, along with the menus, panels, Classic and Advanced 3D, tracking, +expressions, scripting and export. The self-graded 92-item checklist (appendix of +target-app-parity.md) says ≈ 99%; measuring against After Effects 26.5 itself, rather than our +catalogue, finds what that checklist never listed: the 26.2–26.5 features (Object Matte, +Substance materials, ACES 2.0, Proportional Scrubbing, paste SVG/AI as shapes…), the 621 bundled +animation presets, project and template formats, and hardware encoding. -It does not answer the question users care about: *can someone who uses After Effects for a living -do real work in EffectCraft?* Nobody has measured that yet. Our best estimate is **≈ 30–50%**, -limited mainly by four things: projects we can't open, behaviour nobody has checked against After -Effects, reliability on platforms other than macOS, and the third-party plug-ins professional -projects depend on. - -The checklist is also internally inconsistent: parity.md's per-area table still shows Interface -75%, Project 68%, Animation 70% and Paint 0% next to the 99% headline. Some rows are stale; none -of them have been reconciled. +**Ready for real work ≈ 45%** answers *can someone who uses After Effects for a living do real +work in EffectCraft?* It is still estimated, not measured. Four things hold it back: projects we +can't open (`.aep`), behaviour nobody has checked against After Effects (one live comparison so +far: scalar ease, 168 samples), reliability outside macOS, and the third-party plug-ins and +presets professional projects depend on. It sits higher than the 5 October estimate (30–50%) +because 179 issues were closed with regression tests between 5 and 10 October. ## By dimension -| Dimension | Estimate | Evidence | -|---|---|---| -| Breadth of features | ≈ 95%+ | All 306 effects, the After Effects menus and panels, Classic and Advanced 3D, tracking, expressions, scripting, render queue (parity.md) | -| Behaves like After Effects | largely unmeasured, ≈ 60–80% | One outside contributor found four bugs in features marked done within a day (PRs #6–#10): Hold keyframes eased the motion into them, `keyInSpatialTangent` / `keyOutSpatialTangent` had the wrong names, a zero frame rate crashed, Find and Enter Full Screen shared Ctrl+F off macOS. There are about 10 After Effects reference captures in total. The first live G1 comparison now checks eight scalar Rotation/ease cases (168 samples) against AE 26.3x87 via AEsync 2.0.4: all pass after fixing overlapping temporal influences, with maximum error below 3e-11 degrees ([scores and reproduction](fidelity/README.md)). Other animation behavior and rendered frames remain unmeasured. User reports of 6 October found five more in features marked done (the Render Queue's template menu, angle revolutions, switches wiping the RAM preview, audio playback skipping frames, preview crashing when video memory ran out; all fixed 7 October) | -| Opening existing After Effects work | ≈ 0% | `.aep` / `.aepx` projects cannot be opened. Third-party After Effects plug-ins cannot run. Expressions and the scripting object model are strong, so scripts and expressions carry over | -| Stability | improving | 23 never-crash PRs landed on 4 October; community PRs on 7 October fixed a hang on projects whose parent chains loop (#142), a panic on non-ASCII label colours (#135), and contained GPU initialization and device failures (#139); [AGENTS.md](../AGENTS.md) "Never crash" now binds every crate. On 5 October `cargo xtask ci` failed on main under Rust 1.99's clippy (a fix is in progress) | -| Real-user experience | ≈ 70%, uneven by platform | Issues #41–#47 (all from the Linux AppImage 0.1.1, 5 October): panning the viewer snaps back, panels can't be resized or rearranged, drag-and-drop and double-click import don't work, layer rename gets stuck, the Layer Settings arrow does nothing, the Project panel clips, the Wayland window icon is generic. Issues #63–#68 (macOS, 5 October): scaling a layer by its handles goes wrong and can stick at 0, two 3D compasses, the viewer lags while a layer is dragged (a frame renders in 20 ms but showed after ≈ 100 ms, behind RAM preview prefetch), the Discord and other links do nothing, Delete doesn't delete in the Project panel, hidden layers can be selected in the viewer. All but the last two were fixed or confirmed fixed with a test on 5–6 October (those two have community PRs); file drops still can't work on Wayland (winit has no support). The panning, rename and arrow bugs had been fixed in v0.2.0 already; nobody had told the reporter. A Windows user (6 October, on Discord) found the font menus listed only the three bundled fonts: installed fonts were read only when a project asked for one, on every platform. Fixed with a regression test on 6 October; the menus now list every installed family and its own styles, and agents get `text.fonts` / `list_fonts`. Seven reports of 6 October (Linux .deb and Windows): Project items and files couldn't be dropped on the viewer (#85), nor effects on a layer there (#88); drops in the Timeline ignored where they landed (#89); an angle's revolutions couldn't be edited (#93); toggling Audio, Lock or Shy threw away the RAM preview and playback with audio skipped frames (#103); running out of video memory panicked every frame thread and left frames stuck (#106); choosing a Render Settings template in the Render Queue did nothing (#117: any popup menu moved up to fit the window closed on the press). All seven were fixed with regression tests on 7 October. Reports of 7 October (Linux, macOS, Windows): the app opened the demo project on every launch, so a new comp landed in it (#204); a comp wider than the GPU's texture limit (11000 × 2200) at Full resolution stopped the window drawing (#201); an Intel HD Graphics 5500 couldn't start the app (#198: most likely the window's device asked for limits that GPU doesn't have, and the failure was silent); Audio Spectrum / Waveform stood still in the viewer (#209) and preview sound waited for the whole work area to cache (#208); a layer couldn't be deselected from the time graph, a shape layer's Fill and Stroke showed only with a shape tool and a drawn shape had no stroke to change, Alt+Shift+P didn't reveal Position (#205); a mask selected in the Timeline wasn't selected in the viewer, Mask Feather had no link and took negative values, the Project panel had no selection box (#203); shape layers had no Add menu for Trim Paths and the other operations, and Classic 3D comps hid Geometry Options (#206); dragging Puppet pins re-rendered the layer under the Puppet at every move (#212). All were fixed with regression tests the same day (#219–#222); the #198 fix couldn't be checked on that GPU. Reports of 8 October (browser, Windows, macOS): an empty project at 1280 × 800 with device pixel ratio 2 crashed on its first frame, because the Project panel's scroll bar clamped its thumb to a track shorter than the thumb (#231, any short panel on every platform); Media Browser ▸ Add Files… dropped a file it couldn't read without a word (#226); after the page's WebGPU device was lost, the browser editor froze on its last frame while edits went on (#225); a press in the viewer always took the topmost layer, so a selected layer behind others couldn't be dragged (#230); the language didn't follow the system's (#229); and a list of missing After Effects behaviour (#227): drawing into, copying, pasting and duplicating shape contents, Tool Creates Shape / Mask and Fill / Stroke Options, Turbulent Displace, Wave Warp and Bulge pinning all but a shape layer's bottom-right quadrant, a Spacebar hold that previewed instead of being the Hand tool, the Effect menu in Effect Controls, dropping items on New Composition and dragging over layer switches. All were fixed with regression tests the same day (#235, #237–#241); multi-monitor support and the Project panel's empty-area menu (community PR #210) remain from #227, and effect points on shape and text layers still sit half a comp off. A list of tweaks from an Intel iMac (#290): the layer bar didn't show where extended footage runs past its source, and Enable Time Remapping then ended at the extended out point; footage items had no Interpret Footage in their menu and the work area bar no menu; Label menus named the labels without their colours; the arrow keys didn't nudge layers; a straight motion path had no Bezier handles; Delete on Time Remap deleted the layer; a mask being drawn with the Pen blacked out its layer until it was closed; a double-click on a mask point didn't select the whole mask. All were fixed with regression tests the same day; the rest of #290 had been fixed already (#221, #238, #255) or came with community PRs #210 (the Timeline's empty-area menu) and #213 (tool flyouts on a long press). A report of 9 October (#397): CC Particle World ignored the comp camera and looked flat. It now sees its particles through an active camera layer, as After Effects does, and through its own Extras ▸ Effect Camera (Distance, Rotation X / Y / Z, FOV, added) otherwise; the layer cache also missed camera and light moves for the effects that read them (Card Dance, Shatter, Card Wipe, Caustics). Fixed with regression tests the same day. Most checking happens on macOS. Localisation covers the menus in Japanese, Simplified and Traditional Chinese, and Ukrainian, and since 9 October the cataloged panels, dialogs and tooltips too (Simplified and Traditional Chinese, and Ukrainian; the Japanese rows of the panel catalog are still open); no accessibility work | -| Performance | unknown against After Effects | Internal numbers only (e.g. Advanced 3D 290 ms/frame at 1080p on the GPU, an M4 Pro under load). Nothing benchmarked against After Effects; no large real projects (4K footage, hundreds of layers) tested | -| Media formats | ≈ 80% | H.264, ProRes, HEVC, AV1, image sequences and audio exist. The new HEVC / AV1 encoders have no B-frames, multi-reference or SAO / CDEF, so files are larger than from mature encoders. Camera formats (BRAW, R3D, ProRes RAW, variable-frame-rate phone video) are unverified | -| AI-assisted tools | ≈ 50% | Both tools can use trained models (pure-Rust inference, optional downloads), but nothing compares them with After Effects yet. Roto Brush 2.0 / 3.0 with MobileSAM (M13.35) scores IoU 0.989 on the base frame of our synthetic moving-disc test and ≥ 0.980 over 20 propagated frames (classic: 0.973). Face tracking with MediaPipe Face Landmarker (M13.36) matches Google's own pipeline to 0.85 px on average on a test portrait; on our synthetic clip the eyes and chin stay within 4% of the face height | -| Maturity | early | First commit 1 October 2026. ≈ 285,000 lines of almost entirely agent-written Rust, ≈ 2,050 tests, about 30 external issue reports so far. After Effects has around 30 years of edge cases behind it | +| Dimension | % | Hours | Evidence | +|---|---:|---:|---| +| Breadth of features | ≈ 91% | (below) | All 306 effects, the After Effects menus (587 entries) and panels, Classic and Advanced 3D, tracking, expressions, scripting, render queue. Missing: entries 7, 13, 14, 16 and 21 below | +| Behaves like After Effects | ≈ 50%, mostly unmeasured | 360–660 (features) | The first live G1 comparison checks eight scalar Rotation/ease cases (168 samples) against After Effects 26.3x87 via AEsync 2.0.4: all pass after fixing overlapping temporal influences, maximum error below 3e-11 degrees ([fidelity/README.md](fidelity/README.md)). Nothing else is compared. An outside contributor found four bugs in features marked done within a day (PRs #6–#10); users have found well over a hundred more since, and 134 issues are open | +| Opening existing After Effects work | ≈ 0% | 80–160 | `.aep` / `.aepx` / `.mogrt` / `.ffx` can't be read; After Effects plug-ins can't run. Expressions and the scripting object model are strong, so scripts and expressions carry over | +| UI/UX fidelity | ≈ 65% | 60–120 | [ui-parity.md](ui-parity.md) | +| File formats | ≈ 50% | 130–230 | [file-format-parity.md](file-format-parity.md) | +| Hardware | ≈ 40% | 60–120 | [hardware-parity.md](hardware-parity.md) | +| Localization | ≈ 12% | 60–110 + review | [localization-parity.md](localization-parity.md) | +| Stability | ≈ 45% | 40–80 | 23 never-crash PRs landed on 4 October and the "Never crash" rules bind every crate, but crash reports are open (#580 Chromatic Aberrations pan/tilt, #591 Windows ARM64 device loss, #501 crash on startup, #426 timeline drag while playing, #341 quit on Intel Mac, #306 divider drag, #234 macOS 27 exit, #511 drag over import dialog), and no CI workflow runs `cargo xtask ci` on pull requests | +| Real-user experience | ≈ 60%, uneven by platform | (in G2) | 179 issues closed 5–10 October, nearly all with regression tests ([log below](#real-user-report-log-510-october)); 134 open, many from Linux and Windows. Most checking still happens on macOS | +| Performance | unknown against After Effects, ≈ 35% | 50–100 | Internal numbers only (e.g. Advanced 3D 290 ms/frame at 1080p on the GPU on an M4 Pro). Users report stutter on cached frames (#595, #304), slow scrubbing (#329), late audio (#554). No 4K / hundreds-of-layers project tested | +| Media formats | ≈ 75% | (in file formats) | H.264, ProRes, HEVC, AV1, VP9, image sequences and audio exist. The HEVC / AV1 encoders have no B-frames, multi-reference or SAO / CDEF, so files are larger than from mature encoders. Camera formats (BRAW, R3D, ProRes RAW, variable-frame-rate phone video) are unverified | +| AI-assisted tools | ≈ 35% | 40–80 | Roto Brush 2.0 / 3.0 with MobileSAM (M13.35) scores IoU 0.989 on the base frame of our synthetic moving-disc test and ≥ 0.980 over 20 propagated frames (classic: 0.973). Face tracking with MediaPipe Face Landmarker (M13.36) matches Google's own pipeline to 0.85 px on a test portrait. Neither is compared with After Effects; Object Matte (26.2) is missing | +| Maturity | early | — | First commit 1 October 2026. ≈ 329,000 lines of almost entirely agent-written Rust, 2,704 tests, 313 GitHub issues. After Effects has around 30 years of edge cases behind it | + +## Every known gap, ranked + +**Alpha blockers: none.** Every core workflow passes the [alpha gate](roadmap.md#alpha-gate); the +entries below are what stands between alpha and beta (gap 1 decides beta). The rough edges the +gate lists live in gaps 5, 8, 9, 10, 11 and 17. + +Ranked by user impact × how many users hit it. Each entry: what's missing, evidence, impact, +estimate, the workstream (G1–G9, below) and the parity doc it belongs to. Small single-issue bugs +are grouped by area; the issue tracker has the rest. + +| # | Gap | Evidence | Impact | Estimate | Work · doc | +|---:|---|---|---|---:|---| +| 1 | **`.aep` / `.aepx` projects can't be opened** | No reader; candidate route py-aep (#248, MIT, external converter) awaits an owner decision on clean-room scope; #190 | The biggest barrier to switching: existing work, templates and client projects stay in After Effects | 80–160 h | G4 · [file-format-parity.md](file-format-parity.md) | +| 2 | **Fidelity unmeasured** beyond scalar ease | [fidelity/](fidelity/README.md): 168 samples, one property type; `compare_frames` helper exists, no corpus | Every "done" feature may still differ; users find the differences | 60–100 h harness + P0/P1 corpus; more per feature | G1 · [target-app-parity.md](target-app-parity.md) | +| 3 | **Open crash reports** | #580, #591, #501, #426, #341, #306, #234, #511 | A crash loses work; outranks features ([AGENTS.md](../AGENTS.md)) | 10–25 h | G3 · this file | +| 4 | **No CI gate on pull requests** | `.github/workflows` has release, packaging-lint, FreeBSD and Windows ARM64 jobs only; `cargo xtask ci` runs locally | Regressions reach main unseen; 49 community PRs landed in one batch (#598) | 4–8 h | G3 · this file | +| 5 | **Playback and scrubbing feel** | #595 stutter on cached frames, #304, #329 slow scrubbing, #554 / #504 / #335 audio late or crackling, #586 puppet lag | Preview is where motion designers spend their day | 25–50 h | G5 · [hardware-parity.md](hardware-parity.md) | +| 6 | **Linux and Windows interaction bugs** | #491 multi-layer edits, #488 rename arrows, #493, #492, #467 shortcuts, #268 drag and drop, #320 workspaces not persisting, #302 docking, #613 Direct3D 12 compositor, #596 GPU not detected | Many reports come from Linux; some Windows GPUs fail to start the compositor | 25–45 h | G2 · [ui-parity.md](ui-parity.md) | +| 7 | **Animation presets: 8 against 621** | After Effects ships 621 `.ffx` presets (Behaviors, Backgrounds, Shapes, Synthetics, Text, Transitions…); we ship 8 original text-animator presets and read only our `.ecpreset` | Presets are how many designers start; tutorials assume them | 20–40 h (original presets; `.ffx` reading is out of clean-room scope) | G8 · [effects-parity.md](effects-parity.md) | +| 8 | **Mask and shape bugs** | #510, #513, #527 (first Intersect/Darken mask), #509 Roto Brush size, #303 Mesh Warp, #553 pen tools on motion paths | Masks are core compositing | 10–20 h | G2 · [target-app-parity.md](target-app-parity.md) | +| 9 | **Effect behaviour bugs** | #580, #526 Fractal reset, #514 Apply Color LUT, #463 Curl Noise, #369 Motion Tile output size, #473 Cryptomatte selection, #474 multi-layer EXR decoded twice, #534–#536 community fixes pending | Each one breaks a look someone built | 10–20 h | G1/G2 · [effects-parity.md](effects-parity.md) | +| 10 | **Colour pipeline differences** | #494 32-bpc blend modes clamp and apply opacity differently; #495 no Working Gamma 2.4; #340 ProRes 4444 tagged bt709 but sRGB-encoded; ACES 2.0 / OCIO 2.5 configs (26.5) missing; #546 / #547 viewer colour | Wrong colour in delivery is a client-visible failure | 15–30 h | G1 · [file-format-parity.md](file-format-parity.md) | +| 11 | **Text rendering and layout** | #453 CJK text in comps, #316 paragraph alignment inverted, #483 squeezed at 4:3, #417 soft at Fit, #583 font search, #550 toolbar font options | Text is in almost every motion graphic | 10–20 h | G2 · [ui-parity.md](ui-parity.md) | +| 12 | **3D interaction and depth** | No 3D transform gizmos (#392, #347); Z position (#489); camera aperture (#260); reflections (#261); bevel options (#315); 3D issue reopened (#307) | Advanced 3D is After Effects' growth area since 2023 | 20–35 h | G2 · [ui-parity.md](ui-parity.md) | +| 13 | **3D model formats and materials** | After Effects 26 imports FBX, USD/USDZ, STL besides glTF and OBJ (`usd_plugins`), and Substance 3D materials with mesh displacement (26.0–26.2); we read glTF and OBJ | Product shots and 3D scenes come as FBX/USDZ | 25–45 h | — · [file-format-parity.md](file-format-parity.md) | +| 14 | **Object Matte** (26.2, disk cache in 26.5) | Not present; MobileSAM is already in `effectcraft-segment` and could back it | The new one-click roto; Roto Brush covers part of it | 15–30 h | G7 · [target-app-parity.md](target-app-parity.md) | +| 15 | **Hardware video decode and encode** | No VideoToolbox, NVENC, Quick Sync or VA-API; #498 asks for it; FilmCraft has VideoToolbox / NVENC / VA-API paths to borrow | 4K playback and export speed | 25–45 h | G5/G6 · [hardware-parity.md](hardware-parity.md) | +| 16 | **Localization depth** | Menus in 6 languages; panels in 4; effect names and ~1,650 effect parameter names untranslated; French, German, Korean, Italian, Russian (After Effects ships them) absent; #599 Hangul boxes on macOS | After Effects is used heavily in Japan, China, Korea, Brazil, Europe | 60–110 h + native review | G9 · [localization-parity.md](localization-parity.md) | +| 17 | **Media Browser lists files import refuses** | `media_browser.rs` lists TGA, DPX, HDR, AVI, MXF; `media::is_importable` has none of them; #431 any image imports as a sequence; #324 MP3 goes silent | Confusing failures on common VFX formats (DPX, TGA) | 15–30 h | G6 · [file-format-parity.md](file-format-parity.md) | +| 18 | **Relative paths and relinking** | Projects store absolute footage paths; no relink when a project moves | Moving or sharing a project breaks it | 6–12 h | G4 · [file-format-parity.md](file-format-parity.md) | +| 19 | **Plug-in ecosystem** | No After Effects SDK hosting (#600, #327 OpenFX, #334, #376 plug-in manager); our WebAssembly API has no third-party plug-ins | Many pro projects depend on Trapcode, Element, Saber… | 40–80 h for API growth; SDK hosting needs an owner decision | G8 · [scripting-parity.md](scripting-parity.md) | +| 20 | **Multi-monitor and video output** | #486 multi-screen, floating panels on other screens, Mercury Transmit to video hardware (we have a second preview window only) | Two-screen setups are standard for professionals | 10–20 h | G2 · [hardware-parity.md](hardware-parity.md) | +| 21 | **Small 26.x features** | Proportional Scrubbing (26.2), copy frame to clipboard and paste SVG/AI as shapes (26.3), variable-font filter (26.3), percentage guides and effect colour labels (26.5), the rebuilt Effect Controls (26.5) | Users of the current version expect them | 15–25 h | — · [ui-parity.md](ui-parity.md) | +| 22 | **Encoder efficiency** | HEVC / AV1 have no B-frames, multi-reference or SAO / CDEF; Opus no FEC / DTX; #372 uncompressed single-file output; #325 render over existing file | Larger files than After Effects + Media Encoder | 20–40 h | G6 · [file-format-parity.md](file-format-parity.md) | +| 23 | **Essential Graphics interchange** | Templates are `.ectemplate`, not `.mogrt`; #338 Scale exposes X and Y; #611 FilmCraft rejects exported `.fcgt` | Premiere editors can't use our templates | 15–30 h | — · [file-format-parity.md](file-format-parity.md) | +| 24 | **Timeline depth requests** | #601 expand precomps in place, #602 reorderable render stack, #604 guide-only effects, #355 bulk keyframes, #425 keyframe confusion, #428 more shortcuts | Power-user speed | 15–30 h | G2 · [ui-parity.md](ui-parity.md) | +| 25 | **Accessibility** | AccessKit is enabled in eframe and tests read focus labels, but no screen-reader pass, keyboard navigation audit or contrast check | Excludes users; After Effects supports screen readers poorly too | 15–30 h | G9 · [ui-parity.md](ui-parity.md) | ## Where we're going: workstreams in priority order @@ -55,11 +98,11 @@ The most important missing piece: it turns every other estimate here into a meas - Drive After Effects through ExtendScript (see CLAUDE.md) to record property values at sampled times and render reference frames; render the same projects in EffectCraft headless. - Compare values exactly and frames with a perceptual metric; publish a per-feature fidelity - score and a fidelity column in parity.md. + score and a fidelity column in target-app-parity.md. - Clean room: After Effects output stays local in `plan/aftereffects/ref/` (gitignored). Commit only our projects, the harness and the scores, never After Effects frames. - Done when: every P0 and P1 feature has at least one corpus project, scores are reproducible from - one command, and parity.md reports breadth and measured fidelity side by side. + one command, and target-app-parity.md reports breadth and measured fidelity side by side. ### G2. Real-user reliability on every platform @@ -143,6 +186,12 @@ The most important missing piece: it turns every other estimate here into a meas - Done when: no open bug blocks a basic workflow (import, arrange, animate, preview, render) on any of the three desktop platforms, and every bug users report gets triaged within a day. +### Real-user report log (5–10 October) + +Kept from the 5 October assessment's "Real-user experience" row, as evidence of what users hit and what was fixed: + +Issues #41–#47 (all from the Linux AppImage 0.1.1, 5 October): panning the viewer snaps back, panels can't be resized or rearranged, drag-and-drop and double-click import don't work, layer rename gets stuck, the Layer Settings arrow does nothing, the Project panel clips, the Wayland window icon is generic. Issues #63–#68 (macOS, 5 October): scaling a layer by its handles goes wrong and can stick at 0, two 3D compasses, the viewer lags while a layer is dragged (a frame renders in 20 ms but showed after ≈ 100 ms, behind RAM preview prefetch), the Discord and other links do nothing, Delete doesn't delete in the Project panel, hidden layers can be selected in the viewer. All but the last two were fixed or confirmed fixed with a test on 5–6 October (those two have community PRs); file drops still can't work on Wayland (winit has no support). The panning, rename and arrow bugs had been fixed in v0.2.0 already; nobody had told the reporter. A Windows user (6 October, on Discord) found the font menus listed only the three bundled fonts: installed fonts were read only when a project asked for one, on every platform. Fixed with a regression test on 6 October; the menus now list every installed family and its own styles, and agents get `text.fonts` / `list_fonts`. Seven reports of 6 October (Linux .deb and Windows): Project items and files couldn't be dropped on the viewer (#85), nor effects on a layer there (#88); drops in the Timeline ignored where they landed (#89); an angle's revolutions couldn't be edited (#93); toggling Audio, Lock or Shy threw away the RAM preview and playback with audio skipped frames (#103); running out of video memory panicked every frame thread and left frames stuck (#106); choosing a Render Settings template in the Render Queue did nothing (#117: any popup menu moved up to fit the window closed on the press). All seven were fixed with regression tests on 7 October. Reports of 7 October (Linux, macOS, Windows): the app opened the demo project on every launch, so a new comp landed in it (#204); a comp wider than the GPU's texture limit (11000 × 2200) at Full resolution stopped the window drawing (#201); an Intel HD Graphics 5500 couldn't start the app (#198: most likely the window's device asked for limits that GPU doesn't have, and the failure was silent); Audio Spectrum / Waveform stood still in the viewer (#209) and preview sound waited for the whole work area to cache (#208); a layer couldn't be deselected from the time graph, a shape layer's Fill and Stroke showed only with a shape tool and a drawn shape had no stroke to change, Alt+Shift+P didn't reveal Position (#205); a mask selected in the Timeline wasn't selected in the viewer, Mask Feather had no link and took negative values, the Project panel had no selection box (#203); shape layers had no Add menu for Trim Paths and the other operations, and Classic 3D comps hid Geometry Options (#206); dragging Puppet pins re-rendered the layer under the Puppet at every move (#212). All were fixed with regression tests the same day (#219–#222); the #198 fix couldn't be checked on that GPU. Reports of 8 October (browser, Windows, macOS): an empty project at 1280 × 800 with device pixel ratio 2 crashed on its first frame, because the Project panel's scroll bar clamped its thumb to a track shorter than the thumb (#231, any short panel on every platform); Media Browser ▸ Add Files… dropped a file it couldn't read without a word (#226); after the page's WebGPU device was lost, the browser editor froze on its last frame while edits went on (#225); a press in the viewer always took the topmost layer, so a selected layer behind others couldn't be dragged (#230); the language didn't follow the system's (#229); and a list of missing After Effects behaviour (#227): drawing into, copying, pasting and duplicating shape contents, Tool Creates Shape / Mask and Fill / Stroke Options, Turbulent Displace, Wave Warp and Bulge pinning all but a shape layer's bottom-right quadrant, a Spacebar hold that previewed instead of being the Hand tool, the Effect menu in Effect Controls, dropping items on New Composition and dragging over layer switches. All were fixed with regression tests the same day (#235, #237–#241); multi-monitor support and the Project panel's empty-area menu (community PR #210) remain from #227, and effect points on shape and text layers still sit half a comp off. A list of tweaks from an Intel iMac (#290): the layer bar didn't show where extended footage runs past its source, and Enable Time Remapping then ended at the extended out point; footage items had no Interpret Footage in their menu and the work area bar no menu; Label menus named the labels without their colours; the arrow keys didn't nudge layers; a straight motion path had no Bezier handles; Delete on Time Remap deleted the layer; a mask being drawn with the Pen blacked out its layer until it was closed; a double-click on a mask point didn't select the whole mask. All were fixed with regression tests the same day; the rest of #290 had been fixed already (#221, #238, #255) or came with community PRs #210 (the Timeline's empty-area menu) and #213 (tool flyouts on a long press). A report of 9 October (#397): CC Particle World ignored the comp camera and looked flat. It now sees its particles through an active camera layer, as After Effects does, and through its own Extras ▸ Effect Camera (Distance, Rotation X / Y / Z, FOV, added) otherwise; the layer cache also missed camera and light moves for the effects that read them (Card Dance, Shatter, Card Wipe, Caustics). Fixed with regression tests the same day. Most checking happens on macOS. Localisation covers the menus in Japanese, Simplified and Traditional Chinese, and Ukrainian, and since 9 October the cataloged panels, dialogs and tooltips too (Simplified and Traditional Chinese, and Ukrainian; the Japanese rows of the panel catalog are still open); no accessibility work + ### G3. Stability and a green main - Keep `cargo xtask ci` green on the current stable toolchain. A toolchain release that breaks the @@ -150,7 +199,10 @@ The most important missing piece: it turns every other estimate here into a meas - Fuzz the inputs we don't control: project files, imported media and documents, expressions, scripts, control-channel and MCP requests. Every crash found becomes a regression test (AGENTS.md "Never crash"). -- Done when: the gate is green on stable and the fuzzers run regularly without new crashes. +- Run the gate on every pull request in CI (gap 4): today no workflow runs the tests on PRs. +- Fix the open crash reports first (gap 3). +- Done when: the gate is green on stable, runs on every pull request, and the fuzzers run + regularly without new crashes. ### G4. Open After Effects projects @@ -163,7 +215,7 @@ The most important missing piece: it turns every other estimate here into a meas `.ecproj`, the way ffmpeg is used only from outside), or its format knowledge could be ported. The second means reading another implementation of the format, which AGENTS.md rules out unless the clean-room scope allows it. -- Also in this area: relative footage paths and relinking when a project moves (parity.md, Project). +- Also in this area: relative footage paths and relinking when a project moves (gap 18). - Done when: the decision is recorded and, if approved, a corpus of real-world-shaped projects opens with its comps, layers, keyframes, effects and expressions intact. @@ -211,19 +263,34 @@ The most important missing piece: it turns every other estimate here into a meas ScriptUI panel bundled as an optional extension (`extensions/scriptui-panels/Ease Presets.jsx`, Window ▸ Ease Presets.jsx) on the public scripting API, which needed `app.settings` that last, per-key methods that keep the key selection, and eases reported as they play - ([plugins.md](plugins.md#bundled-extensions), [parity.md](parity.md)). + ([plugins.md](plugins.md#bundled-extensions), [target-app-parity.md](target-app-parity.md)). ### G9. Reach -- Localisation of the interface, accessibility (keyboard navigation, screen readers, contrast), - user documentation and tutorials. +- Localisation of the interface ([localization-parity.md](localization-parity.md)): since 6–10 + October the menus are translated into Japanese, Simplified and Traditional Chinese, Ukrainian, + Spanish and Brazilian Portuguese, and the panel catalog covers the first four. Next: effect and + parameter names, the remaining panel strings, French, German and Korean, and native-speaker + review of all of them. +- Accessibility (keyboard navigation, screen readers, contrast), user documentation and + tutorials. ## For agents choosing work 1. Read `plan/STATUS.md` for owner blockers and running work, then this file. 2. Prefer G1–G3 over new features. A feature that exists but behaves differently from After Effects is not done. -3. When you fix a behaviour bug in a feature parity.md marks as done, add it to the evidence - above. That is how this assessment stays honest. -4. Don't raise parity.md's numbers without evidence: a G1 score, a user-facing check on more than +3. When you fix a behaviour bug in a feature target-app-parity.md marks as done, add it to the evidence + above, and update its row in the ranked list (remove it when the gap is closed). That is how this assessment stays honest. +4. Don't raise target-app-parity.md's numbers without evidence: a G1 score, a user-facing check on more than one platform, or both. + + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | minor | Alpha gate checked: no alpha blockers | +| 2026-10-10 | major | Re-measured against After Effects 2026 26.5: two numbers (breadth ≈ 91%, ready ≈ 45%), dimension table with hours, a ranked list of 25 gaps with evidence, impact, estimate and parity doc; the real-user evidence moved into a report log under G2; links follow the rename of `parity.md` to `target-app-parity.md` | +| 2026-10-08 | minor | User issues of 6–8 October and their regression evidence | +| 2026-10-05 | major | First honest assessment: breadth ≈ 99% vs real use ≈ 30–50%; workstreams G1–G9 | diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md new file mode 100644 index 00000000..922f75e6 --- /dev/null +++ b/docs/hardware-parity.md @@ -0,0 +1,42 @@ +# Hardware parity + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first hardware parity document, from the code and open issues against After Effects 2026 26.5) · **Target:** Adobe After Effects 2026 + +Each piece of hardware After Effects uses, per platform, against EffectCraft. Ours comes from the +code at `origin/main` 1446e27 (`crates/gpu`, `crates/render`, `crates/ui-egui`) and the issue +tracker; After Effects' from its documentation (Mercury GPU Acceleration, Multi-Frame Rendering, +hardware-accelerated decode and encode, Mercury Transmit) and its bundle. + +**Overall ≈ 40%** (estimated). The GPU compositor and 280 GPU effects are a real strength and run +in the browser too; hardware video decode and encode, video-out devices, tablets and multiple +monitors are missing, and two GPU start-up failures are open. + +| Feature | After Effects 2026 | EffectCraft macOS | Windows | Linux | Web | Notes / evidence | +|---|---|---|---|---|---|---| +| GPU compositing and effects | Mercury GPU (Metal; CUDA / OpenCL / DirectX on Windows) | yes, Metal via wgpu | Vulkan yes; **Direct3D 12 fails** (FXC can't compile the `pointwise` kernel, #613) | Vulkan | WebGPU, in frame workers | 280 of 306 effects on the GPU, checked against the CPU renderer; Fractal and the analysis/tool effects stay on the CPU ([effects-parity.md](effects-parity.md)) | +| CPU fallback and device-loss recovery | yes | yes | yes; Windows ARM64 Adreno panics after device loss (#591) | yes | yes (#225 fixed) | Out of video memory falls back to the CPU (#106) | +| GPU detection and choice | GPU info, renderer choice | Backend Auto per comp | "GPU isn't being detected" (#596); Intel HD 5500 fix unverified (#198) | yes | yes | No settings page listing adapters yet | +| Multi-core rendering | Multi-Frame Rendering | rayon pools; preview scheduler sized to the render pool | same | same | Web Workers, one engine per worker | No benchmark against Multi-Frame Rendering | +| Hardware video decode | H.264 / HEVC / ProRes (Apple silicon) | none (software, FilmCraft) | none | none | none | FilmCraft has VideoToolbox, Media Foundation/DXVA and VA-API paths to borrow (gap 15) | +| Hardware video encode | H.264 / HEVC via VideoToolbox, NVENC, Quick Sync (render queue and Media Encoder) | none | none | none | none | #498 asks for NVENC; our software encoders are slower and lack B-frames | +| Video output devices | Mercury Transmit to AJA, Blackmagic, second display | second preview window only (Video Preview) | same | same | — | No SDI/HDMI device output | +| HDR display | HDR monitoring on EDR / HDR displays | tone-mapped SDR view; no EDR output | no HDR output | no | no | Colour engine handles HDR maths (Rec. 2100 PQ/HLG output files) | +| Pen tablets (pressure, tilt) | yes, for Paint and Roto | Brushes panel has pressure options, but no tablet pressure is read | same | same | same | Paint strokes store pressure per vertex; input never fills it | +| Multiple monitors | panels and windows across screens | floating panels within one window only | same | same (#486) | — | gap 20 | +| Touch / trackpad gestures | pinch zoom in viewers | egui pointer and scroll; no pinch-to-zoom audited | — | — | — | — | +| Audio hardware | Core Audio / ASIO / WASAPI, device choice, latency | cpal output; preview sound late on Linux (#554) and after a start offset (#504) | cpal | cpal (ALSA) | Web Audio | No input or device latency settings | +| Machine learning acceleration | Apple Neural Engine / GPU for Object Matte, Roto Brush 3 | CPU inference (pure Rust, rayon) | CPU | CPU | CPU | No GPU inference for MobileSAM or MediaPipe | +| Memory management | RAM reserve, disk cache | RAM Reserved, Reduce Cache When Low on Memory, disk cache | same | same | OPFS disk cache | good | + +## Estimate + +**≈ 60–120 h**: Direct3D 12 and adapter start-up fixes 6–12 h; hardware decode on three platforms +20–35 h and encode 15–30 h (borrowing FilmCraft's work); multiple monitors 10–20 h; tablet pressure +4–8 h; HDR output 8–15 h. Needs Windows and Linux GPUs we don't have locally, and a human for +the Adreno and Intel cases. + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | major | First hardware parity document | diff --git a/docs/localization-parity.md b/docs/localization-parity.md new file mode 100644 index 00000000..ff70ca62 --- /dev/null +++ b/docs/localization-parity.md @@ -0,0 +1,89 @@ +# Localization parity + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first measurement of every catalog against the After Effects 2026 26.5 language set) · **Target:** Adobe After Effects 2026 + +How much of EffectCraft's interface is translated, language by language, compared with After +Effects. [gaps.md](gaps.md) gap 16 and workstream G9 hold the work; +[target-app-parity.md](target-app-parity.md) the overall numbers. + +## What After Effects ships + +The installed After Effects 2026 (26.5.0.89) bundle has 11 `.lproj` localisations: English, +French, German, Italian, Japanese, Korean, Portuguese (Brazil), Russian, Spanish, Simplified +Chinese (`zh_CN`) and Traditional Chinese (`zh_TW`). Each translates the whole interface, +including effect names and parameters. It ships no Hindi, Arabic, Indonesian or Vietnamese +interface; Arabic and Hebrew text *in layers* is supported through its Middle Eastern text engine. + +## How EffectCraft translates + +- **Menus:** `crates/ui-egui/src/i18n.rs`, one catalog per language keyed by + `(command, English label)`. A test (`every_translation_key_exists_in_the_actual_menu_tree`) + fails if any fixed menu entry is untranslated or a row is stale, so every catalog covers all + **618** fixed entries. The generated Effect menu (effect names) is deliberately left in English. +- **Panels, dialogs, buttons, tooltips:** `crates/ui-egui/src/i18n/ui.rs`, keyed by the English + source string, with English fallback. Catalogs exist for Japanese, Simplified and Traditional + Chinese and Ukrainian. +- **Not translated anywhere yet:** effect names and their ≈ 1,650 parameter names + (`crates/effects`), engine-generated text (property names, errors, undo labels), the Home + screen's unwrapped literals (#574), Learn tutorials and documentation. +- **Language choice:** Settings ▸ General ▸ Language, or Match System + (`match_system_uses_the_system_language_where_there_is_a_catalog`). +- **Fonts:** no bundled CJK font; the UI installs system fallbacks probed by script (Kana, Han, + Arabic, Hebrew, Devanagari, Bengali, Gurmukhi, Gujarati, Tamil, Telugu, Kannada, Malayalam, + Thai: `crates/ui-egui/src/theme.rs`). There is no Hangul probe, so Korean renders as boxes on + macOS (#599). The web build has no system fonts. +- **Script support in the interface:** egui draws text without complex shaping or right-to-left + layout, so an Arabic or Hindi interface would render unshaped and left to right. **In text + layers** (`crates/text`) shaping (harfrust), bidi (UAX #9), vertical text and Tate-Chu-Yoko + work; text-layer typing goes through the IME (`viewer_text.rs` handles preedit and commit). + +## Measurement + +Measured 10 October 2026 at `origin/main` 1446e27 by counting catalog rows. The denominator, +**≈ 3,900 user-visible strings**, is estimated: 618 fixed menu entries + ≈ 1,320 capitalised UI +literals in `crates/ui-egui/src` outside tests and catalogs (a regex count, so an upper bound) + +≈ 1,650 distinct effect parameter names + 306 effect names. Engine messages are not counted, so +the true percentages are slightly lower. + +| Language | Code | UI strings translated | Dialogs / tooltips / help | Script support | Native review | Status | To `full` | +|---|---|---|---|---|---|---|---| +| English | `en` | ≈ 3,900 (100%, source) | All; docs in English | — | yes | full | — | +| Simplified Chinese | `zh-hans` | 1,081 (≈ 28%): menus 618/618, panels 463 | Panels and dialogs partly (≈ 35% of panel strings); no help, effects untranslated; Home screen literals unwrapped (#574) | System CJK fallback; Han follows the UI language; CJK text in comps renders wrongly (#453); IME in text layers | no | partial | 6–9 h + review | +| Spanish | `es` | 618 (≈ 16%) | Menus only | Latin, fine | no | menus only | 7–10 h + review | +| Hindi | `hi` | 0 | — | UI: Devanagari fallback font but no shaping; text layers shape Devanagari | no | none (After Effects: none) | 9–13 h + shaping in the UI | +| Arabic | `ar` | 0 | — | UI: Arabic fallback font but no shaping and no RTL layout; text layers: bidi + shaping | no | none (After Effects: none) | 9–13 h + 15–25 h RTL UI | +| French | `fr` | 0 | — | Latin, fine | no | none | 8–12 h + review | +| Portuguese (Brazil) | `pt-br` | 618 (≈ 16%) | Menus only | Latin, fine | no | menus only | 7–10 h + review | +| Indonesian | `id` | 0 | — | Latin, fine | no | none (After Effects: none) | 8–12 h + review | +| Japanese | `ja` | 1,015 (≈ 26%): menus 618/618, panels 397 | Panels and dialogs partly (≈ 30% of panel strings) | Japanese system fallback; vertical text and Tate-Chu-Yoko in text layers; IME | no | partial | 6–9 h + review | +| German | `de` | 0 | — | Latin, fine | no | none | 8–12 h + review | +| Korean | `ko` | 0 | — | No Hangul fallback probe: boxes on macOS (#599) | no | none | 9–13 h + review | +| Vietnamese | `vi` | 0 | — | Latin with stacked diacritics; untested | no | none (After Effects: none) | 8–12 h + review | + +Other languages EffectCraft ships (2): + +| Language | Code | UI strings translated | Status | To `full` | +|---|---|---|---|---| +| Traditional Chinese | `zh-hant` | 1,081 (≈ 28%): menus 618/618, panels 463 | partial | 6–9 h + review | +| Ukrainian | `uk` | 1,015 (≈ 26%): menus 618/618, panels 397 | partial | 6–9 h + review | + +After Effects also ships Italian and Russian, which EffectCraft does not (8–12 h each). + +**Overall: ≈ 12%**, the average coverage of After Effects' 10 non-English languages +(Simplified and Traditional Chinese 28%, Japanese 26%, Spanish and Portuguese 16%, French, +German, Korean, Italian and Russian 0%). This is the localization row in +[target-app-parity.md](target-app-parity.md#by-dimension). + +**Effort.** One-time work first: route effect names, parameter names and engine messages through +a catalog, wrap the remaining panel literals, and add a Hangul probe (10–20 h). Then each language +needs ≈ 2,900 more strings (≈ 5–9 h per language at the pace of #398 and #401). Parity with After +Effects' 10 non-English languages: **≈ 60–110 h**; the four extra languages of the standard list +(Hindi, Arabic, Indonesian, Vietnamese) add ≈ 50–80 h, most of it right-to-left layout and +complex shaping in the interface. Every language needs native-speaker review, which only a human +can give; none has had it. + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | major | First localization parity document: catalog rows counted for all six languages, the twelve-language table, After Effects' 11 shipped languages from its bundle | diff --git a/docs/roadmap.md b/docs/roadmap.md new file mode 100644 index 00000000..73fdfd23 --- /dev/null +++ b/docs/roadmap.md @@ -0,0 +1,87 @@ +# Roadmap detail + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (new file: milestones moved from ROADMAP.md, current focus and next steps with Opus 5.5 estimates) · **Target:** Adobe After Effects 2026 + +Forward-looking detail behind the [ROADMAP](../ROADMAP.md). The work list itself, one gap at a +time, is [gaps.md](gaps.md); the numbers are in [target-app-parity.md](target-app-parity.md). +There is no `cargo xtask scorecard` or `cargo xtask parity` in this repository yet. + +## Current focus + +In this order (gap numbers from [gaps.md](gaps.md)): + +1. **Crashes and the CI gate** (gaps 3–4, G3): fix the eight open crash reports; run + `cargo xtask ci` on every pull request. 15–30 h. +2. **Fidelity harness** (gap 2, G1): extend the AEsync ease oracle to rendered frames; a corpus + case for every P0 feature, then P1. 60–100 h; needs After Effects running on the owner's Mac. +3. **Real-user reliability** (gaps 5, 6, 8, 9, 11, G2): the open Linux and Windows interaction, + mask, effect and text bugs, each with a scripted test. 60–120 h, parallel. +4. **Owner decision on `.aep`** (gap 1, G4): then the importer. 80–160 h after the decision. +5. **Performance** (gap 5, G5): benchmarks at 1080p and 4K; real-time cached playback. 40–70 h. + +## Alpha gate + +The core workflows of a motion designer in After Effects, checked on EffectCraft's main platform +(macOS) from the scripted UI tests, the live After Effects ease oracle and the open issues +(standard: craftrules `standards/progress-docs.md`, "Stages"). **All six pass, so EffectCraft is +alpha**: each works end to end and the work saves and reopens; what's rough in them is listed, but +none blocks the workflow. + +| Workflow | Works end to end? | Evidence | Hours to clear the rough edges | +|---|---|---|---| +| 1. Set up: new comp, import footage, stills, PSD/AI and audio, arrange and trim layers | yes | `file.import` and image-sequence tests, `ui_drag_drop`, `ui_project_*`; rough: a still imports as a sequence (#431), an MP3 layer can go silent (#324), multi-file drops (#552) | 2–4 | +| 2. Animate: keyframes, easing in the Graph Editor, motion paths, parenting | yes | 168 live samples match After Effects 26.3 ([fidelity/](fidelity/README.md)); `ui_keyframes`; rough: Graph Editor jitter and clipped buttons (#456, #457), negative rotation drag (#569) | 3–6 | +| 3. Motion graphics: text layers with animators, shape layers with operators | yes | `ui_text_edit`, `ui_timeline_shapes`, text and shape engine tests; rough: paragraph alignment inverted (#316), CJK text in comps (#453) | 3–6 | +| 4. Composite: masks, track mattes, blend modes, keying and effects | yes | Compositor and mask tests, 306 effects with CPU/GPU agreement; rough: mask bugs (#510, #513, #527), 32-bpc blend differences (#494), effect bugs (#580 crash, #514) | 6–12 | +| 5. Preview with audio (RAM preview, cache) | yes | `ui_ram_preview`, `ui_live_preview`; rough: stutter on cached frames (#595, #304), late audio (#554, #504) | 10–20 | +| 6. Save, reopen and deliver: `.ecproj`, auto-save, Render Queue to H.264 / ProRes / sequences | yes | Versioned-schema reopen tests, crash recovery, render-queue and encoder tests; rough: the macOS default output folder is `/`, so a render fails until an output path is chosen (#446), ProRes 4444 colour tag (#340), projects don't relink when moved | 1–3 | + +The gate is not beta: beta also needs After Effects' own `.aep` projects to open (gap 1) and +≈ 75% ready for real work. + +## Milestones + +The original build plan; all but M7 and M12 are done. What is left after them is measured +against After Effects in [gaps.md](gaps.md), not as milestones. + +| | Milestone | State | +|---|---|---| +| M0 | Skeleton: crates, compositor, the After Effects style shell, control channel, `cargo xtask` | Done (web build: [web.md](web.md)) | +| M1 | Keyframes: temporal and spatial interpolation, Easy Ease, roving, velocity | Done | +| M2 | Compositing: 38 blend modes, track mattes, parenting, adjustment layers | Done | +| M3 | Project operations: settings dialogs, layer commands, `.ecproj`, undo, After Effects menu bar | Done | +| M4 | Preview: precomps, motion blur, cached playback | Done | +| M5 | Timeline depth: graph editor, keyframe clipboard and dialogs, time remapping, pick-whips, expression editor | Done | +| M6 | Shapes, masks and footage: shape operators, masks and the pen tool, video and image import | Done | +| M7 | 3D: 3D layers, cameras, lights, shadows, depth of field, 3D views and camera tools | In review (3D gizmos, FBX/USD, Substance still missing: gaps 12–13) | +| M8 | Expressions with the After Effects object model | Done | +| M9 | Text and effects: text animators, layer styles, 306 effects incl. time and audio effects | Done | +| M10 | Export: render queue, H.264, ProRes, image sequences, GIF, audio | Done | +| M11 | Animation tools: audio, Lottie, presets, Motion Sketch, Wiggler, Smoother | Done (preset library: gap 7) | +| M12 | Performance: layer cache, parallel and GPU compositing, disk cache, motion tracking; GPU versions of the remaining CPU-only effects | Mostly done (benchmarks and hardware video: gaps 5, 15) | +| M13 | Puppet, paint, Roto Brush, tracking, plug-in API, timeline depth, trained models | Done | +| M14 | Built for agents: MCP server, CLI, control channel, Settings, shortcut editor, auto-save, crash recovery | Done | +| M15 | The web app (WebAssembly, WebGPU) | Done | + +## What's next, with estimates + +| Next | Gaps | Opus 5.5 hours | Parallel? | Needs a human | +|---|---|---:|---|---| +| Crash fixes and a PR CI gate | 3, 4 | 15–30 | yes | GPUs for #591 / #613 | +| G1 fidelity harness and P0/P1 corpus | 2 | 60–100 | after the harness | After Effects runs on the owner's Mac | +| Open bug backlog (Linux, Windows, masks, effects, text, 3D) | 5, 6, 8, 9, 11, 12, 24 | 100–190 | yes | Linux / Windows testers | +| `.aep` / `.aepx` import | 1 | 80–160 | partly | Owner decision on clean-room scope | +| Performance benchmarks and real-time preview | 5, 15 | 50–100 | partly | — | +| Localization depth | 16 | 60–110 | yes | Native-speaker review | +| Preset library, 26.x features, Object Matte | 7, 14, 21 | 50–95 | yes | — | +| Formats: VFX stills, MXF, 3D models, templates | 13, 17, 18, 22, 23 | 80–160 | yes | — | +| Plug-in ecosystem, multi-monitor, accessibility | 19, 20, 25 | 65–130 | yes | Owner decision on SDK hosting | + +To beta (≈ 75% ready and `.aep` opening): **≈ 350–620 h**. To full parity: **≈ 850–1,500 h**. + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | minor | Alpha gate: six core workflows checked; all pass, stage stays alpha | +| 2026-10-10 | major | New file: milestones from ROADMAP.md, current focus, next steps with estimates | diff --git a/docs/scripting-parity.md b/docs/scripting-parity.md new file mode 100644 index 00000000..fef88efb --- /dev/null +++ b/docs/scripting-parity.md @@ -0,0 +1,43 @@ +# Expressions and scripting parity + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first expressions and scripting checklist against After Effects 2026 26.5) · **Target:** Adobe After Effects 2026 + +Expressions and ExtendScript are how After Effects work is automated and how much of it is +built: rigs, templates, studio pipelines. They are also what carries over from After Effects +today, while `.aep` projects can't. Architecture: [architecture.md](architecture.md) §5 and §5a; +extension points: [plugins.md](plugins.md); overall numbers: +[target-app-parity.md](target-app-parity.md). + +**Breadth ≈ 93%, ready ≈ 65%** (estimated). The object models are written from Adobe's public +Expression Language Reference and Scripting Guide (behaviour only, AGENTS.md §2) and tested with +our own scripts; no real-world script library has been run against both apps. + +| Feature | After Effects 2026 | EffectCraft | Status | Evidence / gap | +|---|---|---|---|---| +| Expression language | JavaScript engine (default) and Legacy ExtendScript | JavaScript (boa) with legacy array maths (`value + [10, 0]`) | good | `crates/expr` tests; no separate Legacy ExtendScript engine mode | +| Expression object model | `thisComp`, `thisLayer`, `wiggle`, `loopOut`, `valueAtTime`, `sampleImage`, `footage()`, text style API… | Same members, from the reference | good | `crates/expr/src/tests.rs`; behaviour of edge cases unmeasured (G1) | +| Expression editing | Inline field, error bar, pick whip, Expression Language menu, external editors | Inline field that grows, error bar, pick whips (property and value), language menu | partial | External editor support (#364) | +| Expression performance | Cached, multi-frame | Per-thread boa contexts | unknown | No benchmark | +| Scripting object model | `app`, `Project`, items, layers, properties, keys, eases, render queue, `TextDocument`, `Shape`, markers, `File`/`Folder`, `Socket`, `app.settings` | Same classes, edits run through undoable engine commands; match names table | good | `crates/script` tests (≈ 10 test files) | +| ScriptUI | Dialogs, palettes, dockable panels, resource strings, `onDraw` / ScriptUIGraphics | Same, dockable panels reopen at launch | good | `ui_scriptui`, `tests_ui*` | +| Scripts menu, ScriptUI Panels folder | yes | yes | same | [plugins.md](plugins.md) | +| Startup / Shutdown folders | yes | not run | missing | plugins.md | +| `#include`, `#target` directives | yes | a script starting with one doesn't parse | missing | plugins.md; common in real scripts | +| `.jsxbin` | yes | no | out of scope | No public specification | +| Keyboard shortcuts for scripts | yes | no | missing | plugins.md | +| Command-line rendering `aerender` | yes | `effectcraft-cli render`, `script`, `exec` | same purpose | `apps/effectcraft-cli` | +| Essential Graphics scripting hooks | `addToMotionGraphicsTemplate…` | yes (M13.7) | good | — | +| Agent control (MCP, control channel, every command) | none | yes | beyond | [agents.md](agents.md), [control-protocol.md](control-protocol.md); `list_effects` omits parameter types and ranges (#441) | +| Real-world script corpus | — | none run | gap | Run popular free scripts (the 11 sample scripts After Effects ships are a good start, run as behaviour tests, never copied) | + +## Estimate + +**≈ 20–40 h**: `#include` / `#target` and Startup / Shutdown folders 4–8 h, script shortcuts 2–4 h, +a corpus of public scripts and expressions with the fixes it finds 10–20 h, an expression +performance benchmark 4–8 h. + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | major | First expressions and scripting checklist | diff --git a/docs/parity.md b/docs/target-app-parity.md similarity index 76% rename from docs/parity.md rename to docs/target-app-parity.md index cdec9986..12d37245 100644 --- a/docs/parity.md +++ b/docs/target-app-parity.md @@ -1,16 +1,280 @@ # Parity with After Effects -How close EffectCraft is to After Effects 2026, feature by feature, and how much work is left. +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (full re-measure against After Effects 2026 26.5; merged `docs/parity.md` into this file; reconciled the stale per-area rows) · **Target:** Adobe After Effects 2026 (26.5.0.89) -> **Read this as a measure of breadth, not of real-world readiness.** It checks whether each -> feature exists, using our own catalogue, graded by the agents that built it. It does not measure -> whether the feature behaves like After Effects (nothing compares the two yet), whether After -> Effects projects open (they don't), or how the app holds up for real users on every platform. -> Those gaps, and the work that closes them, are in [gaps.md](gaps.md). Don't raise the numbers -> here without the evidence gaps.md asks for. The per-area table further down has stale rows that -> have not been reconciled with the headline. +The authoritative assessment of how close EffectCraft is to After Effects, and how much work is +left. [gaps.md](gaps.md) lists every known shortfall one at a time; the +[ROADMAP](../ROADMAP.md) summarises both. Deep dives: [effects-parity.md](effects-parity.md), +[scripting-parity.md](scripting-parity.md), [file-format-parity.md](file-format-parity.md), +[ui-parity.md](ui-parity.md), [hardware-parity.md](hardware-parity.md), +[localization-parity.md](localization-parity.md), and the live measurements in +[fidelity/](fidelity/README.md). -## Current status (audit at commit `d39c0e8`, 4 October 2026; updated 5 October for M3.9–M4.12) +## Headline + +| Number | Value | Kind | +|---|---|---| +| **Feature breadth** (does each After Effects 26.5 feature exist?) | **≈ 91%** | Estimated, weighted by area (table below). The self-graded 92-item checklist says 89 done / 3 partial (≈ 99%), but it was written before After Effects 26.2 and 26.5 and does not count animation presets, project formats or hardware; see the appendix | +| **Ready for real work** (can an After Effects professional do client work here?) | **≈ 45%** (range 40–50%) | Estimated: 60% feature depth (≈ 50%, table below), 15% opening and delivering work files (≈ 35%), 10% performance (≈ 35%), 10% stability (≈ 45%), 5% platforms (≈ 65%) | +| **Stage** | **alpha** | All six core workflows pass the [alpha gate](roadmap.md#alpha-gate) on macOS and ready (≈ 45%) is above the ≈ 40% bar, but `.aep` projects can't be opened, fidelity is measured for one narrow case, and 134 issues are open ([ROADMAP](../ROADMAP.md#stage)) | +| **Mainstream practitioner** (the typical motion designer's weekly work) | **≈ 47%** | Estimated: everyday depth 72% × interaction 0.90 × stability 0.90 × file exchange 0.80 ([below](#mainstream-practitioner)) | +| **Essentials user** (core features only) | **≈ 52%** | Estimated: core depth 77% × launch/stability 0.88 × clarity 0.90 × opening files 0.85 ([below](#essentials-user)) | +| Remaining to **beta** (≈ 75% ready, `.aep` opens) | **≈ 350–620 Opus 5.5 agent-hours** | Estimated | +| Remaining to **full parity** | **≈ 850–1,500 Opus 5.5 agent-hours** | Estimated | + +### Readiness by audience + +| Audience | Ready % | Opus 5.5 agent wall-clock hours to ~95% | Work that dominates | +|---|---:|---:|---| +| Full target (ready for real work) | ≈ 45% | ≈ 850–1,500 h | `.aep` import, a fidelity corpus for every feature, the bug backlog, performance, hardware video, formats, localization, ecosystem | +| Mainstream practitioner | ≈ 47% | ≈ 280–530 h | `.aep` exchange (80–160 h), stability and crash fixes (40–80 h), fidelity of the everyday features (40–70 h), everyday-area bugs and depth (60–110 h), interaction feel (30–60 h), preview speed (25–50 h) | +| Essentials user | ≈ 52% | ≈ 150–300 h | Opening the `.aep` files tutorials ship, basic subset (60–120 h), start-up and stability (20–40 h), core-feature bugs and clarity (30–60 h), preview with sound (20–40 h), fidelity of the core features (20–40 h) | + +Each audience's hours are a subset of the next (essentials ≤ mainstream ≤ full). They are +calibrated like the rest of this file ([calibration](#calibration-of-the-hours)): a behaviour bug +with its regression test ≈ 0.1–0.3 h, a mid-size feature 1–4 h. About 60–70% parallelises across +5–8 agents. The serial parts: the `.aep` owner decision before the importer, and the fidelity +harness before per-feature scores. Every audience needs After Effects running on the owner's Mac +for reference captures. + +The previous headline (5 October: breadth ≈ 99%, real use ≈ 30–50%) moved for these reasons: +breadth went **down** because this pass measured against 26.5 rather than our own catalogue and +found missing features the catalogue never listed (Object Matte, Substance materials, ACES 2.0 +configs, proportional scrubbing, copy frame to clipboard, paste SVG/AI as shapes, percentage +guides, After Effects' 621 animation presets, FBX/USD/STL models, Media Encoder-style hardware +encoding); real use moved **up** to the upper half of the old range because the 5–10 October +work closed 179 issues, most of them user-reported bugs fixed with regression tests, added six partial UI languages +and a first live After Effects fidelity measurement. It is still an estimate: nothing renders a +corpus in both apps yet. + +## How this was measured + +- **Target.** Adobe After Effects 2026, the copy installed on the owner's Mac: + `CFBundleShortVersionString` 26.5.0, build 26.5.0.89. The one live fidelity comparison + ([fidelity/README.md](fidelity/README.md)) used 26.3x87. +- **From the installed bundle (file names and listings only, per [AGENTS.md](../AGENTS.md) §2):** + `Info.plist` `CFBundleDocumentTypes` (26 document types: `.aep`, `.aepx`, `.aet`, `.aetx`, + `.mogrt`, `.mgjson`, `.aecap`, `.aegraphic`, `.ffx`, `.ars`, `.aom`, `.jsx`, `.psd`, `.cin`, + `.mov`, `.pct`, `.pic`…); the `Plug-ins/Format` importers (AIFF, Animate, JPEG, OpenEXR, PNG, + Premiere Pro import, Photoshop layers in and out, Radiance, SGI, SVG, Targa, the standard + multi-format plug-in); `Plug-ins/Effects` (306 `.plugin` bundles in 231 entries); + `Presets` (**621** `.ffx` animation presets); `Scripts` (11 sample scripts, Startup / Shutdown / + ScriptUI Panels folders); `Resources/usd_plugins` (USD, FBX, glTF, OBJ, STL); `Resources/MLModels` + (FastMask, ShotCutDetection) and tokenizer folders (Whisper, BERT, Marian); `.lproj` + localisations (11 languages, [localization-parity.md](localization-parity.md)). + After Effects was not launched for this pass, so there is no menu dump. +- **From Adobe's public release notes** for 26.2 (April 2026: Object Matte, displacement on + parametric meshes, Quick Apply, Proportional Scrubbing), 26.3 (June 2026: Advanced 3D depth of + field, paste SVG/AI as shapes, faster mask tracker, copy frame to clipboard, Curl Noise, + variable-font filter) and 26.5 (September 2026: percentage guides, Object Matte disk cache, + effect colour labels, a rebuilt Effect Controls panel, ACES 2.0 through OpenColorIO 2.5.1). +- **From this repository at `origin/main` 1446e27 (10 October 2026):** 587 menu entries in the + After Effects-shaped menu tree (`crates/engine/src/menus.rs`), 411 registered engine commands, + 306 registered effects (280 on the GPU, [effects.md](effects.md)), 12 export formats + (`OutputFormat::ALL`), the import extension lists (`crates/media/src/lib.rs`), 2,704 tests, ≈ 329,000 + lines of Rust, 6 UI language catalogs, and the 134 open / 179 closed GitHub issues. +- **Presence is not parity.** A feature counts toward *breadth* when it exists and works in the + common case; it counts toward *ready* in proportion to how much of the area a professional + could rely on, discounted for open bugs in it, for being unmeasured against After Effects, and + for being checked on one platform only. + +### Calibration of the hours + +Hours are **Opus 5.5 agent wall-clock hours**: one agent session working sequentially, tests and +verification included. Calibrated against this repository's own history: + +- The whole repository (≈ 329,000 lines including tests, 1,142 commits, 233 merged PRs) was built + between 1 and 10 October 2026, typically by 4–8 agents at once: roughly 700–1,000 agent-hours, + or ≈ 350–450 lines per agent-hour. Getting to today's ≈ 45% took that; the estimate to full + parity is about 1–1.5× it again, because what is left is measurement, compatibility and + long-tail behaviour rather than new code. +- Named arcs (from the audit notes kept below): Classic 3D ≈ 2.7 h, timeline depth (graph editor, + keyframe dialogs, time remapping, pen and masks) ≈ 2.3 h, 85 effects ≈ 1.2 h, the After Effects + menu bar ≈ 1.3 h, layer styles ≈ 0.6 h; a wave the audit priced at 21 h took ≈ 6 agent-hours + but landed at 80–90%. +- Recent PRs (commit-to-merge): seven Timeline/viewer issues (#436) ≈ 0.6 h; eight tweaks from + #290 (#383) ≈ 1 h; image-sequence import (#384) ≈ 4 h; panel i18n infrastructure plus 384 + Chinese strings (#398) ≈ 2–3 h; one 618-row menu language (#401 Ukrainian) ≈ 1.5–2 h; EXR + beauty layer + OCIO v2 configs (#449) ≈ 1.3 h. So a user-reported behaviour bug costs ≈ 0.1–0.3 h, + a mid-size feature 1–4 h. +- What history can't calibrate: fidelity work (each corpus case needs After Effects running, + which only the owner's Mac has), `.aep` import (blocked on an owner decision), and anything + needing hardware we don't have. Those carry the widest ranges. +- **Parallelism:** about 70% of the hours parallelise across 5–8 agents (effects fidelity, + languages, bugs, formats are independent). Serial chains: the G1 harness before per-feature + fidelity scores; the `.aep` decision before `.aep` work. **Needs a human:** the `.aep` + clean-room decision, running After Effects for oracle captures, native-speaker review of every + language, Windows/Linux GPU hardware for #591/#596/#613, and an optional decision on hosting + After Effects SDK plug-ins. + +## By feature area + +Weights are the share of a typical motion designer's working time in After Effects (animation, +compositing and effects dominate; audio is light). Hours are to full parity for the area. + +| Area | Weight | Breadth | Ready | Hours | Biggest gaps (details in [gaps.md](gaps.md)) | +|---|---:|---:|---:|---:|---| +| Project, footage and import | 8% | 92% | 55% | 25–45 | No relative paths or relinking when a project moves; Media Browser lists TGA/DPX/HDR/AVI/MXF it can't import; any image imports as a sequence (#431); MP3 layer goes silent (#324); multi-file drops (#552, #351) | +| Compositions, layers and compositing | 13% | 98% | 60% | 30–55 | 32-bpc blend modes differ from After Effects (#494); mask modes (#527, #510, #513); separated Position edits (#539, #540); expand precomps in place (#601) | +| Animation: keyframes, graph editor, motion paths, puppet | 15% | 97% | 55% | 35–60 | Only scalar ease is measured against After Effects (168 samples); Graph Editor jitter and clipped buttons (#456, #457); bulk keyframing (#355); pen tools on motion paths (#553); puppet lag and jump (#586, #318); Proportional Scrubbing (26.2) | +| Expressions and scripting | 7% | 93% | 65% | 20–40 | `.jsxbin`, Startup/Shutdown folders, `#include`; external editor (#364); see [scripting-parity.md](scripting-parity.md) | +| Shapes, masks and roto | 9% | 93% | 45% | 30–50 | Several open mask bugs (#510, #513, #527); Roto Brush size (#509); Mesh Warp can't be dragged (#303); paste SVG/AI as shape layers (26.3) | +| Text and typography | 7% | 94% | 50% | 20–35 | CJK text in comps renders wrongly (#453); paragraph alignment inverted (#316); squeezed text at 4:3 (#483); font search (#583); soft text at Fit (#417); variable-font filter (26.3) | +| Effects and animation presets | 14% | 85% | 40% | 70–130 | All 306 effects exist, but rendered output is unmeasured against After Effects; 8 built-in presets against After Effects' 621; open effect bugs (#580 crash, #514, #526, #463, #369, #473); effect colour labels (26.5); see [effects-parity.md](effects-parity.md) | +| 3D (Classic and Advanced) | 6% | 82% | 35% | 35–65 | No FBX/USD/STL models, no Substance materials, mesh displacement unverified; no 3D transform gizmos (#392, #347); Z position (#489); reflections (#261); camera aperture (#260); bevel options (#315) | +| Tracking and AI-assisted tools | 5% | 80% | 35% | 35–70 | No Object Matte (26.2); Roto Brush (MobileSAM) and face tracking (MediaPipe) unmeasured against After Effects; camera-tracker view (#505) | +| Preview and cache | 7% | 92% | 40% | 25–50 | Playback stutters on cached frames (#595, #304); slow scrubbing (#329); late audio (#554, #504, #335); no benchmark against After Effects | +| Render queue and export | 7% | 85% | 55% | 25–45 | No hardware encoding; ProRes 4444 colour tag (#340); rendering over an existing file (#325); default macOS output location (#446); uncompressed single-file output (#372); HEVC/AV1 without B-frames | +| Audio | 2% | 85% | 45% | 6–12 | Audio sync and crackle (#335); audio to keyframes depth | +| **Weighted** | 100% | **≈ 91%** | **≈ 50%** | **≈ 360–660** | | + +Feature depth (≈ 50%) feeds the overall *ready* number with the other dimensions below. + +## By dimension + +| Dimension | % | Hours | Evidence | Doc | +|---|---:|---:|---|---| +| Features, breadth | 91% | (in features) | Table above | this file | +| Features, ready for real work | 50% | 360–660 | Table above | this file, [gaps.md](gaps.md) | +| UI/UX fidelity | 65% | 60–120 | After Effects-shaped menus (587 entries), panels, workspaces, shortcuts; many interaction issues still open; no multi-monitor; Effect Controls not yet like 26.5's | [ui-parity.md](ui-parity.md) | +| File formats | 50% | 130–230 | `.aep` / `.aepx` / `.mogrt` / `.ffx` can't be read; strong modern codecs (H.264, HEVC, AV1, VP9, ProRes) | [file-format-parity.md](file-format-parity.md) | +| Hardware | 40% | 60–120 | wgpu GPU on Metal/Vulkan/Direct3D 12/WebGPU with CPU fallback; no hardware decode/encode; Direct3D 12 compositor fails (#613); no video-out hardware | [hardware-parity.md](hardware-parity.md) | +| Localization | 12% | 60–110 + review | Average coverage of After Effects' 10 non-English languages (measured catalog rows over ≈ 3,900 strings): menus 618/618 in 6 languages, panels in 4, effects in none; After Effects ships 11 languages fully | [localization-parity.md](localization-parity.md) | +| Performance | 35% | 50–100 | Internal numbers only; users report stutter and slow scrubbing | [gaps.md](gaps.md#g5-performance-at-real-world-scale) | +| Stability | 45% | 40–80 | Open crash reports #580, #591, #501, #426, #341, #306, #234, #511; no CI runs the tests on pull requests | [gaps.md](gaps.md#g3-stability-and-a-green-main) | +| Platforms | 65% | 30–60 | Releases for macOS, Windows x64/x86/ARM64, Linux, FreeBSD and the web (more than After Effects), but Linux and Windows users hit basic bugs | [gaps.md](gaps.md#g2-real-user-reliability-on-every-platform) | +| Ecosystem and plug-ins | 20% | 40–80 | After Effects scripts and expressions mostly carry over; no After Effects SDK plug-ins, `.ffx` presets or `.mogrt`; our WebAssembly plug-in API has no third-party plug-ins yet | [plugins.md](plugins.md) | +| AI features | 35% | 40–80 (overlaps Tracking) | Roto Brush (MobileSAM) and face tracking (MediaPipe) as optional downloads; no Object Matte; the After Effects bundle ships Whisper, BERT and Marian tokenizers and a shot-cut model we have no equivalent of | [gaps.md](gaps.md) | +| Agent control (beyond After Effects) | ahead | — | Every command is drivable over MCP, the control channel and the CLI | [agents.md](agents.md) | + +**Totals.** Summing the rows double-counts: AI overlaps Tracking, performance overlaps Preview, +UI overlaps every area. With the overlaps removed: **≈ 850–1,500 h to full parity**, of which +**≈ 350–620 h to beta**: `.aep` import (80–160 h, after the owner decision), the G1 fidelity +harness and P0/P1 corpus (60–100 h), the open bug and crash backlog (40–80 h), performance to +real-time preview at 1080p (40–70 h), Linux/Windows reliability (30–50 h), and feature depth in +animation, masks, effects and text (100–160 h). + +## Ready for real work: how the number is built + +The full-target number is a written weighted sum, not a single judgement: + +| Component | Weight | Value | Source | +|---|---:|---:|---| +| Feature depth | 60% | 50% | Weighted feature-area table above | +| Opening and delivering work files | 15% | 35% | [file-format-parity.md](file-format-parity.md): `.aep` / `.mogrt` / `.ffx` 0%, delivery formats strong | +| Performance | 10% | 35% | By dimension, below | +| Stability | 10% | 45% | By dimension, below | +| Platforms | 5% | 65% | By dimension, below | +| **Result** | | **46.2%**, reported as **≈ 45%** (40–50%) | Rounded down to the nearest 5: no live render comparison exists yet | + +Checked on 10 October 2026 when the mainstream and essentials numbers were added: it is unchanged. + +## Mainstream practitioner and essentials user + +Two narrower questions, computed the same way in every craft app (craftrules +`standards/progress-docs.md`, "Numbers"). The stage still follows the full-target number and the +[alpha gate](roadmap.md#alpha-gate). + +### Mainstream practitioner + +**≈ 47%** (estimated). + +*Can the typical professional motion designer do their weekly work here?* Areas are the alpha-gate +workflows and their everyday tools. Left out: third-party plug-ins and `.ffx` preset libraries, +generative/cloud AI (Object Matte, Firefly), Adobe services and team features, specialist hardware +(video I/O, tablets), languages other than English, and areas a typical designer uses rarely +(Advanced 3D models, 3D camera tracking, Roto Brush). Depth is for the everyday subset of each area +(the common effects, the common formats), which is why it sits above the full feature-area table. + +| Area (everyday subset) | Weight | Depth | Evidence | +|---|---:|---:|---| +| Project and import: MOV/MP4, PNG/JPEG sequences, PSD/AI, WAV/MP3 | 10% | 75% | Import tests; #431 still imported as a sequence, #324 MP3 silence | +| Comps, layers, blend modes, mattes, parenting, precomps | 16% | 80% | Compositor tests; #494 32-bpc blend modes, #539/#540 separated Position | +| Keyframes, Graph Editor, easing, motion paths | 20% | 75% | 168 live samples match After Effects; #456/#457, #553, #569 | +| Expressions: `wiggle`, `loopOut`, linking, sliders | 6% | 80% | `crates/expr` tests; no live expression oracle yet | +| Shape layers and masks | 12% | 65% | #510, #513, #527 mask bugs; #303 Mesh Warp | +| Text and text animators | 12% | 70% | #316 alignment inverted, #453 CJK, #583 font search | +| Everyday effects: blurs, glow, fill/tint, curves/levels, keying, drop shadow, displacement | 10% | 70% | All present with GPU paths; unmeasured against After Effects; #514 Apply Color LUT | +| Preview (RAM preview with audio) | 8% | 55% | #595, #304 stutter; #554, #504 late audio | +| Render to H.264 / ProRes / PNG sequences | 6% | 75% | Encoder tests; #446 default macOS output folder, #340 ProRes 4444 tag | +| **Average depth** | 100% | **72.2%** | | + +Discounts for what still stops real work: + +| Discount | Factor | Evidence | +|---|---:|---| +| Interaction fidelity | ×0.90 | UI parity ≈ 65% ([ui-parity.md](ui-parity.md)): Graph Editor jitter, snapping (#352), viewer zoom keys (#570), no multi-monitor (#486) | +| Stability on real machines | ×0.90 | Eight open crash reports (#580, #591, #501, #426, #341, #306, #234, #511); Direct3D 12 compositor fails (#613); no CI on pull requests | +| File exchange with After Effects users | ×0.80 | Can't open `.aep`, `.aepx`, `.mogrt` or `.ffx` sent by colleagues or bought as templates; PSD, AI, footage and rendered deliverables exchange fine | + +72.2% × 0.90 × 0.90 × 0.80 = **46.8%, ≈ 47%**. It sits only just above the full number because +the gap is not in After Effects' long tail: fidelity, stability and `.aep` exchange hit +mainstream users as hard as specialists. + +### Essentials user + +**≈ 52%** (estimated). + +*Can someone who only touches the core features (a social-media animator, a student, a +first-time user) get their work done?* No advanced options, pro workflows or exchange edge cases. + +| Core feature | Weight | Depth | Evidence | +|---|---:|---:|---| +| Launch, new project, undo, save, reopen | 10% | 85% | Versioned project reopen tests, auto-save and crash recovery | +| Import a video, picture or song | 10% | 75% | #431 a picture imported as a sequence, #324 MP3 silence | +| New comp; move, scale, rotate, fade and trim layers | 15% | 80% | `ui_viewer`, Timeline tests | +| Add and style text | 10% | 70% | #316, #583 | +| Draw shapes and simple masks | 10% | 70% | Shape tool tests; #513 mask drawing feedback | +| Keyframes and Easy Ease | 15% | 85% | Ease measured against After Effects | +| Apply a few common effects | 10% | 75% | Effects & Presets search, Effect Controls tests | +| Preview with sound | 10% | 65% | #595 stutter, #554 late audio | +| Export an MP4 | 10% | 80% | H.264 encoder tests; #446 | +| **Average depth** | 100% | **76.75%** | | + +| Discount | Factor | Evidence | +|---|---:|---| +| Launch and stability | ×0.88 | Start-up failures (#501, #234 macOS 27, #596 GPU not detected, #613 Direct3D 12); #426 timeline crash while playing | +| Discoverability and UI clarity | ×0.90 | After Effects' dense layout reproduced faithfully; Home screen and interactive Learn tutorials help; renders fail silently on macOS until an output folder is chosen (#446); layout nitpicks (#581) | +| Opening files people send | ×0.85 | Tutorial projects and templates arrive as `.aep` / `.mogrt`, which don't open | + +76.75% × 0.88 × 0.90 × 0.85 = **51.7%, ≈ 52%**. + +### User evidence (GitHub, 10 October 2026) + +Counted from all 315 issues and their comments, excluding the maintainer account and +agent-generated maintainer replies: + +- **Praise:** 5 people in 5 issues say so outright: "felt almost exactly like After Effects. + Really impressive" (#611), "performance has been excellent" (#326), "Awesome project" (#322), + "Great work" (#293), "great tool and AE alternative" (#290). About 6 more open with "would love…" + in a feature request. +- **"Switched from After Effects" reports:** none found. #468 is a working motion designer's + list of Mac issues, so people are trying it on real work. +- **Open issues (136):** ≈ 95 are core-path bugs (masks, keyframes, timeline, preview, audio, + text, render, crashes; this includes 15 community `fix(...)` proposals), ≈ 40 are feature + requests or niche (plug-in hosting, OpenFX, ffmpeg export, Discord presence, Store listing, + rigging tools). Mostly core-path bugs, which matches the discounts above. + +## Out of scope by design + +Adobe services (Team Projects, Libraries, Stock, Media Encoder, Dynamic Link, Frame.io, +Exchange, Firefly), third-party bundles (Cineware / Cinema 4D, Mocha), and formats without a +public specification or permissive data (`.prproj`, `.jsxbin`, Vanishing Point `.vpe`, the +predefined CJK CMaps in PDF import, Kodak film emulations) are not counted against breadth. +`.aep` / `.aepx` *are* counted: they are the main file format and decide beta. + +--- + +## Appendix: feature-checklist audit history + +Moved here from `docs/parity.md` on 10 October 2026 and kept as evidence. It scores the 92-item +catalogue in `plan/aftereffects/feature-catalog.md`, graded by the agents that built the +features; read it as breadth only. **Its per-area table ("By area", below) was stale** +(Interface 75%, Project 68%, Animation 70%, Paint 0% next to a 99% headline) and is superseded by +the feature-area table above; it is kept for its "biggest gaps" notes, which record what +landed when. + +### Checklist status (audit at commit `d39c0e8`, 4 October 2026; updated 5 October for M3.9–M4.12) | Measure | Value | |---|---| @@ -53,15 +317,15 @@ Out of clean-room scope (no public specification or no permissively licensed dat emulations. Adobe service integrations (Team Projects, Libraries, Media Encoder, Dynamic Link, Frame.io, Exchange) and third-party plug-ins (Cinema 4D, Mocha) are intentionally absent. -### Previous audit (commit `58163a2`, 3 October 2026, evening): ≈ 98% +#### Previous audit (commit `58163a2`, 3 October 2026, evening): ≈ 98% 87 done / 5 partial / 0 missing. -### Previous audit (commit `978e8d7`, 3 October 2026): ≈ 94% +#### Previous audit (commit `978e8d7`, 3 October 2026): ≈ 94% 80 done / 12 partial / 0 missing. -### Previous audit (commit `fa26ad9`, 2 October 2026, late): ≈ 93% +#### Previous audit (commit `fa26ad9`, 2 October 2026, late): ≈ 93% By area: Layers 98%, Output 95%, Compositions 95%, Automation 96%, Paint 95%, Text 95%, Import 96%, Animation 94%, Masks 94%, Preview 94%, Interface 93%, Shapes 93%, 3D 91%, Audio 90%, Project 90%, @@ -80,7 +344,7 @@ thread counts — landed in M13.1 (GPU particles landed in M12.7). Render Queue The sections below are the original audit (morning of 2 October, ≈ 64%) and its updates, kept for history. -## Summary +### Summary | Measure | Value | |---|---| @@ -107,7 +371,7 @@ Work in progress at the time of the audit, not yet counted: motion tracking, pre keyboard shortcut editor, auto-save and crash recovery, paint and puppet tools, Lottie import and export. -## Update after the second wave (same day, commit `e1f7241`) +### Update after the second wave (same day, commit `e1f7241`) Motion tracking (1-, 2- and 4-point, stabilize, corner pin), paint (Brush, Clone Stamp, Eraser with write-on) and the puppet tools, preferences, a keyboard shortcut editor that rebinds, @@ -129,7 +393,7 @@ hours** at the pace observed so far and **≈ 35–40 hours** if the audit's fig hardest remaining systems (Roto Brush, Warp Stabilizer, the 3D camera tracker, Advanced 3D, GPU rendering, on-canvas text editing) are where the conservative figure is most likely to be right. -## Update at the end of 2 October 2026 +### Update at the end of 2 October 2026 A third wave landed: viewer interactions (snapping, rulers, channels, exposure, snapshots, region of interest, shape pen and pen-tool family, motion-path handles, graph editor transform box), @@ -155,7 +419,7 @@ M6.7; native macOS menus, the Composition Flowchart, Timeline column/search dept and every After Effects workspace in the UI-polish wave; PSD/SVG import, WebM/WAV/AIFF output and the disk cache in the formats wave; the 3D Camera Tracker in M12.6.) -## By area +### By area (stale; superseded on 2026-10-10 by the feature-area table at the top) | Area | Weighted parity | Remaining (agent-hours) | Biggest gaps | |---|---|---|---| @@ -178,7 +442,7 @@ the disk cache in the formats wave; the 3D Camera Tracker in M12.6.) | Tracking | ≈ 96% | 0.3 | face tracking's trained model (MediaPipe Face Landmarker, M13.36) is an optional download; without it the classical (skin model + feature components + shape model) fitter is weak on profile views and occluded faces; Rolling Shutter Ripple is approximated by Subspace Warp's mesh density (face tracking (Outline Only / Detailed Features with Face Track Points and Extract & Copy Face Measurements), Subspace Warp's content-preserving mesh warp on subspace-smoothed trajectories, and radial lens distortion (k1, k2) in the camera bundle adjustment with Undistort Footage landed in M13.3; Rolling Shutter Repair in M9.11; point tracker, mask tracking, Warp Stabilizer and the 3D Camera Tracker in M6.x / M12.5 / M12.6) | | Paint | 0% | 4.0 | Brush, Clone Stamp, Eraser | -## Effects still missing +### Effects still missing The full catalogue with GPU / 32-bpc badges and per-effect status is [effects.md](effects.md) (generated from the registry). @@ -186,7 +450,7 @@ The full catalogue with GPU / 32-bpc badges and per-effect status is [effects.md None: the 3D Camera Tracker landed in M12.6. Boris FX Mocha and Cineware are third-party and not counted. -### Added in M9.11 (39 effects) +#### Added in M9.11 (39 effects) | Category | Effects | Notes | |---|---|---| @@ -201,7 +465,7 @@ Mocha and Cineware are third-party and not counted. | Utility | Color Profile Converter | Our colour spaces and ACES; rendering intents (perceptual gamut compression, relative / absolute colorimetric, saturation). | | Matte | Mocha shape | Mocha's export format is not public: reads a documented JSON shape format instead. | -## Update: M13.5 long-tail polish +### Update: M13.5 long-tail polish Stroke Taper / Wave / multi-segment dashes and the radial gradient highlight (SHP-2); variable-width mask feather points (MSK-1; mask motion blur verified by tests); the Adaptive @@ -217,7 +481,7 @@ Reference Axes; Animate Text ▸ Variable Font Axes. Remaining in these rows: Lo taper/wave (the Variable Font Axes animator changes advances since M13.12, the Character panel's axes since M13.6; Advanced 3D's DOF has iris shapes since M13.8). -## Update: M13.8 Preview panel, Align panel, Advanced 3D completion +### Update: M13.8 Preview panel, Align panel, Advanced 3D completion - **Preview panel (PRV-1)**: Shortcut popup (Spacebar, Shift+Spacebar, Numpad 0, Shift+Numpad 0, Alt+Numpad 0), each shortcut with its own saved options (settings `preview`): Include @@ -238,7 +502,7 @@ axes since M13.6; Advanced 3D's DOF has iris shapes since M13.8). as real geometry lit by the parent (2D collapsed precomps draw nested 3D layers with the parent's renderer); text and shape strokes extrude as bevelled meshes in paint order. -## Update: M13.22 GPU effects, part A +### Update: M13.22 GPU effects, part A - **GPU ports (EFF-5)**: 30 more effects run on the GPU (196 GPU effects), each matching the CPU oracle within 1/255 (8 bpc) / 1e-3 (32 bpc) on every tested pixel, directly on a buffer @@ -275,7 +539,7 @@ axes since M13.6; Advanced 3D's DOF has iris shapes since M13.8). comps. The GPU walk matches the CPU compositor on the same rasters exactly at 8 and 32 bpc, with only the frame's own readback. -## Update: M13.13 GPU performance and the remaining GPU ports +### Update: M13.13 GPU performance and the remaining GPU ports - **Small comps on the GPU (PRV-3)**: the GPU frame allocated (and zero-initialised) several full-frame RGBA f32 textures per layer; working textures now come from a pool (reused once @@ -303,7 +567,7 @@ axes since M13.6; Advanced 3D's DOF has iris shapes since M13.8). Vertical Distortion render on the CPU (`gpu_supported`), since their Newton inverse wanders chaotically near the crease and f32 settles on other pixels. -## Update: M13.11 Advanced 3D on the GPU, Essential Graphics mirrors, ScriptUI paint +### Update: M13.11 Advanced 3D on the GPU, Essential Graphics mirrors, ScriptUI paint - **Advanced 3D on the GPU (PRV-3, 3D-3)**: a whole Advanced 3D run renders on the device (`gpu::adv3d`, `Renderer::prepare_adv_run`, `Accelerator::render_3d`): every motion-blur @@ -338,7 +602,7 @@ axes since M13.6; Advanced 3D's DOF has iris shapes since M13.8). `drawImage`, image controls and icon buttons; script-host threads are compiled out of the web build (no dead code warnings). -## Update: M13.5 UI completion & fidelity +### Update: M13.5 UI completion & fidelity **Extended Viewer** (Settings ▸ 3D, the viewer's Extended Viewer button, `view.extendedViewer`): custom 3D views (and the Active Camera view with Draft 3D on) render the visible pasteboard @@ -356,7 +620,7 @@ workspace bar order (Default, Review, Learn, Small Screen, Standard) and a singl above the viewer. Not yet: Composition Settings / Render Queue / Settings dialogs compared pixel by pixel (no reference captures of them yet). -## Update: M13.6 panels and content tools +### Update: M13.6 panels and content tools The last placeholder panels became real panels with automation ids: **Lumetri Scopes** (waveform RGB / Luma / YC, vectorscope YUV / HLS, histogram, parade RGB / YUV; Rec. 601 / 709 / @@ -375,7 +639,7 @@ menu entries. Not yet: creating a reference frame by editing a still (After Effe Photoshop), Content-Aware Fill's learned model (ours is PatchMatch + flow propagation), and Media Browser in the web app (landed in M13.10: browser storage and File System Access folders). -## Update: M13.12 import and text leftovers, test hardening +### Update: M13.12 import and text leftovers, test hardening - **PDF / AI**: mesh shadings — free-form and lattice-form Gouraud triangle meshes (types 4, 5), Coons and tensor-product patch meshes (6, 7, tessellated to triangles) — and function-based @@ -401,7 +665,7 @@ Browser in the web app (landed in M13.10: browser storage and File System Access wrong signatures and versions, fuel exhaustion, traps and bit-identical output; script `Socket` validates ports, and ScriptUI resource strings accept trailing array commas. -## Update: text and viewer fidelity (G1) +### Update: text and viewer fidelity (G1) Fixes from measured reports: @@ -420,7 +684,7 @@ Fixes from measured reports: factor) instead of minified bilinearly (#417). Not yet: the viewer resolution and magnification are not remembered between launches. -## Update: plug-ins and extensions as in After Effects; ease presets become a ScriptUI panel +### Update: plug-ins and extensions as in After Effects; ease presets become a ScriptUI panel EffectCraft's core keeps After Effects parity; what After Effects users get from third parties is an extension, on the same extension points After Effects has ([plugins.md](plugins.md) compares @@ -455,7 +719,7 @@ them: scripts, ScriptUI panels, effect plug-ins, with a guide to writing a panel Extended and Snap to Features in Collapsed Compositions and Text Layers (the layers inside collapsed precomp layers snap); the per-feature toggles are gone and every feature snaps. -## Update: M5.9–M5.14 keyframes, M3.15 clipboard, M12.8 responsiveness +### Update: M5.9–M5.14 keyframes, M3.15 clipboard, M12.8 responsiveness - **The app froze every 10 s on Windows** (M12.8): the cache budgets' memory reading started PowerShell on the UI thread and waited about a second for it. It runs on a background thread @@ -585,7 +849,7 @@ rather than the keyed frame (Ctrl/Cmd+click averages 5 × 5 pixels); agents do t `effect.pickColor {effect, param, x, y, average?}` (effect space). Searching "keylight" in Effects & Presets, or `list_effects`, finds it. -## Update: M4.9–M4.11 nested comps and motion blur +### Update: M4.9–M4.11 nested comps and motion blur - **Composition Settings ▸ Advanced ▸ Preserve frame rate when nested or in render queue** and **Preserve resolution when nested** (model, renderer, Render Queue, dialog, `comp.settings`, @@ -601,7 +865,7 @@ Not yet: collapsed precomps don't blur with the precomp layer's own motion or th camera's; animated content inside a layer (shape paths, text animators, nested frames) isn't re-rendered per sub-sample; nested comp markers on the precomp layer bar. -## Update: M4.5–M4.8 cached playback +### Update: M4.5–M4.8 cached playback - **RAM preview keys** carry the render options (Fast Previews Draft / Fast Draft, Realtime Shadows), so a mode switch never shows frames rendered the other way; eviction drops the least @@ -618,7 +882,7 @@ re-rendered per sub-sample; nested comp markers on the precomp layer bar. Not yet: content-keyed RAM frames (an edit drops every comp's RAM frames; the disk cache keeps content keys), audio scrubbing, four snapshot slots. -## Update: M3.14 project files +### Update: M3.14 project files Project files name the version that wrote them (`savedBy`); opening one that a newer EffectCraft saved warns that what this version doesn't know is lost on saving (`file.open` reports @@ -627,7 +891,7 @@ fails the save with a message instead of writing a project that can never be ope on disk is left as it was). Increment and Save and Save a Copy keep an XML project (`.ecprojx`) XML; a file that isn't a project says so by name. -## Update: M3.13 menu bar +### Update: M3.13 menu bar Group Shapes (Ctrl+G) and Ungroup Shapes (Ctrl+Shift+G) moved to the Layer menu itself, where After Effects has them; Window ▸ Learn opens the Home screen's Learn tab; Composition ▸ @@ -637,7 +901,7 @@ Window's shortcut); Layer ▸ Transform ▸ Center In View shows Ctrl+Home; "Rev Viewer (several unlocked Composition viewers at once; View ▸ Split with New Locked Viewer adds the one locked viewer the interface has). -## Update: M3.12 Pre-compose and New Comp from Selection +### Update: M3.12 Pre-compose and New Comp from Selection - **Pre-compose** makes the new composition with the original's settings (pixel aspect, background, motion blur shutter / samples / switch, frame blending switch, 3D renderer); it @@ -649,7 +913,7 @@ the one locked viewer the interface has). (`file.newCompFromSelection {single, dimensionsFrom, duration, addToRenderQueue, sequence, overlap, overlapDuration, transition}`). `layer.addItem {duration}` sets a still's length. -## Update: M3.11 layer commands +### Update: M3.11 layer commands - **Lock**: locked layers can't be selected (a Timeline click, Select All, Ctrl+Up / Down step over them, as do shy layers while hidden) and are left alone by Clear / Delete, Arrange, the @@ -667,7 +931,7 @@ the one locked viewer the interface has). matte when both were copied, else the original in the same composition (paste dropped them always). -## Update: M3.10 Layer Settings and settings dialogs +### Update: M3.10 Layer Settings and settings dialogs Layer ▸ Layer Settings on a solid or adjustment layer opens Solid Settings on the layer's solid (it did nothing before): name, size, Pixel Aspect Ratio, colour and **Affect all layers that use @@ -679,7 +943,7 @@ for every control (`dialog.comp.*`, `dialog.solid.*`), Composition Settings take frame rate besides the list, New Composition starts from the settings of the last composition made with it, and their colour buttons show the stored sRGB colour (they showed it lighter). -## Update: M3.9 unsaved changes +### Update: M3.9 unsaved changes Closing a modified project asks first, as After Effects does: Quit and the window's close button, File ▸ New Project, Open Project, Open Recent, Close Project, the demo project and a Home @@ -690,7 +954,7 @@ the project's path and `*` while it has unsaved changes. The modified mark now f and edits that change nothing record no undo step. Agents' `engine.execute` / MCP calls are never asked; the control channel's `app.quit {force: true}` skips the prompt. -## Update: M13.25 Home template gallery, Simulate Output ▸ My Custom RGB +### Update: M13.25 Home template gallery, Simulate Output ▸ My Custom RGB **Home ▸ Templates** (UI-7; File ▸ New ▸ New Project from Template…): a gallery of eight original built-in project templates authored in code from engine commands — Lower Third, Title @@ -717,7 +981,7 @@ in Settings (`customRgb`) and simulated like the built-in profiles, with Preserv ICC profiles too (`lut8Type`, `lut16Type`, `lutAToBType` / `lutBToAType`, evaluated from the public ICC specification and baked into a 3D LUT for the viewer). -### M13.26: Premiere Pro interop via timeline interchange +#### M13.26: Premiere Pro interop via timeline interchange | Feature | After Effects | EffectCraft | Status | |---|---|---|---| @@ -728,7 +992,7 @@ Not yet: native `.prproj` (no public specification; awaits a decision), media em (not extracted), Premiere effects other than Motion/Opacity/Volume, speed ramps, titles/graphics, and Dynamic Link. -## Update: M13.23 GPU effects, part B (EFF-5) +### Update: M13.23 GPU effects, part B (EFF-5) 40 more effects run on the GPU (236 in all, with M13.22's 30), each checked against the CPU oracle (≤ 1/255 at 8 bpc, ≤ 1e-3 at 32 bpc) directly on a buffer (full and half resolution, adjustment) and @@ -767,7 +1031,7 @@ composited at 8 and 32 bpc: default to the layer). Shatter's wireframe views and Foam's User Defined texture, Environment Map and flow-map preview render on the CPU. -## Update: M13.28 GPU effects, part C (EFF-5) +### Update: M13.28 GPU effects, part C (EFF-5) 37 more effects run on the GPU, each checked against the CPU oracle (≤ 1/255 at 8 bpc, ≤ 1e-3 at 32 bpc) directly on a buffer with masks, another layer and an audio track (full and half @@ -799,7 +1063,7 @@ resolution, adjustment) and composited at 8 and 32 bpc: of the pixels even at low magnification, WGSL has no f64 and Metal's fast math defeats double-f32 emulation. -## Update: M13.29 GPU effects, part D (EFF-5) +### Update: M13.29 GPU effects, part D (EFF-5) 7 more effects run on the GPU (280 in all, with M13.28's 37), and five CPU fallbacks are gone. Each case is checked against the CPU oracle (≤ 1/255 at 8 bpc, ≤ 1e-3 at 32 bpc) directly on a buffer (full @@ -829,7 +1093,7 @@ are pinned bit for bit by golden hashes (`crates/effects/tests/particle_golden.r CPU solves the inverse map from the geometry alone and the GPU samples along it, so the chain stays on the GPU without a readback. -## Update: M13.30 web depth and polish +### Update: M13.30 web depth and polish - **GPU in the browser's job workers** (WEB-1): each job worker opens its own WebGPU device (deferred readbacks) like the frame workers. The Render Queue's export and the analyses' @@ -851,7 +1115,7 @@ are pinned bit for bit by golden hashes (`crates/effects/tests/particle_golden.r (default 256)}`; `templates.create {projectPath?, footageDir?}` extracts it. - **ICC A2B0 profiles** in View ▸ Simulate Output ▸ My Custom RGB (see above). -## Highest-value gaps, in order +### Highest-value gaps, in order 1. ~~On-canvas text editing and per-character styles~~ (landed: M9.9–M9.10). 2. ~~Effect Controls widgets: angle dial, point crosshair, eyedropper, curves and levels editors.~~ (second wave, 2 October) @@ -882,3 +1146,16 @@ missing was interactive tooling, settings stored but not rendered, about 69 disa and the large systems (tracking, puppet, paint, roto). All of those have landed since; one menu entry stays disabled on purpose (Import ▸ Vanishing Point), and what is left is listed at the top of this page and in items 16–18 above. + + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | minor | Readiness-by-audience table: hours to ≈ 95% for each of the three numbers; the full number stays an additive weighted sum (unchanged) | +| 2026-10-10 | minor | Added mainstream practitioner (≈ 47%) and essentials user (≈ 52%) numbers with weights, discounts and GitHub user evidence; wrote down how the full ready number is built (unchanged at ≈ 45%) | +| 2026-10-10 | minor | Stage checked against the core-workflow alpha gate: passes, still alpha | +| 2026-10-10 | major | Full re-measure against After Effects 2026 26.5 (installed bundle, 26.2–26.5 release notes, repository counts); two numbers (breadth ≈ 91%, ready ≈ 45%); feature-area and dimension tables with Opus 5.5 hours; `docs/parity.md` merged in as the appendix and its stale per-area table superseded | +| 2026-10-05 | minor | Checklist updated for M3.9–M4.12 (as `docs/parity.md`) | +| 2026-10-04 | major | Checklist audit at `d39c0e8`: ≈ 99% weighted breadth, 89 / 3 / 0 of 92 | +| 2026-10-02 | major | First audit: ≈ 64% weighted breadth | diff --git a/docs/ui-parity.md b/docs/ui-parity.md new file mode 100644 index 00000000..fd807ebc --- /dev/null +++ b/docs/ui-parity.md @@ -0,0 +1,47 @@ +# UI parity + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first UI parity checklist against After Effects 2026 26.5) · **Target:** Adobe After Effects 2026 + +How closely EffectCraft's interaction matches After Effects: tools, handles, snapping, nudging, +modifier keys, shortcuts, drag behaviour, numeric entry, panels, context menus and feel. Evidence +is the code at `origin/main` 1446e27, the 42 scripted UI test files in `crates/ui-egui/tests` +(real pointer and keyboard events, run headless), [fidelity/workflow-checks.md](fidelity/workflow-checks.md) +and the issue tracker. After Effects was not driven for this pass, so "same as After Effects" means +"matches its documentation and users have not reported a difference". + +**Overall ≈ 65%** (estimated): the shape of the interface is After Effects' (menus, panels, +workspaces, shortcuts), and the scripted tests cover many interactions, but users keep finding +feel and platform differences, and the 26.x interface changes are not in yet. + +| Area | Status | % | Evidence and gaps | +|---|---|---:|---| +| Menu bar | After Effects' menus, order, submenus and labels; native on macOS; keyboard-operable in-window bar | 95% | 587 entries in `crates/engine/src/menus.rs`; Adobe-service entries omitted by design; `ui_menus` tests (#279) | +| Panels and workspaces | Every After Effects panel; all standard workspaces; drag-to-dock, tabs, floating panels, maximise | 75% | `ui_tabs`, `ui_panels`; dropping a panel on a stacked panel doesn't make a tab (#302); stacked-panel headers (#301); saved workspaces don't persist (#320); no panels on a second monitor (#486) | +| Timeline | Switches, modes, parent & link, twirl-downs, reveal shortcuts (U, UU, P, S, R, T, A, E, M, F, Alt+Shift…), pick whips, graph editor, work area, markers | 75% | `ui_keyframes`, `ui_timeline_*`; Graph Editor jitter and hidden buttons (#456, #457); rotation can't go negative by dragging (#569); bulk keyframing (#355); expand precomps in place (#601); Proportional Scrubbing (26.2) missing | +| Composition viewer | Selection, handles with direction-aware cursors, rulers, guides, grids, snapping, channels, exposure, region of interest, snapshots, 3D views, Spacebar hand | 70% | `ui_viewer`, `ui_viewers`; Cmd/Ctrl +/− doesn't zoom (#570); inaccurate snapping (#352); anchor point grab on Linux (#492); no 3D transform gizmos (#392, #347); percentage guides (26.5); copy frame to clipboard (26.3) | +| Tools panel | All After Effects tools; long-press flyouts; Tool Creates Shape / Mask; Fill / Stroke options | 80% | #213, #227 fixed; pen and vertex tools on motion paths (#553) | +| Nudging and modifiers | Arrow nudge (1 px, Shift 10 px), Alt/Option duplicates and slips, Shift constrains | 75% | #290 nudging fixed; Linux multi-layer edits (#491) | +| Numeric entry | Scrubbable values, typed entry with Tab / Shift+Tab, angle revolutions, Escape cancels, non-finite rejected | 85% | `ui_angles`, `ui_inline_edit` (#93, #141, #361); rename arrows on Linux (#488) | +| Effect Controls | After Effects' twirl-downs, angle dial, point crosshair, eyedropper, curves/levels editors, effect menu on right-click | 70% | `ui_effect_controls`, `ui_fx_editors`; 26.5 rebuilt this panel with icon controls and effect colour labels, not matched | +| Context menus | Layer, property, Project, Timeline empty area, work area, labels with colours | 80% | `ui_blank_context_menus`; #227, #290, #407 fixed | +| Keyboard shortcuts | After Effects' defaults; Keyboard Shortcuts editor that rebinds and saves sets | 75% | Shortcut editor in Settings; adding shortcuts fails on Linux (#467); requests for more (#428, #336); no shortcuts for scripts | +| Drag and drop | Project → Timeline / viewer / New Comp / Render Queue; effects onto layers; files from the OS | 65% | `ui_drag_drop`; OS drops fail on Wayland (winit) and some Linux setups (#268); multi-file drops (#552, #351); crash dragging over the import dialog (#511) | +| Text editing on canvas | Point and paragraph text, per-character styles, Character / Paragraph panels, OpenType and variable axes | 70% | `ui_text_edit`; paragraph alignment inverted (#316); font search by typing (#583); toolbar font options (#550); variable-font filter (26.3) | +| Preview feel | Spacebar / Numpad 0 previews, RAM preview, cache bars, adaptive resolution | 55% | `ui_ram_preview`, `ui_live_preview`; stutter (#595, #304), scrubbing lag (#329), Shift+Space default (#551) | +| Home screen, Learn, templates | Home with recent projects, template gallery, interactive Learn tutorials | 70% | Layout nitpicks (#581); Home literals untranslated (#574) | +| Quick Apply (26.2) | Search to apply effects, presets or commands | partial | A command palette exists; not After Effects' Quick Apply | +| Accessibility | AccessKit enabled; focus labels tested | 20% | No screen-reader pass, keyboard-navigation audit or contrast check | +| UI scale | Settings ▸ Appearance ▸ UI Scale 75–200% | beyond | After Effects follows the system's scaling (#284) | + +## Estimate + +**≈ 60–120 h**: the open interaction issues above (25–45 h, about 0.2–0.5 h each with a scripted +test), 3D gizmos 8–15 h, multi-monitor panels 10–20 h, the 26.x interface changes 10–20 h, an +accessibility pass 15–30 h. Checking feel against After Effects needs the owner's Mac with After +Effects running; most of the rest parallelises. + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | major | First UI parity checklist | From c8657bf2146ca8b449f3884624db51fdc9259a23 Mon Sep 17 00:00:00 2001 From: FrederoxDev Date: Sat, 10 Oct 2026 19:26:20 +0100 Subject: [PATCH 035/107] UI: one After Effects-style colour picker with a hex field for every colour input Every colour input opens one picker laid out like After Effects' Color Picker: a square and slider for the channel chosen with H / S / B / R / G / B, the new colour over the original, HSB and RGB fields, a hex field (#RRGGBB, RRGGBB, #RGB) and OK / Cancel. Changes apply live; Cancel, Escape and the original swatch restore the starting colour. The 3D light dialog, Essential Graphics and Layer Styles passed sRGB values to egui's color_edit_button_rgb(a), which reads them as linear, so picked colours were stored darker (#808080 became #373737). They and the other dialogs now use the shared sRGB colour button. The picker and colour helpers live in color_picker.rs; widgets re-exports them. Tests: ui_color_hex. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/color_picker.rs | 411 ++++++++++++++++++ crates/ui-egui/src/header.rs | 32 +- crates/ui-egui/src/i18n/ui.rs | 2 + crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/panels/dialogs_3d.rs | 2 +- crates/ui-egui/src/panels/effect_controls.rs | 2 +- crates/ui-egui/src/panels/essential.rs | 7 +- crates/ui-egui/src/panels/fx_editors.rs | 2 +- .../ui-egui/src/panels/layer_styles_dialog.rs | 6 +- crates/ui-egui/src/panels/paint_panels.rs | 4 +- crates/ui-egui/src/panels/properties.rs | 2 +- crates/ui-egui/src/panels/settings.rs | 16 +- crates/ui-egui/src/panels/text_panels.rs | 2 +- crates/ui-egui/src/panels/timeline.rs | 2 +- crates/ui-egui/src/panels/viewer.rs | 9 +- crates/ui-egui/src/widgets.rs | 13 +- crates/ui-egui/tests/ui_color_hex.rs | 233 ++++++++++ 17 files changed, 678 insertions(+), 68 deletions(-) create mode 100644 crates/ui-egui/src/color_picker.rs create mode 100644 crates/ui-egui/tests/ui_color_hex.rs diff --git a/crates/ui-egui/src/color_picker.rs b/crates/ui-egui/src/color_picker.rs new file mode 100644 index 00000000..cd24dbc0 --- /dev/null +++ b/crates/ui-egui/src/color_picker.rs @@ -0,0 +1,411 @@ +//! The colour picker every colour input opens, laid out like After Effects' Color Picker: a +//! square and a slider for the channel chosen with the H / S / B / R / G / B buttons, the new +//! colour over the original, the HSB and RGB values, the hex code, and OK / Cancel. +//! +//! Colours are sRGB components (0–1), as the project stores them. Changes apply as they are made +//! (After Effects' Preview); Cancel or Escape goes back to the colour the picker opened with. + +use egui::{Color32, Rect, Response, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; + +use crate::i18n::tr; +use crate::widgets::{open_popup, popup_is_open, pressed_outside, select_all}; + +/// A colour button for egui-laid-out rows and dialogs: a swatch that opens [`color_popup`]. Don't +/// use egui's `color_edit_button_rgb(a)` for project colours: they read linear values, so the +/// swatch shows lighter and a picked colour is stored darker. The value is only rewritten when the +/// user picks a colour. +pub fn srgb_color_button(ui: &mut Ui, c: &mut [f32; 3]) -> Response { + let (rect, mut resp) = ui.allocate_exact_size(ui.spacing().interact_size, Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::ColorButton, ui.is_enabled(), srgb_hex(*c))); + let stroke = if resp.hovered() { ui.visuals().widgets.hovered.fg_stroke } else { ui.visuals().widgets.inactive.bg_stroke }; + ui.painter().rect_filled(rect, 2.0, srgb32(*c)); + ui.painter().rect_stroke(rect, 2.0, stroke, StrokeKind::Inside); + if resp.clicked() { + open_popup(ui, resp.id); + } + if color_popup(ui, resp.id, rect.left_bottom(), c) { + resp.mark_changed(); + } + resp +} + +/// The colour picker as a popup at `pos`, bound to `c`, opened with [`open_popup`]. Returns true +/// when the colour changed (Cancel, Escape and the original swatch change it back). +pub fn color_popup(ui: &mut Ui, id: egui::Id, pos: egui::Pos2, c: &mut [f32; 3]) -> bool { + if !popup_is_open(ui, id) { + return false; + } + let orig_id = id.with("original"); + let original = ui.data_mut(|d| *d.get_temp_mut_or_insert_with(orig_id, || *c)); + let mut out = Outcome::default(); + let area = egui::Area::new(id.with("area")).order(egui::Order::Foreground).fixed_pos(pos + vec2(0.0, 4.0)).show(ui.ctx(), |ui| { + egui::Frame::popup(ui.style()).show(ui, |ui| out = color_picker(ui, id, c, original)); + }); + if ui.input(|i| i.key_pressed(egui::Key::Escape)) { + out.close = Some(false); + } + if pressed_outside(ui.ctx(), &area.response) { + out.close.get_or_insert(true); + } + if let Some(keep) = out.close { + if !keep && *c != original { + *c = original; + out.changed = true; + } + ui.data_mut(|d| { + d.insert_temp(id.with("open"), false); + d.remove::<[f32; 3]>(orig_id); + }); + } + out.changed +} + +/// What the picker did this frame. +#[derive(Default)] +struct Outcome { + changed: bool, + /// Close the picker, keeping the colour (OK) or not (Cancel). + close: Option, +} + +/// The channel the slider sets; the square sets the other two of its model. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Chan { + H, + S, + V, + R, + G, + B, +} + +impl Chan { + /// The square's (across, up) channels while `self` is on the slider (After Effects' layout). + fn square(self) -> (Chan, Chan) { + match self { + Chan::H => (Chan::S, Chan::V), + Chan::S => (Chan::H, Chan::V), + Chan::V => (Chan::H, Chan::S), + Chan::R => (Chan::B, Chan::G), + Chan::G => (Chan::B, Chan::R), + Chan::B => (Chan::R, Chan::G), + } + } + + fn index(self) -> usize { + match self { + Chan::H | Chan::R => 0, + Chan::S | Chan::G => 1, + Chan::V | Chan::B => 2, + } + } + + fn is_hsv(self) -> bool { + matches!(self, Chan::H | Chan::S | Chan::V) + } +} + +/// The colour as both HSB and RGB: HSB keeps the hue (and saturation) of greys and black, where +/// RGB has none, so moving off black returns to the hue that was chosen. +#[derive(Clone, Copy, Debug, PartialEq)] +struct Pick { + hsv: [f32; 3], + rgb: [f32; 3], +} + +impl Pick { + fn get(&self, ch: Chan) -> f32 { + let v = if ch.is_hsv() { self.hsv } else { self.rgb }; + v.get(ch.index()).copied().unwrap_or(0.0) + } + + fn set(mut self, ch: Chan, v: f32) -> Pick { + let v = v.clamp(0.0, 1.0); + if ch.is_hsv() { + if let Some(x) = self.hsv.get_mut(ch.index()) { + *x = v; + } + self.rgb = hsv_to_rgb(self.hsv); + } else { + if let Some(x) = self.rgb.get_mut(ch.index()) { + *x = v; + } + self.hsv = keep_hue(rgb_to_hsv(self.rgb), self.hsv); + } + self + } + + /// The colour at (`u` across, `v` up) on the square, or `t` up the slider. + fn at(&self, ch: Chan, u: f32, v: f32) -> Pick { + let (x, y) = ch.square(); + self.set(x, u).set(y, v) + } +} + +const SQUARE: f32 = 200.0; +const SLIDER_W: f32 = 16.0; + +fn color_picker(ui: &mut Ui, id: egui::Id, c: &mut [f32; 3], original: [f32; 3]) -> Outcome { + let state_id = id.with("pick"); + let mut pick = match ui.data(|d| d.get_temp::<([f32; 3], [f32; 3])>(state_id)) { + Some((hsv, rgb)) if rgb == *c => Pick { hsv, rgb }, + Some((hsv, _)) => Pick { hsv: keep_hue(rgb_to_hsv(*c), hsv), rgb: *c }, + None => Pick { hsv: rgb_to_hsv(*c), rgb: *c }, + }; + // The slider's channel is remembered between pickers, as After Effects does. + let chan_id = egui::Id::new("color-picker-channel"); + let mut chan = ui.data(|d| d.get_temp::(chan_id)).and_then(|i| CHANS.get(i).map(|c| c.0)).unwrap_or(Chan::H); + let before = pick; + let mut out = Outcome::default(); + + ui.horizontal_top(|ui| { + ui.spacing_mut().item_spacing.x = 10.0; + // The square and slider, with OK and Cancel under the square's left edge. + ui.vertical(|ui| { + ui.horizontal_top(|ui| { + square(ui, chan, &mut pick); + slider(ui, chan, &mut pick); + }); + ui.add_space(8.0); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 6.0; + if ui.add_sized(vec2(72.0, 20.0), egui::Button::new(tr("OK"))).clicked() { + out.close = Some(true); + } + if ui.add_sized(vec2(72.0, 20.0), egui::Button::new(tr("Cancel"))).clicked() { + out.close = Some(false); + } + }); + }); + ui.vertical(|ui| { + ui.spacing_mut().item_spacing.y = 4.0; + if swatches(ui, pick.rgb, original) { + pick = Pick { hsv: keep_hue(rgb_to_hsv(original), pick.hsv), rgb: original }; + } + ui.add_space(4.0); + let mut field = |ui: &mut Ui, ch: Chan, name: &str, scale: f32, suffix: &str| { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 2.0; + let radio = ui.radio(chan == ch, format!("{name}:")); + // Line the fields up whatever each label's width. + ui.add_space((40.0 - radio.rect.width()).max(0.0)); + if radio.clicked() { + chan = ch; + } + // Rounded as the hex code rounds, and 360° shows as 0°. + let mut v = (pick.get(ch) * scale).round(); + if ch == Chan::H && v >= 360.0 { + v = 0.0; + } + let drag = egui::DragValue::new(&mut v).range(0.0..=scale).fixed_decimals(0).suffix(suffix).speed(scale / 255.0); + if ui.add_sized(vec2(56.0, 18.0), drag).labelled_by(radio.id).changed() { + pick = pick.set(ch, v.round() / scale); + } + }); + }; + field(ui, Chan::H, "H", 360.0, "°"); + field(ui, Chan::S, "S", 100.0, "%"); + field(ui, Chan::V, "B", 100.0, "%"); + ui.add_space(4.0); + field(ui, Chan::R, "R", 255.0, ""); + field(ui, Chan::G, "G", 255.0, ""); + field(ui, Chan::B, "B", 255.0, ""); + ui.add_space(4.0); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 4.0; + let mut rgb = pick.rgb; + if hex_field(ui, id.with("hex"), &mut rgb) { + pick = Pick { hsv: keep_hue(rgb_to_hsv(rgb), pick.hsv), rgb }; + } + }); + }); + }); + + if let Some(i) = CHANS.iter().position(|(c, _)| *c == chan) { + ui.data_mut(|d| d.insert_temp(chan_id, i)); + } + if pick != before && pick.rgb != *c { + *c = pick.rgb; + out.changed = true; + } + ui.data_mut(|d| d.insert_temp(state_id, (pick.hsv, pick.rgb))); + out +} + +/// The channels in the order of their buttons. +const CHANS: [(Chan, &str); 6] = [(Chan::H, "H"), (Chan::S, "S"), (Chan::V, "B"), (Chan::R, "R"), (Chan::G, "G"), (Chan::B, "B")]; + +/// Drag or click on the square to set its two channels. +fn square(ui: &mut Ui, chan: Chan, pick: &mut Pick) { + let (rect, resp) = ui.allocate_exact_size(vec2(SQUARE, SQUARE), Sense::click_and_drag()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Slider, true, "Color field")); + let (x, y) = chan.square(); + if let Some(p) = resp.interact_pointer_pos().filter(|_| resp.is_pointer_button_down_on()) { + *pick = pick.at(chan, (p.x - rect.min.x) / rect.width(), 1.0 - (p.y - rect.min.y) / rect.height()); + } + // Hue changes unevenly across a row, so the HSB squares get a finer grid. + let n: u32 = if chan.is_hsv() { 24 } else { 8 }; + let row = n + 1; + let mut mesh = egui::Mesh::default(); + for yi in 0..=n { + for xi in 0..=n { + let (u, v) = (xi as f32 / n as f32, yi as f32 / n as f32); + mesh.colored_vertex(rect.lerp_inside(vec2(u, v)), srgb32(pick.at(chan, u, 1.0 - v).rgb)); + if xi < n && yi < n { + let k = yi * row + xi; + mesh.add_triangle(k, k + 1, k + row); + mesh.add_triangle(k + 1, k + row + 1, k + row); + } + } + } + ui.painter().add(mesh); + let at = rect.lerp_inside(vec2(pick.get(x), 1.0 - pick.get(y))); + let light = pick.rgb[0] * 0.3 + pick.rgb[1] * 0.59 + pick.rgb[2] * 0.11 > 0.6; + ui.painter().circle_stroke(at, 5.0, Stroke::new(1.5, if light { Color32::BLACK } else { Color32::WHITE })); +} + +/// Drag or click on the slider to set `chan`; arrows on both sides mark its value. +fn slider(ui: &mut Ui, chan: Chan, pick: &mut Pick) { + let (outer, resp) = ui.allocate_exact_size(vec2(SLIDER_W + 12.0, SQUARE), Sense::click_and_drag()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Slider, true, "Color slider")); + let bar = Rect::from_center_size(outer.center(), vec2(SLIDER_W, SQUARE)); + if let Some(p) = resp.interact_pointer_pos().filter(|_| resp.is_pointer_button_down_on()) { + *pick = pick.set(chan, 1.0 - (p.y - bar.min.y) / bar.height()); + } + let n: u32 = if chan == Chan::H { 36 } else { 16 }; + let mut mesh = egui::Mesh::default(); + for i in 0..=n { + let t = i as f32 / n as f32; + // The hue slider shows pure hues; the others vary their channel in the current colour. + let col = if chan == Chan::H { hsv_to_rgb([1.0 - t, 1.0, 1.0]) } else { pick.set(chan, 1.0 - t).rgb }; + let y = bar.min.y + t * bar.height(); + mesh.colored_vertex(pos2(bar.min.x, y), srgb32(col)); + mesh.colored_vertex(pos2(bar.max.x, y), srgb32(col)); + if i < n { + let k = 2 * i; + mesh.add_triangle(k, k + 1, k + 2); + mesh.add_triangle(k + 1, k + 3, k + 2); + } + } + ui.painter().add(mesh); + let y = bar.min.y + (1.0 - pick.get(chan)) * bar.height(); + let fg = ui.visuals().strong_text_color(); + let stroke = Stroke::new(1.2, fg); + for (edge, dir) in [(outer.min.x + 1.0, 1.0), (outer.max.x - 1.0, -1.0)] { + let tip = pos2(edge + dir * 4.0, y); + ui.painter().line_segment([pos2(edge, y - 4.0), tip], stroke); + ui.painter().line_segment([pos2(edge, y + 4.0), tip], stroke); + } +} + +/// The new colour over the original; returns true when the original is clicked (to go back to it). +fn swatches(ui: &mut Ui, new: [f32; 3], original: [f32; 3]) -> bool { + let (rect, _) = ui.allocate_exact_size(vec2(64.0, 64.0), Sense::hover()); + let (top, bottom) = rect.split_top_bottom_at_fraction(0.5); + let new_resp = ui.interact(top, ui.id().with("new-swatch"), Sense::hover()); + new_resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Other, true, "New color")); + let old = ui.interact(bottom, ui.id().with("original-swatch"), Sense::click()).on_hover_text(tr("Original color: click to go back to it")); + old.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "Original color")); + ui.painter().rect_filled(top, 0.0, srgb32(new)); + ui.painter().rect_filled(bottom, 0.0, srgb32(original)); + ui.painter().rect_stroke(rect, 0.0, ui.visuals().widgets.inactive.bg_stroke, StrokeKind::Outside); + old.clicked() +} + +/// `hsv` with `old`'s hue where `hsv` has none (a grey or black), and `old`'s saturation where it +/// has neither (black). +fn keep_hue(mut hsv: [f32; 3], old: [f32; 3]) -> [f32; 3] { + if hsv[1] == 0.0 || hsv[2] == 0.0 { + hsv[0] = old[0]; + } + if hsv[2] == 0.0 { + hsv[1] = old[1]; + } + hsv +} + +/// The hex field. While it has focus it keeps what was typed (`id` holds the text); otherwise it +/// shows the colour. Focusing it selects the code, so typing replaces it; a valid code applies as +/// it is typed. +fn hex_field(ui: &mut Ui, id: egui::Id, c: &mut [f32; 3]) -> bool { + let mut text = ui.data(|d| d.get_temp::(id)).unwrap_or_else(|| srgb_hex(*c).trim_start_matches('#').to_string()); + let mut changed = false; + let label = ui.label("#"); + let resp = ui + .add(egui::TextEdit::singleline(&mut text).id(id.with("edit")).desired_width(62.0).char_limit(7).font(egui::TextStyle::Monospace)) + .labelled_by(label.id); + if resp.gained_focus() { + select_all(ui.ctx(), resp.id, &text); + } + if resp.changed() + && let Some(rgb) = hex_rgb(&text) + { + let v = rgb.map(|b| b as f32 / 255.0); + if v != *c { + *c = v; + changed = true; + } + } + if resp.has_focus() { + ui.data_mut(|d| d.insert_temp(id, text.clone())); + } else { + ui.data_mut(|d| d.remove::(id)); + } + changed +} + +/// An sRGB colour (components 0–1) as an egui colour. +pub fn srgb32(c: [f32; 3]) -> Color32 { + let b = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; + Color32::from_rgb(b(c[0]), b(c[1]), b(c[2])) +} + +/// An sRGB colour (components 0–1) as `#RRGGBB`. +pub fn srgb_hex(c: [f32; 3]) -> String { + let col = srgb32(c); + format!("#{:02X}{:02X}{:02X}", col.r(), col.g(), col.b()) +} + +/// Parse a hex colour: `#RRGGBB`, `RRGGBB`, `#RGB` or `RGB` (any case, surrounding space ignored). +pub fn hex_rgb(s: &str) -> Option<[u8; 3]> { + let h = s.trim().trim_start_matches('#'); + if !h.is_ascii() { + return None; + } + let digit = |i: usize| h.get(i..i + 1).and_then(|d| u8::from_str_radix(d, 16).ok()); + let pair = |i: usize| h.get(i..i + 2).and_then(|d| u8::from_str_radix(d, 16).ok()); + match h.len() { + 3 => Some([digit(0)? * 17, digit(1)? * 17, digit(2)? * 17]), + 6 => Some([pair(0)?, pair(2)?, pair(4)?]), + _ => None, + } +} + +/// sRGB (components 0–1) to hue, saturation and value (0–1). +pub fn rgb_to_hsv([r, g, b]: [f32; 3]) -> [f32; 3] { + let (r, g, b) = (r.clamp(0.0, 1.0), g.clamp(0.0, 1.0), b.clamp(0.0, 1.0)); + let max = r.max(g).max(b); + let d = max - r.min(g).min(b); + if d <= 0.0 { + return [0.0, 0.0, max]; + } + let h = if max == r { + ((g - b) / d).rem_euclid(6.0) + } else if max == g { + (b - r) / d + 2.0 + } else { + (r - g) / d + 4.0 + }; + [h / 6.0, d / max, max] +} + +/// Hue, saturation and value (0–1) to sRGB (components 0–1). +pub fn hsv_to_rgb([h, s, v]: [f32; 3]) -> [f32; 3] { + let h = h.clamp(0.0, 1.0) * 6.0; + let (s, v) = (s.clamp(0.0, 1.0), v.clamp(0.0, 1.0)); + let f = |n: f32| { + let k = (n + h) % 6.0; + v - v * s * k.min(4.0 - k).clamp(0.0, 1.0) + }; + [f(5.0), f(3.0), f(1.0)] +} diff --git a/crates/ui-egui/src/header.rs b/crates/ui-egui/src/header.rs index 1b93658f..779459fb 100644 --- a/crates/ui-egui/src/header.rs +++ b/crates/ui-egui/src/header.rs @@ -364,7 +364,7 @@ fn fill_stroke_options(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Pa } } let mut c = tp.color; - if color_popup(ui, picker_id(key), sr.left_bottom(), &mut c) { + if widgets::color_popup(ui, picker_id(key), sr.left_bottom(), &mut c) { tool_options(app, json!({key: c})); paint(app, key, json!(c)); } @@ -445,12 +445,6 @@ fn paint_options_popup(app: &mut EffectcraftApp, ui: &mut egui::Ui, key: &str, t } } -/// sRGB components (0–1) as a colour. -fn srgb32(c: [f32; 3]) -> Color32 { - let b = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; - Color32::from_rgb(b(c[0]), b(c[1]), b(c[2])) -} - /// A Fill or Stroke swatch: its colour, a gradient (white to black), or None's red slash. fn paint_swatch(p: &egui::Painter, r: Rect, paint: &ToolPaint, hovered: bool, t: &Tokens) { let clip = p.with_clip_rect(r); @@ -460,7 +454,7 @@ fn paint_swatch(p: &egui::Painter, r: Rect, paint: &ToolPaint, hovered: bool, t: clip.line_segment([r.left_bottom(), r.right_top()], Stroke::new(1.5, Color32::from_rgb(0xe0, 0x30, 0x30))); } PaintKind::Solid => { - clip.rect_filled(r, 2.0, srgb32(paint.color)); + clip.rect_filled(r, 2.0, widgets::srgb32(paint.color)); } PaintKind::Linear => { let mut m = egui::Mesh::default(); @@ -555,28 +549,6 @@ fn ws_menu(app: &mut EffectcraftApp, ui: &mut egui::Ui) { } } -/// A small colour picker popup bound to an RGB value. -pub fn color_popup(ui: &mut egui::Ui, id: egui::Id, pos: egui::Pos2, c: &mut [f32; 3]) -> bool { - let open_id = id.with("open"); - if !ui.data(|d| d.get_temp::(open_id).unwrap_or(false)) { - return false; - } - let mut changed = false; - let area = egui::Area::new(id.with("area")).order(egui::Order::Foreground).fixed_pos(pos + vec2(0.0, 4.0)).show(ui.ctx(), |ui| { - egui::Frame::popup(ui.style()).show(ui, |ui| { - let mut col = srgb32(*c); - if egui::color_picker::color_picker_color32(ui, &mut col, egui::color_picker::Alpha::Opaque) { - *c = [col.r() as f32 / 255.0, col.g() as f32 / 255.0, col.b() as f32 / 255.0]; - changed = true; - } - }); - }); - if widgets::pressed_outside(ui.ctx(), &area.response) || ui.input(|i| i.key_pressed(egui::Key::Escape)) { - ui.data_mut(|d| d.insert_temp(open_id, false)); - } - changed -} - /// The ArtCraft mark as supplied in `docs/brand` (the README's and getartcraft.com's logo; used /// unmodified, see `docs/brand/LICENSE-brand.txt`). static ARTCRAFT_MARK: &[u8] = include_bytes!("../../../docs/brand/artcraft-mark.png"); diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index fdcaa27e..387bfdd6 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -1923,9 +1923,11 @@ mod tests { /// The interface text that has moved into this catalog. Add a panel here when you convert it; /// the tests below then keep it translated. const CONVERTED: &[(&str, &str)] = &[ + ("color_picker.rs", include_str!("../color_picker.rs")), ("credits.rs", include_str!("../credits.rs")), ("header.rs", include_str!("../header.rs")), ("menus.rs", include_str!("../menus.rs")), + ("widgets.rs", include_str!("../widgets.rs")), ("panels/anim_tools.rs", include_str!("../panels/anim_tools.rs")), ("panels/camera_tracker_ui.rs", include_str!("../panels/camera_tracker_ui.rs")), ("panels/comp_settings.rs", include_str!("../panels/comp_settings.rs")), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index dd0efc0c..a64aca56 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -10,6 +10,7 @@ pub mod audio; pub mod automation; pub mod bench; +pub mod color_picker; pub mod control; pub mod credits; pub mod dock; diff --git a/crates/ui-egui/src/panels/dialogs_3d.rs b/crates/ui-egui/src/panels/dialogs_3d.rs index 485e2e8b..4e3c8007 100644 --- a/crates/ui-egui/src/panels/dialogs_3d.rs +++ b/crates/ui-egui/src/panels/dialogs_3d.rs @@ -268,7 +268,7 @@ pub fn light(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { }); ui.end_row(); ui.label(tr("Color")); - ui.color_edit_button_rgb(&mut d.color); + crate::widgets::srgb_color_button(ui, &mut d.color); ui.end_row(); ui.label(tr("Intensity")); ui.add(egui::DragValue::new(&mut d.intensity).range(-10000.0..=10000.0).suffix(" %")); diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index 94d8e5a4..adab0247 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -353,7 +353,7 @@ fn prop_row( } app.auto.add(&format!("effectControls.prop.{uid}.eyedropper"), er, &prop.name); let mut rgb = [c[0] as f32, c[1] as f32, c[2] as f32]; - if crate::header::color_popup(ui, pop, sr.left_bottom(), &mut rgb) { + if crate::widgets::color_popup(ui, pop, sr.left_bottom(), &mut rgb) { set(actions, json!([rgb[0], rgb[1], rgb[2], 1.0])); } } diff --git a/crates/ui-egui/src/panels/essential.rs b/crates/ui-egui/src/panels/essential.rs index 26d458d4..c46a5ad0 100644 --- a/crates/ui-egui/src/panels/essential.rs +++ b/crates/ui-egui/src/panels/essential.rs @@ -344,11 +344,10 @@ fn value_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, auto: &str, ty: Opt } ControlType::Color => { let c = v.as_color(); - let mut rgba = egui::Rgba::from_rgba_unmultiplied(c[0], c[1], c[2], c[3]); - let r = egui::widgets::color_picker::color_edit_button_rgba(ui, &mut rgba, egui::widgets::color_picker::Alpha::Opaque); + let mut rgb = [c[0], c[1], c[2]]; + let r = crate::widgets::srgb_color_button(ui, &mut rgb); if r.changed() { - let u = rgba.to_rgba_unmultiplied(); - out = Some(json!([u[0], u[1], u[2], u[3]])); + out = Some(json!([rgb[0], rgb[1], rgb[2], c[3]])); } r.rect } diff --git a/crates/ui-egui/src/panels/fx_editors.rs b/crates/ui-egui/src/panels/fx_editors.rs index 51b146af..a426f5a1 100644 --- a/crates/ui-egui/src/panels/fx_editors.rs +++ b/crates/ui-egui/src/panels/fx_editors.rs @@ -581,7 +581,7 @@ fn colorama_wheel( t.text_dim, ); let mut rgb = [col[0], col[1], col[2]]; - if crate::header::color_popup(ui, pop, sr.left_bottom(), &mut rgb) { + if crate::widgets::color_popup(ui, pop, sr.left_bottom(), &mut rgb) { let mut st = pal.stops.clone(); st[sel].1 = [rgb[0], rgb[1], rgb[2], col[3]]; new_stops = Some(st); diff --git a/crates/ui-egui/src/panels/layer_styles_dialog.rs b/crates/ui-egui/src/panels/layer_styles_dialog.rs index 7738fbea..6a06da8a 100644 --- a/crates/ui-egui/src/panels/layer_styles_dialog.rs +++ b/crates/ui-egui/src/panels/layer_styles_dialog.rs @@ -111,9 +111,9 @@ fn control(ui: &mut egui::Ui, p: &Property, v: &KV) -> Option { (r.changed() && y != *x).then(|| json!(y)) } (KV::Color(c), _) => { - let mut rgba = egui::Rgba::from_rgba_unmultiplied(c[0] as f32, c[1] as f32, c[2] as f32, c[3] as f32); - let changed = egui::color_picker::color_edit_button_rgba(ui, &mut rgba, egui::color_picker::Alpha::Opaque).changed(); - changed.then(|| json!([rgba.r(), rgba.g(), rgba.b()])) + let mut rgb = [c[0] as f32, c[1] as f32, c[2] as f32]; + let changed = crate::widgets::srgb_color_button(ui, &mut rgb).changed(); + changed.then(|| json!(rgb)) } (KV::Gradient(_), _) => { ui.label(egui::RichText::new(tr("Edit in the Timeline")).weak()); diff --git a/crates/ui-egui/src/panels/paint_panels.rs b/crates/ui-egui/src/panels/paint_panels.rs index 62f27055..675bc1e1 100644 --- a/crates/ui-egui/src/panels/paint_panels.rs +++ b/crates/ui-egui/src/panels/paint_panels.rs @@ -102,11 +102,11 @@ pub fn paint(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { app.auto.add("paint.color", fg, "Foreground color"); app.auto.add("paint.background", bg, "Background color"); let mut fc = rgb(o.color); - if crate::header::color_popup(ui, egui::Id::new("paint-fg-pop"), fg.left_bottom(), &mut fc) { + if crate::widgets::color_popup(ui, egui::Id::new("paint-fg-pop"), fg.left_bottom(), &mut fc) { set(app, json!({"color": [fc[0], fc[1], fc[2], 1.0]})); } let mut bc = rgb(o.background); - if crate::header::color_popup(ui, egui::Id::new("paint-bg-pop"), bg.left_bottom(), &mut bc) { + if crate::widgets::color_popup(ui, egui::Id::new("paint-bg-pop"), bg.left_bottom(), &mut bc) { set(app, json!({"background": [bc[0], bc[1], bc[2], 1.0]})); } let swap = Rect::from_min_size(pos2(x0 + 46.0, y), vec2(18.0, 14.0)); diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 9e78ffbc..48385f68 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -401,7 +401,7 @@ fn text_section( } app.auto.add(&format!("properties.text.{key}"), sw, label); let mut rgb = [c[0], c[1], c[2]]; - if crate::header::color_popup(ui, pop, sw.left_bottom(), &mut rgb) { + if crate::widgets::color_popup(ui, pop, sw.left_bottom(), &mut rgb) { set(actions, json!({key: [rgb[0], rgb[1], rgb[2]], "merge": format!("props-text-{key}")})); } p.text(pos2(sw.max.x + 8.0, sw.center().y), Align2::LEFT_CENTER, label, Tokens::ui(12.0), t.text); diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index 6a84d761..99218517 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -38,6 +38,12 @@ fn hex(c: [u8; 3]) -> String { format!("#{:02x}{:02x}{:02x}", c[0], c[1], c[2]) } +/// sRGB components (0–1) as bytes. +fn srgb_bytes(c: [f32; 3]) -> [u8; 3] { + let col = crate::widgets::srgb32(c); + [col.r(), col.g(), col.b()] +} + fn reg(app: &mut EffectcraftApp, id: &str, r: &egui::Response, label: &str) { app.auto.add(id, r.rect, label); } @@ -178,8 +184,9 @@ fn page_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, page: &Page, cur: &Value } Kind::Color => { let c = crate::panels::viewer::hex_rgb(v.as_str().unwrap_or("")).unwrap_or([128, 128, 128]); - let mut rgb = c; - let r = ui.color_edit_button_srgb(&mut rgb); + let mut f = c.map(|v| v as f32 / 255.0); + let r = crate::widgets::srgb_color_button(ui, &mut f); + let rgb = if r.changed() { srgb_bytes(f) } else { c }; ui.label(RichText::new(hex(rgb)).monospace().color(t.text_dim)); if rgb != c { acts.push(Act::Set(key.into(), json!(hex(rgb)))); @@ -559,8 +566,9 @@ fn labels_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, cur: &Value, acts: &mu egui::Grid::new("settings-labels").num_columns(4).spacing([10.0, 4.0]).show(ui, |ui| { for (i, l) in labels.iter().enumerate() { let c = crate::panels::viewer::hex_rgb(l["color"].as_str().unwrap_or("")).unwrap_or([128, 128, 128]); - let mut rgb = c; - let r = ui.color_edit_button_srgb(&mut rgb); + let mut f = c.map(|v| v as f32 / 255.0); + let r = crate::widgets::srgb_color_button(ui, &mut f); + let rgb = if r.changed() { srgb_bytes(f) } else { c }; app.auto.add(&format!("settings.labels.{i}.color"), r.rect, "label colour"); if rgb != c { acts.push(Act::Set(format!("labels.{i}.color"), json!(hex(rgb)))); diff --git a/crates/ui-egui/src/panels/text_panels.rs b/crates/ui-egui/src/panels/text_panels.rs index 8e24a083..ba352de4 100644 --- a/crates/ui-egui/src/panels/text_panels.rs +++ b/crates/ui-egui/src/panels/text_panels.rs @@ -202,7 +202,7 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } app.auto.add(&format!("character.{key}"), fs, label); let mut rgb = [c[0], c[1], c[2]]; - if crate::header::color_popup(ui, pop, fs.left_bottom(), &mut rgb) { + if crate::widgets::color_popup(ui, pop, fs.left_bottom(), &mut rgb) { actions.push(json!({key: [rgb[0], rgb[1], rgb[2]], "merge": format!("char-{key}")})); } p.text(pos2(fs.max.x + 6.0, y + 10.0), Align2::LEFT_CENTER, label, Tokens::ui(11.5), t.text_dim); diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 7df3439b..3b078d20 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -3558,7 +3558,7 @@ fn value_editor( widgets::open_popup(ui, pop); } let mut rgb = [c[0] as f32, c[1] as f32, c[2] as f32]; - if crate::header::color_popup(ui, pop, r.left_bottom(), &mut rgb) { + if crate::widgets::color_popup(ui, pop, r.left_bottom(), &mut rgb) { set(actions, json!([rgb[0], rgb[1], rgb[2], 1.0])); } } diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index 9adf007f..2af1fc74 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -2147,14 +2147,7 @@ pub(crate) fn minify_factor(smooth: bool, pixels: f64, screen: f64) -> usize { if smooth && k >= 1.0 { k.min(256.0) as usize } else { 1 } } -pub(crate) fn hex_rgb(s: &str) -> Option<[u8; 3]> { - let h = s.trim().trim_start_matches('#'); - if h.len() != 6 || !h.is_ascii() { - return None; - } - let p = |i: usize| u8::from_str_radix(&h[i..i + 2], 16).ok(); - Some([p(0)?, p(2)?, p(4)?]) -} +pub(crate) use crate::widgets::hex_rgb; /// The Extended Viewer's render region for this frame (see [`ViewerState::extended`]): /// Settings ▸ 3D ▸ Extended Viewer on, the comp has 3D layers and the view is a custom 3D view diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 8f12e52b..b1f5a661 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -182,17 +182,8 @@ pub fn swatch(ui: &mut Ui, rect: Rect, c: [f32; 4], id: egui::Id, t: &Tokens) -> resp } -/// A colour button with egui's picker for a colour stored as sRGB components (0–1), as the -/// project stores colours (`color_edit_button_rgb` reads linear values and shows them lighter). -/// The value is only rewritten when the user picks a colour. -pub fn srgb_color_button(ui: &mut Ui, c: &mut [f32; 3]) -> Response { - let mut u = c.map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8); - let r = ui.color_edit_button_srgb(&mut u); - if r.changed() { - *c = u.map(|v| v as f32 / 255.0); - } - r -} +// The colour picker and its helpers (every colour input uses them). +pub use crate::color_picker::{color_popup, hex_rgb, hsv_to_rgb, rgb_to_hsv, srgb_color_button, srgb_hex, srgb32}; /// A twirl-down triangle; returns clicked. pub fn twirl(ui: &mut Ui, rect: Rect, open: bool, id: egui::Id, t: &Tokens) -> Response { diff --git a/crates/ui-egui/tests/ui_color_hex.rs b/crates/ui-egui/tests/ui_color_hex.rs new file mode 100644 index 00000000..90eb19b8 --- /dev/null +++ b/crates/ui-egui/tests/ui_color_hex.rs @@ -0,0 +1,233 @@ +//! The colour picker every colour input opens: its hex field sets an effect's colour (CC Toner), +//! the dialogs' colour button stores sRGB (egui's own `color_edit_button_rgba` read the stored +//! sRGB values as linear, so mid-grey `#808080` was stored as `#373737`), the square, slider and +//! HSB / RGB fields set the colour, and Cancel / Escape / the original swatch restore it. + +use effectcraft_engine::Session; +use effectcraft_engine::project::LayerId; +use effectcraft_ui_egui::EffectcraftApp; +use effectcraft_ui_egui::dock::PanelKind; +use effectcraft_ui_egui::widgets; +use egui::accesskit::Role; +use egui::{Event, Key, Modifiers, Pos2, pos2, vec2}; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::json; + +const GREY: f32 = 128.0 / 255.0; + +fn click(h: &mut Harness<'_, S>, p: Pos2) { + h.input_mut().events.push(Event::PointerMoved(p)); + h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); +} + +/// Click the open popup's Hex field and type `text` over the code it shows. +fn type_hex(h: &mut Harness<'_, S>, text: &str) { + let at = h.get_by_role_and_label(Role::TextInput, "#").rect().center(); + click(h, at); + for pressed in [true, false] { + h.input_mut().events.push(Event::Key { key: Key::A, physical_key: None, pressed, repeat: false, modifiers: Modifiers::COMMAND }); + } + h.step(); + h.input_mut().events.push(Event::Text(text.into())); + h.run_steps(3); +} + +fn close(c: [f32; 3], want: [f32; 3]) -> bool { + c.iter().zip(want).all(|(a, b)| (a - b).abs() < 1e-4) +} + +#[test] +fn hex_code_sets_an_effect_colour() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Hex", "width": 320, "height": 180, "duration": 4})).unwrap(); + let layer = s.execute("layer.newSolid", json!({"name": "Plate", "color": "#40b080"})).unwrap()["layer"].as_u64().unwrap(); + let fx = s.execute("effect.apply", json!({"layer": layer, "effect": "CC Toner"})).unwrap()["effects"][0].as_u64().unwrap(); + let uid = s.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.find_group(fx).unwrap().get("midtones").unwrap().uid; + s.state.selected_layers = vec![LayerId(layer)]; + let mut app = EffectcraftApp::new(s); + app.show_panel(PanelKind::EffectControls); + app.toggle_maximize(PanelKind::EffectControls); + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| app); + h.run_steps(4); + let midtones = |h: &Harness<'_, EffectcraftApp>| { + let c = h.state().session.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.find(uid).unwrap().value.as_color(); + [c[0], c[1], c[2]] + }; + + let [x, y, w, ht] = h.state().auto.find(&format!("effectControls.prop.{uid}.value")).expect("the Midtones swatch").rect; + click(&mut h, pos2(x + w / 2.0, y + ht / 2.0)); + type_hex(&mut h, "#FF8000"); + assert!(close(midtones(&h), [1.0, GREY, 0.0]), "{:?}", midtones(&h)); + // A partial code doesn't apply; the short form does. + type_hex(&mut h, "#12"); + assert!(close(midtones(&h), [1.0, GREY, 0.0]), "{:?}", midtones(&h)); + type_hex(&mut h, "0f8"); + assert!(close(midtones(&h), [0.0, 1.0, 136.0 / 255.0]), "{:?}", midtones(&h)); +} + +/// A colour button in a harness of its own, its picker open. +fn picker(c: [f32; 3]) -> Harness<'static, [f32; 3]> { + let mut h = Harness::builder().with_size(vec2(560.0, 400.0)).build_ui_state( + |ui, c: &mut [f32; 3]| { + widgets::srgb_color_button(ui, c); + }, + c, + ); + h.run_steps(2); + let button = h.get_by_role(Role::ColorWell).rect().center(); + click(&mut h, button); + h +} + +fn press(h: &mut Harness<'_, [f32; 3]>, key: Key, modifiers: Modifiers) { + for pressed in [true, false] { + h.input_mut().events.push(Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }); + } + h.run_steps(2); +} + +fn click_label(h: &mut Harness<'_, [f32; 3]>, label: &str) { + let at = h.get_by_label(label).rect().center(); + click(h, at); +} + +/// Type `text` into the first number field labelled `label` ("R:", "H:" …; "B:" is Brightness +/// before Blue, as in After Effects) and press Enter. +fn type_field(h: &mut Harness<'_, [f32; 3]>, label: &str, text: &str) { + let at = h.get_all_by_role_and_label(Role::SpinButton, label).next().expect("the field").rect().center(); + click(h, at); + press(h, Key::A, Modifiers::COMMAND); + h.input_mut().events.push(Event::Text(text.into())); + h.step(); + press(h, Key::Enter, Modifiers::NONE); +} + +fn near(c: [f32; 3], want: [f32; 3]) -> bool { + c.iter().zip(want).all(|(a, b)| (a - b).abs() < 0.02) +} + +fn is_open(h: &Harness<'_, [f32; 3]>) -> bool { + h.query_by_role_and_label(Role::TextInput, "#").is_some() +} + +#[test] +fn the_dialog_colour_button_stores_srgb() { + let mut h = picker([1.0, 1.0, 1.0]); + type_hex(&mut h, "808080"); + assert!(close(*h.state(), [GREY; 3]), "{:?}", h.state()); + click_label(&mut h, "OK"); + assert!(!is_open(&h)); + assert!(close(*h.state(), [GREY; 3])); +} + +/// Changes apply as they are made; Cancel, Escape and the original swatch go back to the colour +/// the picker opened with, OK keeps the new one. +#[test] +fn cancel_escape_and_the_original_swatch_restore_the_colour() { + let red = [1.0, 0.0, 0.0]; + let mut h = picker(red); + type_hex(&mut h, "00FF00"); + assert!(close(*h.state(), [0.0, 1.0, 0.0])); + click_label(&mut h, "Cancel"); + assert!(!is_open(&h)); + assert!(close(*h.state(), red), "{:?}", h.state()); + + let button = h.get_by_role(Role::ColorWell).rect().center(); + click(&mut h, button); + type_hex(&mut h, "0000FF"); + press(&mut h, Key::Escape, Modifiers::NONE); + assert!(!is_open(&h)); + assert!(close(*h.state(), red), "{:?}", h.state()); + + click(&mut h, button); + type_hex(&mut h, "0000FF"); + click_label(&mut h, "Original color"); + assert!(is_open(&h), "going back to the original keeps the picker open"); + assert!(close(*h.state(), red), "{:?}", h.state()); + type_hex(&mut h, "FFFF00"); + click_label(&mut h, "OK"); + assert!(close(*h.state(), [1.0, 1.0, 0.0]), "{:?}", h.state()); +} + +/// With H on the slider the square is saturation × brightness; the hue is kept while the colour is +/// black, where RGB has none. HSB and RGB values can be typed. +#[test] +fn the_hsb_square_slider_and_fields_set_the_colour() { + let mut h = picker([1.0, 0.0, 0.0]); + let square = h.get_by_label("Color field").rect(); + let slider = h.get_by_label("Color slider").rect(); + + click(&mut h, pos2(square.center().x, square.max.y - 1.0)); + assert!(near(*h.state(), [0.0; 3]), "the bottom edge is black: {:?}", h.state()); + click(&mut h, slider.center()); + assert!(near(*h.state(), [0.0; 3]), "a new hue leaves black black: {:?}", h.state()); + click(&mut h, pos2(square.max.x - 1.0, square.min.y + 1.0)); + assert!(near(*h.state(), [0.0, 1.0, 1.0]), "the hue chosen while black is kept: {:?}", h.state()); + + type_field(&mut h, "R:", "64"); + assert!((h.state()[0] - 64.0 / 255.0).abs() < 1e-4 && near(*h.state(), [64.0 / 255.0, 1.0, 1.0]), "{:?}", h.state()); + type_field(&mut h, "H:", "0"); + type_field(&mut h, "S:", "100"); + type_field(&mut h, "B:", "50"); + assert!(near(*h.state(), [0.5, 0.0, 0.0]), "{:?}", h.state()); +} + +/// With R chosen the slider sets red and the square blue (across) and green (up), as in After +/// Effects; the choice is remembered by the next picker. +#[test] +fn choosing_r_puts_red_on_the_slider_and_blue_green_on_the_square() { + let mut h = picker([0.0, 0.0, 0.0]); + let radio = h.get_by_role_and_label(Role::RadioButton, "R:").rect().center(); + click(&mut h, radio); + let square = h.get_by_label("Color field").rect(); + let slider = h.get_by_label("Color slider").rect(); + click(&mut h, pos2(slider.center().x, slider.min.y + 1.0)); + assert!(near(*h.state(), [1.0, 0.0, 0.0]), "the top of the slider is full red: {:?}", h.state()); + click(&mut h, pos2(square.max.x - 1.0, square.max.y - 1.0)); + assert!(near(*h.state(), [1.0, 0.0, 1.0]), "bottom right adds full blue: {:?}", h.state()); + click(&mut h, pos2(square.min.x + 1.0, square.min.y + 1.0)); + assert!(near(*h.state(), [1.0, 1.0, 0.0]), "top left is full green, no blue: {:?}", h.state()); + click_label(&mut h, "OK"); + + let button = h.get_by_role(Role::ColorWell).rect().center(); + click(&mut h, button); + let slider = h.get_by_label("Color slider").rect(); + click(&mut h, pos2(slider.center().x, slider.max.y - 1.0)); + assert!(near(*h.state(), [0.0, 1.0, 0.0]), "the slider still sets red: {:?}", h.state()); +} + +#[test] +fn hex_codes_parse() { + assert_eq!(widgets::hex_rgb("#ff8000"), Some([255, 128, 0])); + assert_eq!(widgets::hex_rgb(" FF8000 "), Some([255, 128, 0])); + assert_eq!(widgets::hex_rgb("#0f8"), Some([0, 255, 136])); + for bad in ["", "#", "#12", "#12345", "#1234567", "#gg0000", "#ffé000"] { + assert_eq!(widgets::hex_rgb(bad), None, "{bad}"); + } + assert_eq!(widgets::srgb_hex([1.0, GREY, 0.0]), "#FF8000"); +} + +#[test] +fn hsv_round_trips() { + for c in [[1.0, 0.0, 0.0], [0.0, 1.0, 1.0], [GREY, 0.25, 0.75], [0.2, 0.2, 0.2], [0.0, 0.0, 0.0], [1.0, 1.0, 1.0]] { + let back = widgets::hsv_to_rgb(widgets::rgb_to_hsv(c)); + assert!(close(back, c), "{c:?} -> {back:?}"); + } + assert!(close(widgets::hsv_to_rgb([0.5, 1.0, 1.0]), [0.0, 1.0, 1.0])); +} + +/// The fields round as the hex code does (0.7 × 255 = 178.5 shows as 179, `B3`), and a hue just +/// under 360° shows as 0°. +#[test] +fn the_fields_agree_with_the_hex_code() { + let h = picker([1.0, 0.699, 0.7]); + let value = |label: &str, nth: usize| h.get_all_by_role_and_label(Role::SpinButton, label).nth(nth).and_then(|n| n.value()).unwrap_or_default(); + assert_eq!(h.get_by_role_and_label(Role::TextInput, "#").value().as_deref(), Some("FFB2B3")); + assert_eq!(value("G:", 0), "178"); + assert_eq!(value("B:", 1), "179"); + assert_eq!(value("H:", 0), "0°"); +} From 4de11be339effc75edc2bbd2996488640e748557 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 18:50:32 +0000 Subject: [PATCH 036/107] Time: in Frames style a typed number is a frame count (#610 follow-up) parse_timecode reads bare digits as timecode pairs, so 00100 went to frame 30 (1 s) instead of frame 100. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f --- crates/engine/src/commands/time.rs | 11 ++++++++--- crates/engine/src/tests_timeline.rs | 17 ++++++++++++----- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/commands/time.rs b/crates/engine/src/commands/time.rs index 81197602..f98ea5ed 100644 --- a/crates/engine/src/commands/time.rs +++ b/crates/engine/src/commands/time.rs @@ -43,12 +43,17 @@ pub fn parse_display_time(s: &Session, comp: &Comp, text: &str, current: i64) -> let relative = text.trim().starts_with('+') || text.trim().starts_with('-'); if relative { Ok(parsed) + } else if st.time_display == TimeDisplayStyle::Frames + && let Ok(frame) = text.trim().parse::() + { + // Displayed digits are a plain frame count (00100 is frame 100, not 1 s) plus + // Start Numbering Frames At. + Ok(frame.saturating_sub(st.frame_start)) } else if st.time_display == TimeDisplayStyle::Frames { - // Displayed digits are composition frame plus Start Numbering Frames At. - Ok(parsed - st.frame_start) + Ok(parsed.saturating_sub(st.frame_start)) } else { // Displayed timecode includes the composition Start Timecode offset. - Ok(parsed - fr.frame_at(comp.display_start)) + Ok(parsed.saturating_sub(fr.frame_at(comp.display_start))) } } } diff --git a/crates/engine/src/tests_timeline.rs b/crates/engine/src/tests_timeline.rs index 88f7b9bd..a741f5ad 100644 --- a/crates/engine/src/tests_timeline.rs +++ b/crates/engine/src/tests_timeline.rs @@ -413,11 +413,7 @@ fn time_set_snaps_to_the_nearest_frame_and_ae_timecode() { #[test] fn time_set_keeps_the_frame_shown_in_the_current_time_field() { let mut s = Session::default(); - s.execute( - "comp.new", - json!({"name": "Example", "width": 64, "height": 64, "frameRate": 30, "duration": 0.6, "startTimecode": "0:00:00:05"}), - ) - .unwrap(); + s.execute("comp.new", json!({"name": "Example", "width": 64, "height": 64, "frameRate": 30, "duration": 0.6, "startTimecode": "0:00:00:05"})).unwrap(); let r = s.execute("time.set", json!({"frame": 0})).unwrap(); assert_eq!(r["display"], "0:00:00:05"); let r = s.execute("time.set", json!({"timecode": "0:00:00:05"})).unwrap(); @@ -433,6 +429,17 @@ fn time_set_keeps_the_frame_shown_in_the_current_time_field() { assert_eq!(r["frame"], 13); } +/// Frames display style: typed digits are a frame count, so 00100 is frame 100, not 1 s (#530). +#[test] +fn time_set_reads_frames_style_digits_as_a_frame_count() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Long", "width": 64, "height": 64, "frameRate": 30, "duration": 10})).unwrap(); + s.execute("file.projectSettings", json!({"timeDisplay": "frames"})).unwrap(); + let r = s.execute("time.set", json!({"timecode": "00100"})).unwrap(); + assert_eq!(r["frame"], 100); + assert_eq!(r["display"], "00100"); +} + #[test] fn separate_dimensions_split_and_rejoin() { let (mut s, l) = setup(); From ff56c5ad24c581fe9e5285a9a53ff923d2f7efd7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 18:50:32 +0000 Subject: [PATCH 037/107] Audio: the short preview buffer is Linux-only and within the device's range (#614 follow-up) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f --- apps/effectcraft/src/audio_out.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/effectcraft/src/audio_out.rs b/apps/effectcraft/src/audio_out.rs index 7c26db51..7d0ec4b2 100644 --- a/apps/effectcraft/src/audio_out.rs +++ b/apps/effectcraft/src/audio_out.rs @@ -152,7 +152,15 @@ impl AudioDevice for CpalOut { }; let mut cfg: cpal::StreamConfig = sup.config(); cfg.sample_rate = cpal::SampleRate(rate); - cfg.buffer_size = cpal::BufferSize::Fixed(preferred_buffer_frames(rate)); + // A short period only where the default one is long (ALSA/PulseAudio on Linux, #554), + // and only within what the device reports it takes. + if cfg!(target_os = "linux") { + let want = preferred_buffer_frames(rate); + cfg.buffer_size = match sup.buffer_size() { + cpal::SupportedBufferSize::Range { min, max } if *min <= *max => cpal::BufferSize::Fixed(want.clamp(*min, *max)), + _ => cpal::BufferSize::Default, + }; + } let build = |cfg: &cpal::StreamConfig| match sup.sample_format() { cpal::SampleFormat::F32 => run::(&dev, cfg, feed.clone(), latency.clone(), map), cpal::SampleFormat::I16 => run::(&dev, cfg, feed.clone(), latency.clone(), map), From 58e91fafbfdf70cb3d17a3f2964b0256d0d1ce53 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 18:50:32 +0000 Subject: [PATCH 038/107] Viewer: the locked viewer shares the main viewer's display key (#619 follow-up) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f --- crates/ui-egui/src/panels/viewer.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index 88995483..dbf45034 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -523,9 +523,7 @@ fn locked_pane(app: &mut EffectcraftApp, ui: &mut egui::Ui, full: Rect, bg: Colo .then(|| app.session.state.views3d.get(&lv.comp).cloned().unwrap_or_default().cam(lv.view, comp.width as f64, comp.height as f64).state()); let dc = effectcraft_engine::viewer::DisplayColor::of(&app.session); // The display conversion's inputs, keyed as in the main viewer's texture caches. - let sim = app.session.state.viewer.simulation; - let custom = if sim.profile == effectcraft_engine::viewer::SimProfile::MyCustom { format!("{:?}", app.session.prefs.custom_rgb) } else { String::new() }; - let dc_key = format!("{cam:?}{}{sim:?}{}{custom}", dc.is_some(), app.session.prefs.previews.display_profile); + let dc_key = format!("{cam:?}{}", super::viewer_tools::display_key(app, dc.is_some())); let key = { use std::hash::{Hash, Hasher}; let mut h = std::collections::hash_map::DefaultHasher::new(); From 605b7c3a25f265778dd27f0be9c3ff7a558f86e2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 18:50:57 +0000 Subject: [PATCH 039/107] Scripting: scripts can't grant themselves file and network access (#590) A running script reached `prefs.set` through `app.run` and could turn on Settings > Scripting & Expressions > Allow Scripts to Write Files and Access Network (or turn off Warn User When Executing Files), which the File, Socket and system.callSystem gates then read: the permission the user withheld was the script's to take. Commands a script issues now go through `Session::execute_for_script`, which marks the session as running for a script for the command and everything it runs (engine.batch, ScriptUI callbacks, scheduled tasks). While it is, `prefs.set` refuses any change that would leave the Application Scripting settings different (compared on the resulting settings, so dotted keys, whole-page objects and batches are all covered), and a script's `prefs.reset` keeps them as the user set them. Command scripts read from a file (File > Scripts > Run Script File... with .json/.jsonl) count as scripts too. The UI, CLI, control channel and MCP still change them. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f --- crates/engine/src/commands/file_more.rs | 5 ++- crates/engine/src/commands/settings.rs | 12 ++++++++ crates/engine/src/lib.rs | 25 +++++++++++++++ crates/engine/src/prefs.rs | 19 ++++++++++++ crates/script/src/runtime.rs | 2 +- crates/script/src/tests.rs | 41 +++++++++++++++++++++++++ 6 files changed, 102 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/commands/file_more.rs b/crates/engine/src/commands/file_more.rs index aa9e1be9..9aecd8d6 100644 --- a/crates/engine/src/commands/file_more.rs +++ b/crates/engine/src/commands/file_more.rs @@ -465,6 +465,8 @@ fn run_script(s: &mut Session, p: &Value) -> Result { } _ => return Err(bad("file.runScript", "missing `path` (or inline `steps`)")), }; + // A command script read from a file is a script: it can't change what scripts may do. + let from_file = p.get("steps").is_none(); let mut results = vec![]; for (i, step) in steps.iter().enumerate() { let (id, params) = match (step.get("command").or(step.get("id")).and_then(Value::as_str), step.get("method").and_then(Value::as_str)) { @@ -478,7 +480,8 @@ fn run_script(s: &mut Session, p: &Value) -> Result { if id == "file.runScript" || id == "script.run" { return Err(bad("file.runScript", "scripts can't run scripts")); } - let r = s.execute(&id, params).map_err(|e| EngineError::Other(format!("step {} ({id}): {e}", i + 1)))?; + let r = if from_file { s.execute_for_script(&id, params) } else { s.execute(&id, params) }; + let r = r.map_err(|e| EngineError::Other(format!("step {} ({id}): {e}", i + 1)))?; results.push(r); } Ok(json!({"steps": results.len(), "results": results})) diff --git a/crates/engine/src/commands/settings.rs b/crates/engine/src/commands/settings.rs index 320a6c18..1a5789e2 100644 --- a/crates/engine/src/commands/settings.rs +++ b/crates/engine/src/commands/settings.rs @@ -9,6 +9,10 @@ use crate::{EngineError, Result, Session, cmd, query}; // ---------------------------------------------------------------- prefs +/// Why a script can't change Application Scripting settings. +const SCRIPT_PERMISSIONS: &str = + "Scripts can't change what scripts are allowed to do. Change it yourself in Settings ▸ Scripting & Expressions ▸ Application Scripting."; + fn prefs_get(s: &mut Session, p: &Value) -> Result { let key = str_p(p, "key").unwrap_or(""); s.prefs.get(key).ok_or_else(|| bad("prefs.get", format!("unknown setting `{key}`"))) @@ -30,6 +34,9 @@ fn prefs_set(s: &mut Session, p: &Value) -> Result { for (k, v) in &pairs { next.set(k, v.clone()).map_err(|e| bad("prefs.set", e))?; } + if s.running_for_script() && !next.scripting.same_permissions(&s.prefs.scripting) { + return Err(EngineError::Other(SCRIPT_PERMISSIONS.into())); + } s.prefs = next; s.prefs_changed(); s.save_prefs(); @@ -39,7 +46,12 @@ fn prefs_set(s: &mut Session, p: &Value) -> Result { fn prefs_reset(s: &mut Session, p: &Value) -> Result { let page = str_p(p, "page"); + let before = s.prefs.scripting.clone(); s.prefs.reset(page).map_err(|e| bad("prefs.reset", e))?; + // A script's reset leaves what scripts may do as the user set it. + if s.running_for_script() { + s.prefs.scripting.keep_permissions(&before); + } s.prefs_changed(); s.save_prefs(); s.toast(match page { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index a9ee6b89..d09cbf2b 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -472,8 +472,16 @@ pub struct Session { pub footage_stamps: std::collections::HashMap, /// The stamps being taken on a worker thread ([`footage_reload::start_scan`]). pub footage_scan: Option, + /// How many script-issued commands are running ([`Session::execute_for_script`]): while one + /// is, settings that guard what scripts may do can't change (see + /// [`prefs::Scripting::same_permissions`]). + pub script_calls: ScriptCalls, } +/// The count behind [`Session::running_for_script`]; only the engine changes it. +#[derive(Clone, Copy, Debug, Default)] +pub struct ScriptCalls(u32); + /// A thumbnail render ([`Session::thumbnail_job`]): (width, height, RGBA8). pub type ThumbnailJob = Box Option<(u32, u32, Vec)> + Send>; @@ -559,6 +567,7 @@ impl Default for Session { check_footage_on_open: false, footage_stamps: Default::default(), footage_scan: None, + script_calls: ScriptCalls::default(), } } } @@ -601,6 +610,22 @@ impl Session { self.execute(id, params) } + /// [`Session::execute`] for a command a script asked for (the JavaScript engine's + /// `app.run`, ScriptUI callbacks…). The command and everything it runs count as the script's: + /// it may not change the settings that limit scripts (Settings ▸ Scripting & Expressions ▸ + /// Application Scripting), which only the user can, from the UI, CLI or control channel. + pub fn execute_for_script(&mut self, id: &str, params: Value) -> Result { + self.script_calls.0 = self.script_calls.0.saturating_add(1); + let r = self.execute(id, params); + self.script_calls.0 = self.script_calls.0.saturating_sub(1); + r + } + + /// Whether the command running was issued by a script ([`Session::execute_for_script`]). + pub fn running_for_script(&self) -> bool { + self.script_calls.0 > 0 + } + pub fn is_enabled(&self, id: &str) -> bool { commands::find(id).is_some_and(|c| (c.enabled)(self).is_ok()) } diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 0bfa04a5..0ed7145e 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -309,6 +309,25 @@ page!(Scripting { error_banner: bool = true, }); +impl Scripting { + /// Whether `other` grants scripts the same as this: Application Scripting (file and network + /// access, the warning before executing files, the debugger). Scripts can't change these + /// ([`crate::Session::execute_for_script`]): a script could otherwise give itself the + /// access the user withheld. + pub fn same_permissions(&self, other: &Scripting) -> bool { + self.allow_scripts_write_files == other.allow_scripts_write_files + && self.warn_executing_files == other.warn_executing_files + && self.enable_js_debugger == other.enable_js_debugger + } + + /// Take `from`'s Application Scripting settings (see [`Scripting::same_permissions`]). + pub fn keep_permissions(&mut self, from: &Scripting) { + self.allow_scripts_write_files = from.allow_scripts_write_files; + self.warn_executing_files = from.warn_executing_files; + self.enable_js_debugger = from.enable_js_debugger; + } +} + /// One editable label: name and `#rrggbb` colour. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct LabelPref { diff --git a/crates/script/src/runtime.rs b/crates/script/src/runtime.rs index 62479c14..712999ee 100644 --- a/crates/script/src/runtime.rs +++ b/crates/script/src/runtime.rs @@ -152,7 +152,7 @@ pub(crate) fn exec(s: &mut Session, id: &str, params: J) -> Result { if CREATES_LAYER.contains(&id) { s.state.selected_layers.clear(); } - let r = s.execute(id, params); + let r = s.execute_for_script(id, params); if let Some(c) = target && prev != Some(c) && s.state.active_comp == Some(c) diff --git a/crates/script/src/tests.rs b/crates/script/src/tests.rs index 946db49d..b50a7bfb 100644 --- a/crates/script/src/tests.rs +++ b/crates/script/src/tests.rs @@ -362,6 +362,47 @@ fn security_gate_for_files() { let _ = std::fs::remove_dir_all(&dir); } +/// #590: a script can't give itself file and network access (or turn off the warning before +/// executing files) by changing Settings ▸ Scripting & Expressions; the user still can. +#[test] +fn scripts_cant_change_what_scripts_may_do() { + let mut s = session(); + let attempts = [ + "app.run('prefs.set', {key: 'scripting.allowScriptsWriteFiles', value: true})", + "app.run('prefs.set', {values: {'scripting.warnExecutingFiles': false}})", + "app.run('prefs.set', {key: 'scripting', value: {allowScriptsWriteFiles: true}})", + "app.run('engine.batch', {steps: [{command: 'prefs.set', params: {key: 'scripting.enableJsDebugger', value: true}}]})", + ]; + for code in attempts { + let o = run_code(&mut s, code, "grant.jsx"); + let e = o.error.unwrap_or_else(|| panic!("`{code}` was allowed")); + assert!(e.message.contains("Scripts can't change what scripts are allowed to do"), "{code}: {e:?}"); + let sc = &s.prefs.scripting; + assert!(!sc.allow_scripts_write_files && sc.warn_executing_files && !sc.enable_js_debugger, "{code}"); + } + assert!(run_code(&mut s, "new Socket()", "net.jsx").error.is_some()); + // Other settings, and the expression editor's, are still the script's to change. + ok(&mut s, "app.run('prefs.set', {values: {'general.undoLevels': 40, 'scripting.editorFontSize': 16}})"); + assert_eq!((s.prefs.general.undo_levels, s.prefs.scripting.editor_font_size), (40, 16)); + // The user grants access; a script's Reset Settings doesn't take it back or change it. + s.execute("prefs.set", json!({"key": "scripting.allowScriptsWriteFiles", "value": true})).unwrap(); + ok(&mut s, "app.run('prefs.reset', {})"); + assert!(s.prefs.scripting.allow_scripts_write_files); + assert_eq!(s.prefs.general.undo_levels, effectcraft_engine::prefs::Prefs::default().general.undo_levels); + // A command script file is a script too. + let dir = std::env::temp_dir().join(format!("ec-script-grant-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let f = dir.join("grant.json"); + std::fs::write(&f, r#"[{"command": "prefs.set", "params": {"key": "scripting.allowScriptsWriteFiles", "value": false}}]"#).unwrap(); + let e = s.execute("file.runScript", json!({"path": f.to_string_lossy()})).unwrap_err().to_string(); + assert!(e.contains("Scripts can't change"), "{e}"); + assert!(s.prefs.scripting.allow_scripts_write_files); + // The user (UI, CLI, control channel) can take it back. + s.execute("prefs.set", json!({"key": "scripting.allowScriptsWriteFiles", "value": false})).unwrap(); + assert!(!s.prefs.scripting.allow_scripts_write_files); + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn engine_entry_points() { let mut s = session(); From 34f2955585bd22f05fdde35c9b6b1f20ebd9193c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 18:51:07 +0000 Subject: [PATCH 040/107] Media: an audio file that fails to decode no longer stays silent for good (#324) With the Conformed Audio folder on (the default), the conform thread wrote whatever `decode_audio` returned, and that returned silence when the source couldn't be opened or a read failed. The conformed file is found by the source's path, size and date, so one failed decode (an MP3 the decoder stumbled on once) kept the footage silent in previews and exports in every later session, while a renamed copy played. The conform path now decodes with `try_decode_audio`, which fails instead of returning silence; a failed conform removes its temporary file, writes nothing and isn't retried this session (reads go to the decoder). Playback reads still fall back to silence. CONFORM_VERSION goes to 3 so the silent files earlier builds wrote are ignored and conformed again. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f --- crates/media/src/pool.rs | 106 +++++++++++++++++++++++++++++---------- 1 file changed, 79 insertions(+), 27 deletions(-) diff --git a/crates/media/src/pool.rs b/crates/media/src/pool.rs index 5ce5730f..db3e19a4 100644 --- a/crates/media/src/pool.rs +++ b/crates/media/src/pool.rs @@ -53,8 +53,10 @@ struct Entry { } /// Conformed audio files written by builds before this version are ignored (and written again): -/// version 1 files of sources longer than about 12:36 at 48 kHz were silent from there on (#172). -const CONFORM_VERSION: u32 = 2; +/// version 1 files of sources longer than about 12:36 at 48 kHz were silent from there on (#172); +/// version 2 files were written silent where the source failed to decode, and kept the footage +/// silent from then on (#324). +const CONFORM_VERSION: u32 = 3; /// Footage longer than this (24 hours) is read from its decoder rather than conformed: its /// duration comes from the file's header, and a damaged one claiming days of audio had the @@ -200,6 +202,10 @@ struct Inner { /// Conformed files being written right now. #[cfg_attr(target_arch = "wasm32", allow(dead_code))] conforming: Mutex>, + /// Conformed files that failed to write (the source didn't decode) this session: their + /// audio is read from the decoder instead of trying again on every read. + #[cfg_attr(target_arch = "wasm32", allow(dead_code))] + conform_failed: Mutex>, } /// Decodes footage for the renderer: stills, image sequences and movies (FilmCraft codecs), plus @@ -266,6 +272,7 @@ impl MediaPool { opened: AtomicU64::new(0), conform: Mutex::default(), conforming: Mutex::default(), + conform_failed: Mutex::default(), }), } } @@ -397,18 +404,23 @@ impl MediaPool { /// thread (once). #[cfg(not(target_arch = "wasm32"))] fn conform(&self, footage: &Footage, file: std::path::PathBuf, frames: usize, rate: u32) { - if !lock(&self.inner.conforming).insert(file.clone()) { + if lock(&self.inner.conform_failed).contains(&file) || !lock(&self.inner.conforming).insert(file.clone()) { return; } let (pool, f) = (self.clone(), footage.clone()); let spawned = std::thread::Builder::new().name("ec-conform-audio".into()).spawn(move || { let tmp = file.with_extension("tmp"); - let ok = file.parent().is_some_and(|d| std::fs::create_dir_all(d).is_ok()) - && pool.write_conformed(&f, &tmp, frames, rate).is_ok() - && std::fs::rename(&tmp, &file).is_ok(); - if !ok { + let written = match file.parent().map(std::fs::create_dir_all) { + Some(Ok(())) => pool.write_conformed(&f, &tmp, frames, rate).and_then(|()| std::fs::rename(&tmp, &file)), + Some(Err(e)) => Err(e), + None => Err(std::io::Error::other("no folder")), + }; + // A failed decode leaves no file: one written silent would keep the footage silent + // in every later session (#324). + if let Err(e) = written { let _ = std::fs::remove_file(&tmp); - log::warn!("media: could not write conformed audio {}", file.display()); + log::warn!("media: could not write conformed audio {} of {}: {e}", file.display(), f.path); + lock(&pool.inner.conform_failed).insert(file.clone()); } lock(&pool.inner.conforming).remove(&file); }); @@ -419,6 +431,7 @@ impl MediaPool { /// Write `frames` sample frames of `footage`'s audio at `rate` to `path` (raw interleaved /// stereo `f32`) a chunk at a time: the whole track was held in memory before it was written. + /// Fails when any chunk doesn't decode. #[cfg(not(target_arch = "wasm32"))] fn write_conformed(&self, footage: &Footage, path: &std::path::Path, frames: usize, rate: u32) -> std::io::Result<()> { use std::io::Write; @@ -426,7 +439,7 @@ impl MediaPool { let mut at = 0usize; while at < frames { let n = (frames - at).min(1 << 16); - for v in self.decode_audio(footage, sample_time(at, rate), n, rate) { + for v in self.try_decode_audio(footage, sample_time(at, rate), n, rate).map_err(std::io::Error::other)? { out.write_all(&v.to_le_bytes())?; } at += n; @@ -470,19 +483,27 @@ impl MediaPool { /// applied the edit list's priming offset twice and ramped the start of every read: AAC /// footage not at the mix rate stuttered in previews and exports (#274). fn decode_audio(&self, footage: &Footage, start: Tick, frames: usize, rate: u32) -> Vec { + self.try_decode_audio(footage, start, frames, rate).unwrap_or_else(|e| { + log::warn!("media: audio of {}: {e}", footage.path); + vec![0.0; frames * 2] + }) + } + + /// [`MediaPool::decode_audio`], failing when the source can't be opened or read. + fn try_decode_audio(&self, footage: &Footage, start: Tick, frames: usize, rate: u32) -> std::result::Result, String> { let path: Arc = footage.path.as_str().into(); - let Some(src) = self.inner.source(&path) else { return vec![0.0; frames * 2] }; + let src = self.inner.source(&path).ok_or("the file can't be opened")?; let s0 = start.to_units_floor(rate as i64); let native = src.info().audio.as_ref().map_or(rate, |a| a.sample_rate); if native == 0 || rate == 0 || native == rate { - return read_stereo(&src, &path, s0, frames, rate); + return read_stereo(&src, s0, frames, rate); } // Output sample `n` sits at source position n × native / rate. let (native_i, rate_i) = (i128::from(native), i128::from(rate)); let first = (i128::from(s0) * native_i).div_euclid(rate_i); let last = ((i128::from(s0) + frames as i128) * native_i).div_euclid(rate_i) + 1; - let (Ok(first64), Ok(count)) = (i64::try_from(first), usize::try_from(last - first + 1)) else { return vec![0.0; frames * 2] }; - let buf = read_stereo(&src, &path, first64, count, native); + let (Ok(first64), Ok(count)) = (i64::try_from(first), usize::try_from(last - first + 1)) else { return Ok(vec![0.0; frames * 2]) }; + let buf = read_stereo(&src, first64, count, native)?; let mut out = Vec::with_capacity(frames * 2); for k in 0..frames as i128 { let pos = (i128::from(s0) + k) * native_i; @@ -494,7 +515,7 @@ impl MediaPool { out.push(a + (b - a) * f); } } - out + Ok(out) } /// A thumbnail of `footage` (its first frame) fitting in `max_side` × `max_side`, aspect kept. @@ -800,27 +821,21 @@ impl FootageSource for MediaPool { /// `frames` stereo sample frames (interleaved) of `src` from sample `s0` at `rate` Hz, which /// FilmCraft serves as decoded when it is the source's own rate. Mono is duplicated to both -/// channels; silence before the start and where decoding fails. -fn read_stereo(src: &SharedSource, path: &str, s0: i64, frames: usize, rate: u32) -> Vec { +/// channels; silence before the start. An error where decoding fails. +fn read_stereo(src: &SharedSource, s0: i64, frames: usize, rate: u32) -> std::result::Result, String> { let mut out = vec![0.0; frames * 2]; let skip = usize::try_from(s0.saturating_neg()).unwrap_or(0); if skip >= frames { - return out; + return Ok(out); } - let buf = match src.audio(s0.max(0), frames - skip, rate) { - Ok(b) => b, - Err(e) => { - log::warn!("media: audio of {path}: {e}"); - return out; - } - }; - let (Some(l), Some(r)) = (buf.channels.first(), buf.channels.get(1).or(buf.channels.first())) else { return out }; - let Some(dst) = skip.checked_mul(2).and_then(|i| out.get_mut(i..)) else { return out }; + let buf = src.audio(s0.max(0), frames - skip, rate).map_err(|e| e.to_string())?; + let (Some(l), Some(r)) = (buf.channels.first(), buf.channels.get(1).or(buf.channels.first())) else { return Ok(out) }; + let Some(dst) = skip.checked_mul(2).and_then(|i| out.get_mut(i..)) else { return Ok(out) }; for ([o0, o1], (a, b)) in dst.as_chunks_mut::<2>().0.iter_mut().zip(l.iter().zip(r)) { *o0 = *a; *o1 = *b; } - out + Ok(out) } fn lock(m: &Mutex) -> std::sync::MutexGuard<'_, T> { @@ -880,6 +895,43 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + /// #324: audio that fails to decode isn't conformed. A conformed file of silence was written + /// instead, and as it is found by the file's path, size and date the footage stayed silent + /// in every later session (a renamed copy played). + #[test] + fn audio_that_fails_to_decode_is_not_conformed() { + let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/test-conform-fail"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("song.mp3"); + std::fs::write(&path, b"ID3, not really").unwrap(); + let footage = Footage { + path: path.to_string_lossy().into(), + kind: FootageKind::Audio, + duration: Tick::from_seconds_f64(2.0), + has_audio: true, + ..Default::default() + }; + let pool = MediaPool::new(); + let conformed = dir.join("conformed"); + pool.set_conform_folder(Some(conformed.clone())); + let file = pool.conformed_path(&footage.path, 48_000).unwrap(); + assert!(pool.audio_samples(&footage, Tick::ZERO, 1024, 48_000).iter().all(|v| *v == 0.0)); + for _ in 0..500 { + if lock(&pool.inner.conforming).is_empty() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + assert!(lock(&pool.inner.conforming).is_empty(), "the conform thread finished"); + assert!(!file.exists() && !file.with_extension("tmp").exists(), "no conformed file was left"); + assert!(lock(&pool.inner.conform_failed).contains(&file)); + // Later reads go to the decoder without trying again. + pool.audio_samples(&footage, Tick::ZERO, 1024, 48_000); + assert!(lock(&pool.inner.conforming).is_empty()); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn frame_index_loops_and_clamps() { let r = FrameRate::FPS_30; From da06c42de117efee111b25d48a7bb783019b5eaa Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 18:51:12 +0000 Subject: [PATCH 041/107] Media: OpenEXR files with HTJ2K compression say it isn't supported (#482) The exr crate reads HTJ2K (OpenEXR 3.4's High-Throughput JPEG 2000) headers but can't decompress the pixels, and the image paths turned that into a misleading "no non-deep rgb channels" (multi-layer files) or imported the file and showed a transparent frame (single-layer files). `exr_channels::unsupported_compression` reads the headers; import (probe) and frame decoding fail with ": OpenEXR HTJ2K compression isn't supported yet: re-save the file with ZIP, PIZ or DWAA compression". Decoding HTJ2K itself needs a pure-Rust JPEG 2000 Part 15 decoder and is left for later. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f --- crates/media/src/exr_channels.rs | 12 ++++++++++++ crates/media/src/pool.rs | 33 ++++++++++++++++++++++++++++++++ crates/media/src/probe.rs | 5 +++++ 3 files changed, 50 insertions(+) diff --git a/crates/media/src/exr_channels.rs b/crates/media/src/exr_channels.rs index 06bda925..732cd3c4 100644 --- a/crates/media/src/exr_channels.rs +++ b/crates/media/src/exr_channels.rs @@ -37,6 +37,18 @@ pub fn parse_manifest(json: &str) -> Vec<(String, u32)> { v } +/// Why an OpenEXR file's pixels can't be decoded when its headers name a compression this build +/// can't decompress (HTJ2K, OpenEXR 3.4's High-Throughput JPEG 2000): the decoders only said +/// "no non-deep rgb channels" or showed a transparent frame (#482). `None` for other files. +pub fn unsupported_compression(bytes: &[u8]) -> Option<&'static str> { + use exr::compression::Compression; + let meta = exr::meta::MetaData::read_from_buffered(std::io::Cursor::new(bytes), false).ok()?; + meta.headers + .iter() + .any(|h| matches!(h.compression, Compression::HTJ2K32 | Compression::HTJ2K256)) + .then_some("OpenEXR HTJ2K compression isn't supported yet: re-save the file with ZIP, PIZ or DWAA compression") +} + /// Read all channels of an EXR file's bytes. `None` when it is not a readable EXR. pub fn read_exr_channels(bytes: &[u8]) -> Option { use exr::prelude::*; diff --git a/crates/media/src/pool.rs b/crates/media/src/pool.rs index db3e19a4..9db2f225 100644 --- a/crates/media/src/pool.rs +++ b/crates/media/src/pool.rs @@ -731,6 +731,13 @@ impl Inner { if let Some(img) = crate::layered::decode(path, &bytes, footage, op)? { return Ok(img); } + // (an OpenEXR compression the decoder lacks came out transparent or as a missing + // channel error) + if path.to_ascii_lowercase().ends_with(".exr") + && let Some(why) = crate::exr_channels::unsupported_compression(&bytes) + { + return Err(MediaError::Decode(format!("{path}: {why}"))); + } let img = match image::load_from_memory(&bytes) { Ok(img) => img, // A multi-layer OpenEXR file without an unnamed RGB layer: its colour layer. @@ -963,6 +970,32 @@ mod tests { assert!(c.take_spare(17).is_empty()); } + /// #482: an OpenEXR file with HTJ2K compression (which the decoder lacks) says so on import + /// and when its frame is read, instead of "no non-deep rgb channels" or a transparent frame. + #[test] + fn htj2k_exr_is_reported_as_unsupported() { + use exr::prelude::*; + let channels = + AnyChannels::sort(vec![AnyChannel::new("R", FlatSamples::F32(vec![0.5; 4])), AnyChannel::new("G", FlatSamples::F32(vec![0.5; 4]))].into()); + let image = exr::image::Image::from_layer(Layer::new((2, 2), LayerAttributes::default(), Encoding::UNCOMPRESSED, channels)); + let mut bytes = std::io::Cursor::new(Vec::new()); + image.write().to_buffered(&mut bytes).unwrap(); + let mut bytes = bytes.into_inner(); + assert_eq!(crate::exr_channels::unsupported_compression(&bytes), None); + // The header's compression attribute (name, type, size 1, value) says HTJ2K32 (11). + let tag = b"compression\0compression\0\x01\0\0\0"; + let at = bytes.windows(tag.len()).position(|w| w == tag).unwrap() + tag.len(); + bytes[at] = 11; + let b: Arc<[u8]> = bytes.into(); + let e = crate::probe_bytes("/shot.exr", b.clone()).unwrap_err().to_string(); + assert!(e.contains("/shot.exr: OpenEXR HTJ2K compression isn't supported yet"), "{e}"); + let pool = MediaPool::new(); + pool.add_bytes("/shot.exr", b); + let f = Footage { path: "/shot.exr".into(), kind: FootageKind::Still, width: 2, height: 2, has_video: true, ..Default::default() }; + let e = pool.frame_at(&f, Tick::ZERO).unwrap_err().to_string(); + assert!(e.contains("HTJ2K compression isn't supported yet"), "{e}"); + } + /// #411: Interpret Footage ▸ Preserve RGB reads a float OpenEXR's values as the file stores /// them (no automatic linear → sRGB encode), so an OCIO effect is the only conversion; the /// default interpretation still encodes. Both interpretations of one file decode separately. diff --git a/crates/media/src/probe.rs b/crates/media/src/probe.rs index 4e986151..6e8718fd 100644 --- a/crates/media/src/probe.rs +++ b/crates/media/src/probe.rs @@ -158,6 +158,11 @@ pub(crate) fn still_footage(path: &str, w: u32, h: u32, fmt: image::ImageFormat, fn probe_still_bytes(path: &str, bytes: &[u8], fmt: image::ImageFormat) -> Result { use image::ImageDecoder; + if fmt == image::ImageFormat::OpenExr + && let Some(why) = crate::exr_channels::unsupported_compression(bytes) + { + return Err(MediaError::Decode(format!("{path}: {why}"))); + } let reader = image::ImageReader::with_format(std::io::Cursor::new(bytes), fmt); let (w, h, has_alpha) = match reader.into_decoder() { Ok(dec) => (dec.dimensions().0, dec.dimensions().1, dec.color_type().has_alpha()), From 72b2e9774e448d85fe8872342cfef53eb96a218a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 18:51:45 +0000 Subject: [PATCH 042/107] Dock: test the empty-dock reopen with the reporter's Minimal workspace (#629 follow-up, test from #630) Co-Authored-By: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f --- crates/ui-egui/src/dock.rs | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 8785aeb2..ba5f5eaa 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -1456,6 +1456,27 @@ mod tests { assert_eq!(PanelKind::from_name("effects & presets"), Some(PanelKind::EffectsPresets)); } + /// Ported from #630 (Ni-zav): the reporter's reproduction on the Minimal workspace. + #[test] + fn window_panels_reopen_after_last_panel_is_closed() { + use PanelKind::*; + for reopen in [Composition, Timeline, Project] { + let mut d = workspace("Minimal"); + d.close(Composition); + d.close(Timeline); + assert_eq!(d.panel_count(), 0); + d.open_near(reopen, EffectsPresets); + assert_eq!(d.panel_count(), 1); + assert!(d.is_visible(reopen), "{reopen:?} must be visible"); + } + let mut d = tabs(&[Audio], 0); + d.open_near(History, Project); + assert_eq!(d, tabs(&[Audio, History], 1)); + let mut d = workspace("Minimal"); + d.open_near(EffectsPresets, Timeline); + assert_eq!(d.path_of(EffectsPresets), d.path_of(Timeline)); + } + #[test] fn closing_earlier_inactive_tab_keeps_the_shown_panel() { use PanelKind::*; From 7a95a0d138c8eafd1156c1ba7aa0e627c5f68f4a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 18:51:45 +0000 Subject: [PATCH 043/107] Project: a single unselected folder or data-file drag still reports why; index math saturates (#634 follow-up) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f --- crates/ui-egui/src/panels/mod.rs | 4 +++- crates/ui-egui/src/panels/timeline.rs | 2 +- crates/ui-egui/src/panels/viewer_drop.rs | 21 ++++++++++----------- crates/ui-egui/tests/ui_drag_drop.rs | 14 ++++++++------ 4 files changed, 22 insertions(+), 19 deletions(-) diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 76691dce..79ef9c2b 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -88,7 +88,9 @@ pub(crate) fn project_drop_items(app: &EffectcraftApp, dragged: u64) -> Vec let candidates: Vec = if selected.iter().any(|id| id.0 == dragged) { selected.iter().map(|id| id.0).collect() } else { - vec![dragged] + // One unselected item goes through as is, so a folder or data file still gets the + // engine's error instead of a silent no-op. + return vec![dragged]; }; candidates .into_iter() diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 32c52b47..f91987c0 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -2964,7 +2964,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { crate::panels::DragPayload::Item(id) => { // Insert each selected item at the drop point, in selection order. for (offset, item) in crate::panels::project_drop_items(app, *id).into_iter().enumerate() { - actions.push(("layer.addItem".into(), json!({"item": item, "index": index + offset, "time": time.clone()}))); + actions.push(("layer.addItem".into(), json!({"item": item, "index": index.saturating_add(offset), "time": time.clone()}))); } } crate::panels::DragPayload::Files(paths) => { diff --git a/crates/ui-egui/src/panels/viewer_drop.rs b/crates/ui-egui/src/panels/viewer_drop.rs index dec8d380..2d42eb2c 100644 --- a/crates/ui-egui/src/panels/viewer_drop.rs +++ b/crates/ui-egui/src/panels/viewer_drop.rs @@ -24,12 +24,15 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &egui::Ui, painter: &egui::Pain let stroke = Stroke::new(2.0, app.tokens.accent); let at = map.to_comp(ptr); let actions: Vec<(&str, Value)> = match payload.as_ref() { - DragPayload::Effect(effect) => layer_at(ectx, at).map(|l| { - if let Some((_, q, _)) = layer_quad(ectx, l) { - painter.add(egui::Shape::closed_line(q.iter().map(|c| map.to_screen(*c)).collect(), stroke)); - } - ("effect.apply", json!({"effect": effect, "layers": [l.id.0]})) - }).into_iter().collect(), + DragPayload::Effect(effect) => layer_at(ectx, at) + .map(|l| { + if let Some((_, q, _)) = layer_quad(ectx, l) { + painter.add(egui::Shape::closed_line(q.iter().map(|c| map.to_screen(*c)).collect(), stroke)); + } + ("effect.apply", json!({"effect": effect, "layers": [l.id.0]})) + }) + .into_iter() + .collect(), DragPayload::Item(item) => { // The layer's frame, centred on the pointer. if let Some([w, h]) = app.session.project.item(ItemId(*item)).and_then(|it| frame_size(&it.kind, ectx.comp.pixel_aspect)) { @@ -37,11 +40,7 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &egui::Ui, painter: &egui::Pain } // New layers go above the current selection, so insert in reverse // to preserve the order of the dragged Project items. - crate::panels::project_drop_items(app, *item) - .into_iter() - .rev() - .map(|item| ("layer.addItem", json!({"item": item, "position": at}))) - .collect() + crate::panels::project_drop_items(app, *item).into_iter().rev().map(|item| ("layer.addItem", json!({"item": item, "position": at}))).collect() } DragPayload::Files(paths) => vec![("mediaBrowser.import", json!({"paths": paths, "addToComp": true, "position": at}))], DragPayload::Property { .. } => vec![], diff --git a/crates/ui-egui/tests/ui_drag_drop.rs b/crates/ui-egui/tests/ui_drag_drop.rs index 56f9ffd7..3dee2727 100644 --- a/crates/ui-egui/tests/ui_drag_drop.rs +++ b/crates/ui-egui/tests/ui_drag_drop.rs @@ -129,10 +129,11 @@ fn project_items_dropped_on_the_viewer_land_under_the_pointer() { fn dragging_selected_project_items_adds_every_item_to_the_timeline() { let (mut h, clip) = harness(); let main = h.state().session.active_comp_id().unwrap().0; - let other = h.state_mut().session.execute("comp.new", json!({"name": "Other", "width": 80, "height": 60, "frameRate": 30, "duration": 1})).unwrap()["comp"].as_u64().unwrap(); + let other = h.state_mut().session.execute("comp.new", json!({"name": "Other", "width": 80, "height": 60, "frameRate": 30, "duration": 1})).unwrap()["comp"] + .as_u64() + .unwrap(); h.state_mut().session.execute("comp.open", json!({"comp": main})).unwrap(); - h.state_mut().session.state.project_selection = - vec![effectcraft_engine::project::ItemId(clip), effectcraft_engine::project::ItemId(other)]; + h.state_mut().session.state.project_selection = vec![effectcraft_engine::project::ItemId(clip), effectcraft_engine::project::ItemId(other)]; h.run_steps(3); let from = rect(&h, &format!("project.item.{clip}.name")).center(); let top = rect(&h, &format!("timeline.layer.{}.row", layer_id(&h, "Top"))); @@ -145,10 +146,11 @@ fn dragging_selected_project_items_adds_every_item_to_the_timeline() { fn dragging_selected_project_items_adds_every_item_to_the_viewer() { let (mut h, clip) = harness(); let main = h.state().session.active_comp_id().unwrap().0; - let other = h.state_mut().session.execute("comp.new", json!({"name": "Other", "width": 80, "height": 60, "frameRate": 30, "duration": 1})).unwrap()["comp"].as_u64().unwrap(); + let other = h.state_mut().session.execute("comp.new", json!({"name": "Other", "width": 80, "height": 60, "frameRate": 30, "duration": 1})).unwrap()["comp"] + .as_u64() + .unwrap(); h.state_mut().session.execute("comp.open", json!({"comp": main})).unwrap(); - h.state_mut().session.state.project_selection = - vec![effectcraft_engine::project::ItemId(clip), effectcraft_engine::project::ItemId(other)]; + h.state_mut().session.state.project_selection = vec![effectcraft_engine::project::ItemId(clip), effectcraft_engine::project::ItemId(other)]; h.run_steps(3); let from = rect(&h, &format!("project.item.{clip}.name")).center(); let to = comp_to_screen(&h.ctx, [80.0, 45.0]).unwrap(); From 7210b37bb2b719384a607e20869f92915e08b90b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 18:51:45 +0000 Subject: [PATCH 044/107] Format effect.list's parameter metadata (#631 follow-up) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f --- crates/engine/src/commands/effect.rs | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/commands/effect.rs b/crates/engine/src/commands/effect.rs index a5561cee..3c597ebd 100644 --- a/crates/engine/src/commands/effect.rs +++ b/crates/engine/src/commands/effect.rs @@ -487,7 +487,12 @@ pub fn specs() -> Vec { cmd!("effect.copy", "Copy Effects", [], None, "{layer?, effect? | effects?: [uid|name|index]} (default: the selected effects)", has_layers, copy), cmd!("effect.paste", "Paste Effects", [], None, "{layers?} — adds the copied effects to the layers", has_layers, paste), cmd!("effect.reset", "Reset Effect", [], None, "{layer?, effect}", has_layers, reset), - crate::query!("effect.list", "List Effects", "{filter?} → [{params: [{id, name, type, default, options?, min?, max?, sliderMin?, sliderMax?, decimals?}]}]; popup indices are zero-based (JSX uses one-based values)", list), + crate::query!( + "effect.list", + "List Effects", + "{filter?} → [{params: [{id, name, type, default, options?, min?, max?, sliderMin?, sliderMax?, decimals?}]}]; popup indices are zero-based (JSX uses one-based values)", + list + ), crate::query!( "effect.warning", "Effect Warning", @@ -524,11 +529,14 @@ mod list_metadata_tests { let menu = &menus[0]["params"][0]; assert_eq!(menu["type"], "enum"); assert_eq!(menu["default"], 0); - assert_eq!(menu["options"], json!([ - {"value": 0, "label": "Item 1"}, - {"value": 1, "label": "Item 2"}, - {"value": 2, "label": "Item 3"} - ])); + assert_eq!( + menu["options"], + json!([ + {"value": 0, "label": "Item 1"}, + {"value": 1, "label": "Item 2"}, + {"value": 2, "label": "Item 3"} + ]) + ); let sliders = s.execute("effect.list", json!({"filter": "ec.control.slider"})).unwrap(); assert_eq!(sliders.as_array().map(Vec::len), Some(1)); let slider = &sliders[0]["params"][0]; From ad7df67602c01c665ca12b0fd3b4910428ad3057 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 19:16:03 +0000 Subject: [PATCH 045/107] #360: the Project row menu's New Comp from Selection uses the whole selection Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f --- crates/ui-egui/src/panels/project.rs | 8 +++----- crates/ui-egui/tests/ui_project_rows.rs | 26 +++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/panels/project.rs b/crates/ui-egui/src/panels/project.rs index 8f249b78..21f84297 100644 --- a/crates/ui-egui/src/panels/project.rs +++ b/crates/ui-egui/src/panels/project.rs @@ -599,12 +599,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { actions.push(("layer.addItem".into(), json!({"item": id.0}))); ui.close(); } + // File ▸ New Comp from Selection on the selection (the right-clicked row joins it + // above): several items ask how in its dialog, as in After Effects (#360). if ui.button(crate::i18n::tr("New Comp from Selection")).clicked() { - if let Some((w, h)) = it.dimensions() { - let d = it.duration().map(|d| d.seconds()).unwrap_or(10.0); - actions.push(("comp.new".into(), json!({"name": format!("{} Comp", it.name), "width": w, "height": h, "duration": d}))); - actions.push(("layer.addItem".into(), json!({"item": id.0}))); - } + actions.push(("file.newCompFromSelection".into(), json!({}))); ui.close(); } ui.separator(); diff --git a/crates/ui-egui/tests/ui_project_rows.rs b/crates/ui-egui/tests/ui_project_rows.rs index 43daf2ed..4c255cad 100644 --- a/crates/ui-egui/tests/ui_project_rows.rs +++ b/crates/ui-egui/tests/ui_project_rows.rs @@ -148,6 +148,32 @@ fn footage_context_menu_interprets_the_footage() { assert_eq!(h.state().dialog, Some(Dialog::Form), "the Interpret Footage dialog opened"); } +/// #360: the row menu's New Comp from Selection works on the whole selection, as File ▸ New +/// Comp from Selection does: with several items selected it asks how in After Effects' New +/// Composition from Selection dialog, and Multiple Compositions makes one per item. +#[test] +fn context_menu_new_comp_from_selection_uses_the_selection() { + let (mut h, _, comp, solid) = harness(); + h.state_mut().session.state.project_selection = vec![comp, solid]; + h.run_steps(2); + let at = center(&h, &format!("project.item.{}.name", solid.0)); + h.input_mut().events.push(Event::PointerMoved(at)); + h.step(); + for pressed in [true, false] { + h.input_mut().events.push(Event::PointerButton { pos: at, button: egui::PointerButton::Secondary, pressed, modifiers: Modifiers::NONE }); + h.step(); + } + h.run_steps(2); + assert_eq!(h.state().session.state.project_selection, vec![comp, solid], "the selection stays"); + let item = h.query_by_label_contains("New Comp from Selection").expect("in the menu").rect(); + click_at(&mut h, item.center()); + assert_eq!(h.state().dialog, Some(Dialog::Form)); + assert!(h.state().auto.find("form.field.single").is_some(), "the New Composition from Selection dialog"); + let comps = h.state().session.project.comps().count(); + h.state_mut().session.execute_checked("file.newCompFromSelection", json!({"single": false})).unwrap(); + assert_eq!(h.state().session.project.comps().count(), comps + 2, "one per item"); +} + /// Dragging from the Project panel's empty area draws a selection box that selects the rows it /// touches; a click there deselects (#203). #[test] From e4916e5ca937466e719d5f64dd4c70d6f261a8da Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 19:16:03 +0000 Subject: [PATCH 046/107] #513: clicking off a mask's path, even on its layer, deselects its points Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f --- crates/ui-egui/src/panels/viewer.rs | 4 ++++ crates/ui-egui/tests/ui_viewer.rs | 20 ++++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index 9adf007f..e3cc4485 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -1640,6 +1640,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { _ => { if pick(app, &ectx, cpt, mods.shift).is_none() { let _ = app.session.execute("edit.deselectAll", json!({})); + } else if !mods.shift && !app.session.state.selected_vertices.is_empty() && ov::segment_at(&paths, &map, pos, 6.0).is_none() { + // A click away from the paths, even on their layer, deselects the mask + // points (the layer stays selected), as in After Effects (#513). + let _ = app.session.execute("mask.selectVertices", json!({"vertices": []})); } } } diff --git a/crates/ui-egui/tests/ui_viewer.rs b/crates/ui-egui/tests/ui_viewer.rs index 6b535b6c..fd12d555 100644 --- a/crates/ui-egui/tests/ui_viewer.rs +++ b/crates/ui-egui/tests/ui_viewer.rs @@ -750,6 +750,26 @@ fn pen_mask_undoes_step_by_step_and_double_click_moves_the_whole_mask() { assert!(back.vertices.iter().zip(&moved.vertices).all(|(v, c)| near(*v, *c)), "{back:?}"); } +/// #513: clicking away from a mask's path, even on its own layer, deselects the mask's points; +/// the layer stays selected. +#[test] +fn clicking_off_a_mask_deselects_its_points() { + let mut h = harness(); + let plate = layer_id(&h, "Plate"); + let s = &mut h.state_mut().session; + s.execute("layer.select", json!({"layers": [plate]})).unwrap(); + s.execute("mask.new", json!({"layer": plate, "vertices": [[100.0, 60.0], [500.0, 60.0], [500.0, 300.0], [100.0, 300.0]], "closed": true})).unwrap(); + h.state_mut().ui.tool = Tool::Selection; + h.run_steps(2); + let at = screen(&h, [500.0, 60.0]); + click(&mut h, at); + assert_eq!(h.state().session.state.selected_vertices.len(), 1, "a click on a point selects it"); + let away = screen(&h, [200.0, 250.0]); + click(&mut h, away); + assert!(h.state().session.state.selected_vertices.is_empty(), "a click off the path deselects its points"); + assert_eq!(h.state().session.state.selected_layers, vec![LayerId(plate)], "the layer stays selected"); +} + /// A straight two-key motion path shows Bezier handles at both keys (it had none), and dragging /// one pulls the path into a curve (#290). #[test] From bf5e6c6cec2e7a370cfa58b1deb50125f402755f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 19:16:03 +0000 Subject: [PATCH 047/107] #581: the Discord pill fits its label and field text sits on the field's middle Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f --- crates/ui-egui/src/header.rs | 9 ++++++--- crates/ui-egui/src/widgets.rs | 26 ++++++++++++++++++++++++++ crates/ui-egui/tests/ui_panels.rs | 11 +++++++++++ 3 files changed, 43 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/header.rs b/crates/ui-egui/src/header.rs index 1b93658f..f92193f7 100644 --- a/crates/ui-egui/src/header.rs +++ b/crates/ui-egui/src/header.rs @@ -177,12 +177,15 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { // Right side: community buttons, workspaces. let mut rx = rect.max.x - 10.0; - let discord = Rect::from_min_max(pos2(rx - 104.0, cy - 13.0), pos2(rx, cy + 13.0)); + // The pill fits its label (icon, gap, text) with even padding: it was a fixed 104 px with the + // text at its left, which left a wide gap on the right (#581). + let label = p.layout_no_wrap(tr("Discord").to_string(), Tokens::semibold(12.0), Color32::WHITE); + let discord = Rect::from_min_max(pos2(rx - (10.0 + 14.0 + 6.0 + label.size().x + 12.0), cy - 13.0), pos2(rx, cy + 13.0)); let dresp = ui.interact(discord, egui::Id::new("hdr-discord"), Sense::click()); let dc = Color32::from_rgb(0x58, 0x65, 0xf2); p.rect_filled(discord, 13.0, if dresp.hovered() { dc.gamma_multiply(1.2) } else { dc }); - icons::paint(&p, Rect::from_center_size(pos2(discord.min.x + 16.0, cy), vec2(14.0, 14.0)), Icon::Chat, Color32::WHITE); - p.text(pos2(discord.min.x + 28.0, cy), Align2::LEFT_CENTER, tr("Discord"), Tokens::semibold(12.0), Color32::WHITE); + icons::paint(&p, Rect::from_center_size(pos2(discord.min.x + 17.0, cy), vec2(14.0, 14.0)), Icon::Chat, Color32::WHITE); + p.galley(pos2(discord.min.x + 30.0, cy - label.size().y / 2.0), label, Color32::WHITE); app.auto.add("header.discord", discord, "Join the ArtCraft Discord"); if dresp.on_hover_text(tr("Join the ArtCraft community on Discord")).clicked() { let _ = app.session.execute("help.discord", json!({})); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 8f12e52b..c37f8c34 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -277,6 +277,8 @@ fn field(ui: &mut Ui, rect: Rect, text: &mut String, hint: &str, t: &Tokens, sea .hint_text(hint) .frame(egui::Frame::NONE) .desired_width(inner.width()) + // As tall as the field, so the text and the hint sit on its middle (they sat high, #581). + .min_size(inner.size()) .font(Tokens::ui(12.0)) .vertical_align(egui::Align::Center), ) @@ -559,3 +561,27 @@ pub fn popup_is_open(ui: &Ui, id: egui::Id) -> bool { pub fn open_popup(ui: &Ui, id: egui::Id) { ui.data_mut(|d| d.insert_temp(id.with("open"), true)); } + +#[cfg(test)] +mod tests { + use super::*; + + /// #581: a field's text edit fills it, so its text and hint sit on the field's middle line + /// (it was a row at the top of the field, its text high). + #[test] + fn field_text_is_vertically_centred() { + let ctx = egui::Context::default(); + let t = Tokens::for_kind(Default::default()); + for h in [22.0, 26.0, 30.0] { + let field = Rect::from_min_size(pos2(20.0, 40.0), vec2(220.0, h)); + let mut text = String::new(); + let mut got = None; + let mut out = ctx.run_ui(Default::default(), |ui| { + got = Some(search_field(ui, field, &mut text, "Filter recent files", &t).rect); + }); + out.textures_delta.clear(); + let r = got.expect("the field was drawn"); + assert!((r.center().y - field.center().y).abs() < 0.5 && r.height() >= h - 2.5, "{h}: {r:?} in {field:?}"); + } + } +} diff --git a/crates/ui-egui/tests/ui_panels.rs b/crates/ui-egui/tests/ui_panels.rs index d58c2146..3b580921 100644 --- a/crates/ui-egui/tests/ui_panels.rs +++ b/crates/ui-egui/tests/ui_panels.rs @@ -35,6 +35,17 @@ fn click(h: &mut Harness<'_, EffectcraftApp>, id: &str) { h.run_steps(2); } +/// #581: the header's Discord pill fits its label with even padding (it was a fixed 104 px with +/// the text at its left). +#[test] +fn discord_pill_fits_its_label() { + let h = harness(); + let e = h.state().auto.find("header.discord").expect("the Discord pill").clone(); + let text = h.ctx.fonts_mut(|f| f.layout_no_wrap("Discord".into(), effectcraft_ui_egui::theme::Tokens::semibold(12.0), egui::Color32::WHITE).size().x); + // Padding 10, the 14 px icon, a 6 px gap, the text, padding 12. + assert!((e.rect[2] - (text + 42.0)).abs() < 0.5, "{} for a {text} px label", e.rect[2]); +} + #[test] fn window_menu_opens_the_new_panels() { let mut h = harness(); From dbbc0e2662f2a532c9a3e3dd277f2af9b669f5e6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 19:16:03 +0000 Subject: [PATCH 048/107] #577: font previews are scaled and moved to fit inside their row Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f --- crates/ui-egui/src/panels/text_panels.rs | 72 ++++++++++++++++++++++-- 1 file changed, 68 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/panels/text_panels.rs b/crates/ui-egui/src/panels/text_panels.rs index 8e24a083..df2e6dce 100644 --- a/crates/ui-egui/src/panels/text_panels.rs +++ b/crates/ui-egui/src/panels/text_panels.rs @@ -580,10 +580,14 @@ fn font_popup(app: &EffectcraftApp, ui: &mut egui::Ui, id: egui::Id, pos: egui:: l } }; - let o = pos2(resp.rect.max.x - 120.0, resp.rect.center().y + 5.0); - for poly in lines.iter() { - let pts: Vec = poly.iter().map(|p| o + vec2(p[0], p[1])).collect(); - ui.painter().add(egui::Shape::line(pts, egui::Stroke::new(1.0, t.text_dim))); + let room = Rect::from_min_max(pos2(resp.rect.max.x - 120.0, resp.rect.min.y + 1.0), pos2(resp.rect.max.x - 6.0, resp.rect.max.y - 1.0)); + if let Some((k, o)) = fit_preview(&lines, room, resp.rect.center().y + 5.0) { + let painter = ui.painter().with_clip_rect(resp.rect); + for poly in lines.iter() { + let pts: Vec = + poly.iter().filter(|p| p[0].is_finite() && p[1].is_finite()).map(|p| (o + vec2(p[0], p[1]) * k).to_pos2()).collect(); + painter.add(egui::Shape::line(pts, egui::Stroke::new(1.0, t.text_dim))); + } } } if resp.clicked() { @@ -594,6 +598,30 @@ fn font_popup(app: &EffectcraftApp, ui: &mut egui::Ui, id: egui::Id, pos: egui:: }) } +/// 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 +/// spills out of its row (#577). `None`: nothing to draw. +fn fit_preview(lines: &[Vec<[f32; 2]>], room: Rect, baseline: f32) -> Option<(f32, egui::Vec2)> { + let b = lines + .iter() + .flatten() + .filter(|p| p[0].is_finite() && p[1].is_finite()) + .fold(Rect::NOTHING, |b, p| b.union(Rect::from_min_max(pos2(p[0], p[1]), pos2(p[0], p[1])))); + if !(b.min.x <= b.max.x && b.min.y <= b.max.y) || !room.is_positive() { + return None; + } + let k = (room.width() / b.width().max(1e-3)).min(room.height() / b.height().max(1e-3)).min(1.0); + let mut o = vec2(room.min.x - b.min.x * k, baseline); + let (top, bottom) = (o.y + b.min.y * k, o.y + b.max.y * k); + if top < room.min.y { + o.y += room.min.y - top; + } else if bottom > room.max.y { + o.y -= bottom - room.max.y; + } + Some((k, o)) +} + /// Align panel: Align Layers to Selection / Composition, the six align buttons and the six /// Distribute Layers buttons. Buttons run `layer.align {edge, to}` and `layer.distribute /// {mode}` on the selected layers. @@ -722,3 +750,39 @@ fn align_glyph(p: &egui::Painter, r: Rect, op: &str, distribute: bool, t: &Token p.rect_filled(Rect::from_min_size(pos2(c.x + 3.0, off(9.0)), vec2(5.0, 9.0)), 1.0, t.text_dim); } } + +#[cfg(test)] +mod tests { + use super::*; + + /// #577: every font's "Sample" preview lands inside its row's preview box: at its natural size + /// when it fits, else scaled down; nothing for no outlines. + #[test] + fn font_previews_fit_their_row() { + let room = Rect::from_min_max(pos2(180.0, 101.0), pos2(294.0, 119.0)); + let baseline = 115.0; + let inside = |lines: &[Vec<[f32; 2]>]| { + let (k, o) = fit_preview(lines, room, baseline).expect("a preview"); + for p in lines.iter().flatten() { + let q = o + vec2(p[0], p[1]) * k; + assert!(room.expand(0.01).contains(pos2(q.x, q.y)), "{p:?} → {q:?} outside {room:?}"); + } + k + }; + // A plain 14 px sample fits as it is, on the baseline. + let plain = vec![vec![[0.0, -10.0], [60.0, -10.0], [60.0, 3.0], [0.0, 3.0]]]; + assert_eq!(inside(&plain), 1.0); + assert_eq!(fit_preview(&plain, room, baseline).map(|(_, o)| o.y), Some(baseline)); + // Huge, wide, tall, offset and negative-bearing outlines are scaled into the box. + for lines in [ + vec![vec![[0.0, -80.0], [400.0, 30.0]]], + vec![vec![[-50.0, -5.0], [500.0, 2.0]]], + vec![vec![[10.0, -200.0], [12.0, 0.0]]], + vec![vec![[0.0, 20.0], [30.0, 40.0]]], + ] { + assert!(inside(&lines) <= 1.0); + } + assert!(fit_preview(&[], room, baseline).is_none()); + assert!(fit_preview(&[vec![[f32::NAN, 1.0]]], room, baseline).is_none()); + } +} From a207b19b12da776ec3930ef9d75853a7efbc1f9f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 19:16:03 +0000 Subject: [PATCH 049/107] #336: Ctrl+\ / Cmd+\ maximizes or restores the application window Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f --- crates/ui-egui/src/menus.rs | 39 +++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 7851779f..3d6ed3f0 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -122,6 +122,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[ uic!("app.home", "Home", [], None), uic!("markers.dialog", "Marker Settings...", [], None), uic!("window.maximizePanel", "Maximize Panel Under Pointer", [], Some("`")), + uic!("window.maximizeApp", "Maximize or Restore Application Window", [], Some("Cmd+\\")), uic!("window.dockPanel", "Dock Panel", [], None), uic!("window.floatPanel", "Undock Panel", [], None), uic!("window.closePanel", "Close Panel", [], None), @@ -416,6 +417,10 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V _ => {} } } + // A UI-only command (not in the engine's menus), so `frontend` gets it from here. + if id == "window.maximizeApp" { + return frontend(app, ctx, id, params); + } // Docking: {panel, anchor, zone: center|left|right|top|bottom}, {panel, rect?}, {panel?}. if let Some(op) = id.strip_prefix("window.").filter(|o| matches!(*o, "maximizePanel" | "dockPanel" | "floatPanel" | "closePanel")) { let panel_of = |k: &str| params.get(k).and_then(Value::as_str).map(|s| PanelKind::from_name(s).ok_or(format!("unknown panel `{s}`"))); @@ -909,6 +914,12 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu }; Value::Null } + // Ctrl+\ / Cmd+\, as in After Effects (#336): `{maximized}`, the window's new state. + "window.maximizeApp" => { + let maximized = !ctx.input(|i| i.viewport().maximized.unwrap_or(false)); + ctx.send_viewport_cmd(egui::ViewportCommand::Maximized(maximized)); + json!({"maximized": maximized}) + } "view.fullScreen" => { let full = ctx.input(|i| i.viewport().fullscreen.unwrap_or(false)); ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(!full)); @@ -1857,6 +1868,34 @@ mod tests { } } + /// #336: Ctrl+\ (Cmd+\ on macOS) maximizes the application window, and again restores + /// it, as in After Effects. + #[test] + fn ctrl_backslash_maximizes_or_restores_the_window() { + let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); + let (m, k) = parse_shortcut("Cmd+\\").unwrap(); + assert!(m.command && k == egui::Key::Backslash, "{m:?} {k:?}"); + assert!(bindings(&app.session).iter().any(|b| (b.0, b.1) == (m, k) && b.2 == "window.maximizeApp")); + let ctx = egui::Context::default(); + let run = |app: &mut EffectcraftApp, maximized: bool| { + let input = egui::RawInput { + viewports: std::iter::once((egui::ViewportId::ROOT, egui::ViewportInfo { maximized: Some(maximized), ..Default::default() })).collect(), + ..Default::default() + }; + let mut r = Value::Null; + let mut out = ctx.run_ui(input, |ui| r = invoke(app, ui.ctx(), "window.maximizeApp", json!({})).unwrap()); + out.textures_delta.clear(); + let cmds = out.viewport_output.get(&egui::ViewportId::ROOT).map(|v| v.commands.clone()).unwrap_or_default(); + (r, cmds) + }; + let (r, cmds) = run(&mut app, false); + assert_eq!(r, json!({"maximized": true})); + assert!(cmds.contains(&egui::ViewportCommand::Maximized(true)), "{cmds:?}"); + let (r, cmds) = run(&mut app, true); + assert_eq!(r, json!({"maximized": false})); + assert!(cmds.contains(&egui::ViewportCommand::Maximized(false)), "{cmds:?}"); + } + #[test] fn dispatch_uses_the_active_custom_preset() { let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); From 29d9ba5fbbe464d343b5e29f10b3355a8f38da99 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 19:16:28 +0000 Subject: [PATCH 050/107] i18n: the Home screen's buttons, pages, tiles, columns and empty states go through the interface catalog (#574) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit panels/home.rs was in the converted list, but most of its text was drawn straight from English literals (New Project, Open Project…, the page tabs, the quick-start tiles, the filter placeholder, the column headings, the empty states, the Opened/Kind cells and the version line), so a Chinese interface still showed them in English. They now go through tr()/tr_args(), with rows in both Chinese catalogs (Traditional's Join the ArtCraft Discord row was also still English). Automation labels stay English. The Opened column's plurals are separate rows so each language words them itself. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f --- crates/ui-egui/src/i18n/ui.rs | 64 ++++++++++++++++++++++++++++++- crates/ui-egui/src/panels/home.rs | 64 +++++++++++++++++++------------ 2 files changed, 103 insertions(+), 25 deletions(-) diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index fdcaa27e..e7e4db63 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -996,6 +996,37 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("Use In-Window Menu Bar on macOS", "在 macOS 上使用窗口内菜单栏"), ("Wireframe", "线框"), ("sRGB IEC61966-2.1 (default)", "sRGB IEC61966-2.1(默认)"), + // The Home screen (#574). + ("New Project", "新建项目"), + ("Open Project…", "打开项目…"), + ("Templates", "模板"), + ("EffectCraft home page", "EffectCraft 主页"), + ("EffectCraft on GitHub", "GitHub 上的 EffectCraft"), + ("Motion graphics and visual effects · version {}", "动态图形和视觉效果 · 版本 {}"), + ("New Composition", "新建合成"), + ("Open Demo Project", "打开示例项目"), + ("Import Footage…", "导入素材…"), + ("Filter recent files", "筛选最近文件"), + ("NAME", "名称"), + ("OPENED", "打开时间"), + ("SIZE", "大小"), + ("KIND", "类型"), + ("Your recent files will show here", "你最近使用的文件会显示在这里"), + ("Create a new project, or open the demo project to look around.", "新建一个项目,或打开示例项目先熟悉一下界面。"), + ("No recent files match the filter", "没有与筛选条件匹配的最近文件"), + ("Try another name or folder.", "请尝试其他名称或文件夹。"), + ("Just now", "刚刚"), + ("Yesterday", "昨天"), + ("{} minute ago", "{} 分钟前"), + ("{} minutes ago", "{} 分钟前"), + ("{} hour ago", "{} 小时前"), + ("{} hours ago", "{} 小时前"), + ("{} day ago", "{} 天前"), + ("{} days ago", "{} 天前"), + ("EffectCraft project", "EffectCraft 项目"), + ("After Effects project", "After Effects 项目"), + ("Lottie animation", "Lottie 动画"), + ("Project", "项目"), ]; const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ @@ -1469,7 +1500,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("Import PDF / Illustrator File", "Import PDF / Illustrator File"), ("Import Photoshop File", "Import Photoshop File"), ("Interpret Unlabeled Alpha As", "Interpret Unlabeled Alpha As"), - ("Join the ArtCraft Discord", "Join the ArtCraft Discord"), + ("Join the ArtCraft Discord", "加入 ArtCraft Discord"), ("Mercury GPU Acceleration ({})", "Mercury GPU Acceleration ({})"), ("Mercury Software Only", "Mercury Software Only"), ("OCIO (built-in ACES config)", "OCIO (built-in ACES config)"), @@ -1482,6 +1513,37 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("Use In-Window Menu Bar on macOS", "Use In-Window Menu Bar on macOS"), ("Wireframe", "Wireframe"), ("sRGB IEC61966-2.1 (default)", "sRGB IEC61966-2.1 (default)"), + // The Home screen (#574). + ("New Project", "新增專案"), + ("Open Project…", "開啟專案…"), + ("Templates", "範本"), + ("EffectCraft home page", "EffectCraft 首頁"), + ("EffectCraft on GitHub", "GitHub 上的 EffectCraft"), + ("Motion graphics and visual effects · version {}", "動態圖形與視覺效果 · 版本 {}"), + ("New Composition", "新增合成"), + ("Open Demo Project", "開啟範例專案"), + ("Import Footage…", "匯入素材…"), + ("Filter recent files", "篩選最近的檔案"), + ("NAME", "名稱"), + ("OPENED", "開啟時間"), + ("SIZE", "大小"), + ("KIND", "類型"), + ("Your recent files will show here", "你最近使用的檔案會顯示在這裡"), + ("Create a new project, or open the demo project to look around.", "新增一個專案,或開啟範例專案先熟悉一下介面。"), + ("No recent files match the filter", "沒有符合篩選條件的最近檔案"), + ("Try another name or folder.", "請嘗試其他名稱或資料夾。"), + ("Just now", "剛剛"), + ("Yesterday", "昨天"), + ("{} minute ago", "{} 分鐘前"), + ("{} minutes ago", "{} 分鐘前"), + ("{} hour ago", "{} 小時前"), + ("{} hours ago", "{} 小時前"), + ("{} day ago", "{} 天前"), + ("{} days ago", "{} 天前"), + ("EffectCraft project", "EffectCraft 專案"), + ("After Effects project", "After Effects 專案"), + ("Lottie animation", "Lottie 動畫"), + ("Project", "專案"), ]; /// Ukrainian (uk): original translations of the same interface rows as the Chinese catalogs. diff --git a/crates/ui-egui/src/panels/home.rs b/crates/ui-egui/src/panels/home.rs index 861d7d49..46bbb44f 100644 --- a/crates/ui-egui/src/panels/home.rs +++ b/crates/ui-egui/src/panels/home.rs @@ -15,6 +15,7 @@ use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; use serde::Serialize; use serde_json::json; +use crate::i18n::{tr, tr_args}; use crate::icons::{self, Icon}; use crate::theme::Tokens; use crate::{EffectcraftApp, widgets}; @@ -87,13 +88,13 @@ pub fn format_utc(secs: i64) -> String { /// date after a week). pub fn format_ago(now: i64, then: i64) -> String { let d = now.saturating_sub(then); - let plural = |n: i64, unit: &str| format!("{n} {unit}{} ago", if n == 1 { "" } else { "s" }); + let plural = |n: i64, one: &str, many: &str| tr_args(if n == 1 { one } else { many }, &[&n]); match d { - i64::MIN..60 => "Just now".to_string(), - 60..3_600 => plural(d / 60, "minute"), - 3_600..86_400 => plural(d / 3_600, "hour"), - 86_400..172_800 => "Yesterday".to_string(), - 172_800..604_800 => plural(d / 86_400, "day"), + i64::MIN..60 => tr("Just now").to_string(), + 60..3_600 => plural(d / 60, "{} minute ago", "{} minutes ago"), + 3_600..86_400 => plural(d / 3_600, "{} hour ago", "{} hours ago"), + 86_400..172_800 => tr("Yesterday").to_string(), + 172_800..604_800 => plural(d / 86_400, "{} day ago", "{} days ago"), _ => format_utc(then).get(..10).unwrap_or_default().to_string(), } } @@ -112,14 +113,14 @@ pub fn format_size(bytes: u64) -> String { } } -/// The Kind column: what a recent file is, by its extension. +/// The Kind column: what a recent file is, by its extension, in the interface language. fn kind_of(path: &str) -> &'static str { - match std::path::Path::new(path).extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase).as_deref() { + tr(match std::path::Path::new(path).extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase).as_deref() { Some("ecproj") => "EffectCraft project", Some("aep" | "aepx") => "After Effects project", Some("json" | "lottie") => "Lottie animation", _ => "Project", - } + }) } /// The config-store name of a project's thumbnail. @@ -237,7 +238,7 @@ fn rail_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, rail: let mut y = rail.min.y + 20.0; for (label, id, primary) in [("New Project", "file.newProject", true), ("Open Project…", "file.open", false)] { let r = Rect::from_min_size(pos2(x0, y), vec2(w, 34.0)); - if widgets::text_button(ui, r, label, primary, &t, egui::Id::new(("home", id))).clicked() { + if widgets::text_button(ui, r, tr(label), primary, &t, egui::Id::new(("home", id))).clicked() { actions.push((id, json!({}))); } app.auto.add(&format!("home.{id}"), r, label); @@ -261,7 +262,7 @@ fn rail_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, rail: } let col = if on { t.tab_text_active } else { t.text_dim }; icons::paint(p, Rect::from_center_size(pos2(r.min.x + 20.0, r.center().y), vec2(16.0, 16.0)), icon, col); - p.text(pos2(r.min.x + 40.0, r.center().y), Align2::LEFT_CENTER, label, Tokens::medium(13.0), col); + p.text(pos2(r.min.x + 40.0, r.center().y), Align2::LEFT_CENTER, tr(label), Tokens::medium(13.0), col); app.auto.add(&format!("home.tab.{}", label.to_ascii_lowercase()), r, label); if resp.clicked() { app.ui.home_learn = k == 2; @@ -281,7 +282,7 @@ fn rail_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, rail: let foot_h = 22.0 + links.len() as f32 * 30.0 + 22.0 + sib_rows * 26.0; let mut cy = (rail.max.y - 16.0 - foot_h).max(y + 16.0); p.line_segment([pos2(x0, cy - 10.0), pos2(x0 + w, cy - 10.0)], Stroke::new(1.0, t.separator)); - p.text(pos2(x0, cy + 6.0), Align2::LEFT_CENTER, crate::i18n::tr("Community"), Tokens::semibold(12.0), t.text_dim); + p.text(pos2(x0, cy + 6.0), Align2::LEFT_CENTER, tr("Community"), Tokens::semibold(12.0), t.text_dim); cy += 22.0; for (icon, label, cmd) in links { let r = Rect::from_min_size(pos2(x0, cy), vec2(w, 26.0)); @@ -294,14 +295,14 @@ fn rail_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, rail: } let col = if discord { Color32::WHITE } else { t.text }; icons::paint(p, Rect::from_center_size(pos2(r.min.x + 16.0, r.center().y), vec2(13.0, 13.0)), icon, col); - p.text(pos2(r.min.x + 30.0, r.center().y), Align2::LEFT_CENTER, label, Tokens::medium(12.0), col); + p.text(pos2(r.min.x + 30.0, r.center().y), Align2::LEFT_CENTER, tr(label), Tokens::medium(12.0), col); app.auto.add(&format!("home.{cmd}"), r, label); if resp.clicked() { let _ = app.session.execute(cmd, json!({})); } cy += 30.0; } - p.text(pos2(x0, cy + 8.0), Align2::LEFT_CENTER, crate::i18n::tr("More ArtCraft apps"), Tokens::ui(11.0), t.text_faint); + p.text(pos2(x0, cy + 8.0), Align2::LEFT_CENTER, tr("More ArtCraft apps"), Tokens::ui(11.0), t.text_faint); cy += 20.0; let sw = (w - 6.0) / 2.0; for (i, (name, slug)) in sib.iter().enumerate() { @@ -328,12 +329,12 @@ fn home_page( let t = app.tokens; let (x0, w) = (area.min.x, area.width()); let mut y = area.min.y; - p.text(pos2(x0, y + 14.0), Align2::LEFT_CENTER, crate::i18n::tr("Welcome to EffectCraft"), Tokens::semibold(26.0), t.tab_text_active); + p.text(pos2(x0, y + 14.0), Align2::LEFT_CENTER, tr("Welcome to EffectCraft"), Tokens::semibold(26.0), t.tab_text_active); y += 40.0; p.text( pos2(x0, y + 8.0), Align2::LEFT_CENTER, - format!("Motion graphics and visual effects · version {}", env!("CARGO_PKG_VERSION")), + tr_args("Motion graphics and visual effects · version {}", &[&env!("CARGO_PKG_VERSION")]), Tokens::ui(12.5), t.text_dim, ); @@ -355,7 +356,7 @@ fn home_page( let ir = Rect::from_center_size(pos2(r.min.x + 30.0, r.center().y), vec2(32.0, 32.0)); p.rect_filled(ir, 7.0, t.accent.gamma_multiply(0.18)); icons::paint(p, ir.shrink(8.0), icon, t.accent); - p.with_clip_rect(r.shrink(2.0)).text(pos2(ir.max.x + 12.0, r.center().y), Align2::LEFT_CENTER, label, Tokens::medium(12.5), t.text); + p.with_clip_rect(r.shrink(2.0)).text(pos2(ir.max.x + 12.0, r.center().y), Align2::LEFT_CENTER, tr(label), Tokens::medium(12.5), t.text); app.auto.add(&format!("home.{id}"), r, label); if resp.clicked() { if id == "templates" { @@ -369,18 +370,18 @@ fn home_page( y += 64.0 + 36.0; // Recent: heading, filter, clear. - p.text(pos2(x0, y + 12.0), Align2::LEFT_CENTER, crate::i18n::tr("Recent"), Tokens::semibold(17.0), t.tab_text_active); + p.text(pos2(x0, y + 12.0), Align2::LEFT_CENTER, tr("Recent"), Tokens::semibold(17.0), t.tab_text_active); let entries = recent_entries(&app.session.prefs); let fid = egui::Id::new("home-filter"); let mut filter: String = ctx.data(|d| d.get_temp(fid)).unwrap_or_default(); let fr = Rect::from_min_size(pos2(x0 + w - 240.0, y), vec2(240.0, 26.0)); - widgets::search_field(ui, fr, &mut filter, "Filter recent files", &t); + widgets::search_field(ui, fr, &mut filter, tr("Filter recent files"), &t); app.auto.add("home.filter", fr, "Filter recent files"); ctx.data_mut(|d| d.insert_temp(fid, filter.clone())); if !entries.is_empty() { let cr = Rect::from_min_size(pos2(fr.min.x - 96.0, y + 3.0), vec2(84.0, 20.0)); let resp = ui.interact(cr, egui::Id::new("home-clear-recent"), Sense::click()); - p.text(cr.right_center(), Align2::RIGHT_CENTER, crate::i18n::tr("Clear list"), Tokens::ui(11.5), if resp.hovered() { t.text } else { t.text_faint }); + p.text(cr.right_center(), Align2::RIGHT_CENTER, tr("Clear list"), Tokens::ui(11.5), if resp.hovered() { t.text } else { t.text_faint }); app.auto.add("home.clearRecent", cr, "Clear Recent Projects"); if resp.clicked() { actions.push(("file.clearRecent", json!({}))); @@ -390,7 +391,7 @@ fn home_page( // Column headings (Name / Opened / Size / Kind, as in After Effects). let (opened_x, size_x, kind_x) = (x0 + w * 0.52, x0 + w * 0.68, x0 + w * 0.80); for (label, x) in [("NAME", x0 + 76.0), ("OPENED", opened_x), ("SIZE", size_x), ("KIND", kind_x)] { - p.text(pos2(x, y), Align2::LEFT_CENTER, label, Tokens::medium(10.5), t.text_faint); + p.text(pos2(x, y), Align2::LEFT_CENTER, tr(label), Tokens::medium(10.5), t.text_faint); } y += 12.0; p.line_segment([pos2(x0, y), pos2(x0 + w, y)], Stroke::new(1.0, t.separator)); @@ -406,8 +407,8 @@ fn home_page( } else { ("No recent files match the filter", "Try another name or folder.") }; - p.text(c + vec2(0.0, 40.0), Align2::CENTER_CENTER, head, Tokens::semibold(14.0), t.text); - p.text(c + vec2(0.0, 62.0), Align2::CENTER_CENTER, sub, Tokens::ui(12.0), t.text_faint); + p.text(c + vec2(0.0, 40.0), Align2::CENTER_CENTER, tr(head), Tokens::semibold(14.0), t.text); + p.text(c + vec2(0.0, 62.0), Align2::CENTER_CENTER, tr(sub), Tokens::ui(12.0), t.text_faint); } let now = web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map_or(0, |d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX)); let row_h = 48.0; @@ -439,7 +440,7 @@ fn home_page( Tokens::medium(13.0), if e.exists { t.tab_text_active } else { t.text_dim }, ); - let sub = if e.exists { e.folder.clone() } else { format!("{} (missing)", e.folder) }; + let sub = if e.exists { e.folder.clone() } else { format!("{} {}", e.folder, crate::i18n::tr("(missing)")) }; name_clip.text(pos2(tr.max.x + 6.0, r.center().y + 9.0), Align2::LEFT_CENTER, sub, Tokens::ui(11.0), t.text_faint); let cell = |x: f32, x1: f32, text: String| { p.with_clip_rect(Rect::from_min_max(pos2(x, r.min.y), pos2(x1 - 8.0, r.max.y))).text( @@ -534,6 +535,21 @@ mod tests { assert_eq!((kind_of("/a/B.ecproj"), kind_of("c.AEP"), kind_of("x")), ("EffectCraft project", "After Effects project", "Project")); } + /// The Home screen's own text goes through the interface catalog (#574): the Opened and Kind + /// columns read in Chinese, and English stays English. + #[test] + fn home_text_follows_the_interface_language() { + let now = 1_790_000_000; + crate::i18n::set_current("zh-hans"); + let zh = (format_ago(now, now - 5), format_ago(now, now - 3 * 3_600), format_ago(now, now - 90_000), kind_of("/a/B.ecproj")); + crate::i18n::set_current("zh-hant"); + let tw = (format_ago(now, now - 60), kind_of("x")); + crate::i18n::set_current("en"); + assert_eq!(zh, ("刚刚".into(), "3 小时前".into(), "昨天".into(), "EffectCraft 项目")); + assert_eq!(tw, ("1 分鐘前".into(), "專案")); + assert_eq!(tr("Filter recent files"), "Filter recent files"); + } + #[test] fn home_screen_registers_its_controls() { let mut s = effectcraft_engine::Session::default(); From 76c9abad501b5250029f85a2d97a4119b8064475 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 19:16:36 +0000 Subject: [PATCH 051/107] Viewer: the 3D Renderer menu shows the comp's renderer and switches it (#307) The Composition panel's bottom bar drew a hard-coded "Classic 3D" dropdown that ignored the comp's renderer and did nothing when clicked, so an Advanced 3D comp still read Classic 3D. It now shows the comp's renderer (translated) and, as in After Effects, its menu switches it through comp.settings (one undo step); the automation label is the renderer's name. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f --- crates/ui-egui/src/panels/viewer_tools.rs | 18 +++++++++++-- crates/ui-egui/tests/ui_3d.rs | 31 +++++++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels/viewer_tools.rs b/crates/ui-egui/src/panels/viewer_tools.rs index 06679783..28714e18 100644 --- a/crates/ui-egui/src/panels/viewer_tools.rs +++ b/crates/ui-egui/src/panels/viewer_tools.rs @@ -719,9 +719,23 @@ pub(crate) fn bottom_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui, bar: Rect, x += 4.0; // 3D renderer + view. if comp.has_3d() { + // The comp's 3D renderer, as After Effects' Composition panel shows it: the menu switches + // it through Composition Settings (undoable), and the automation label is the renderer. + use effectcraft_engine::project::Renderer; + let renderers = [(Renderer::Classic3D, "Classic 3D", "classic3D"), (Renderer::Advanced3D, "Advanced 3D", "advanced3D")]; + let current = renderers.iter().find(|(r, ..)| *r == comp.renderer).map_or("Classic 3D", |(_, label, _)| *label); let r = Rect::from_min_size(pos2(x, cy - 10.0), vec2(96.0, 20.0)); - let _ = widgets::dropdown(ui, r, "Classic 3D", &t, egui::Id::new("vw-3d")); - app.auto.add("viewer.renderer3d", r, "3D Renderer"); + if widgets::dropdown(ui, r, crate::i18n::tr(current), &t, egui::Id::new("vw-3d")).clicked() { + toggle_popup(ui, "vw-3d-pop"); + } + app.auto.add("viewer.renderer3d", r, current); + let items: Vec<(String, bool)> = renderers.iter().map(|(r, label, _)| (label.to_string(), *r == comp.renderer)).collect(); + if let Some((renderer, _, id)) = popup(app, ui, "vw-3d-pop", r, &items, "renderer3dItem").and_then(|i| renderers.get(i)) + && *renderer != comp.renderer + && let Err(e) = app.session.execute("comp.settings", json!({"renderer": id})) + { + app.ui.status = e.to_string(); + } x = r.max.x + 4.0; let cid = app.session.active_comp_id(); let cur = cid.and_then(|c| app.session.state.views3d.get(&c)).map(|v| v.current).unwrap_or_default(); diff --git a/crates/ui-egui/tests/ui_3d.rs b/crates/ui-egui/tests/ui_3d.rs index 385bf7ac..0c65084b 100644 --- a/crates/ui-egui/tests/ui_3d.rs +++ b/crates/ui-egui/tests/ui_3d.rs @@ -47,6 +47,37 @@ fn viewer_registers_3d_controls() { assert!(ids.iter().any(|i| i.starts_with("viewer.camera.")), "camera wireframe registered"); } +/// Click the centre of the automation element `id`. +fn click_id(h: &mut Harness<'_, EffectcraftApp>, id: &str) { + let [x, y, w, hh] = h.state().auto.find(id).unwrap_or_else(|| panic!("{id} is registered")).rect; + let p = egui::pos2(x + w / 2.0, y + hh / 2.0); + h.input_mut().events.push(egui::Event::PointerMoved(p)); + h.input_mut().events.push(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); + h.step(); + h.input_mut().events.push(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }); + h.run_steps(3); +} + +/// #307: the viewer's 3D Renderer menu shows the comp's renderer (it always read "Classic 3D") +/// and switches it, as After Effects' Composition panel does. +#[test] +fn renderer_menu_shows_and_switches_the_comp_renderer() { + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| app()); + h.state_mut().session.execute("comp.settings", json!({"renderer": "classic3D"})).unwrap(); + h.run_steps(3); + let label = |h: &Harness<'_, EffectcraftApp>| h.state().auto.find("viewer.renderer3d").map(|e| e.label.clone()); + assert_eq!(label(&h).as_deref(), Some("Classic 3D")); + click_id(&mut h, "viewer.renderer3d"); + click_id(&mut h, "viewer.renderer3dItem.1"); + let renderer = h.state().session.active_comp().unwrap().renderer; + assert_eq!(renderer, effectcraft_engine::project::Renderer::Advanced3D); + h.run_steps(2); + assert_eq!(label(&h).as_deref(), Some("Advanced 3D")); + // One undoable Composition Settings step. + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + assert_eq!(h.state().session.active_comp().unwrap().renderer, effectcraft_engine::project::Renderer::Classic3D); +} + #[test] fn camera_and_light_dialogs_create_layers() { let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| app()); From e152dfd1d187de780fd73705df5d71b7506308f2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 19:16:47 +0000 Subject: [PATCH 052/107] Essential Graphics: Scale keeps its Constrain Proportions link, on by default (#338) A Scale property added to Essential Graphics fell into the generic Point control: two independent fields with no link, so dragging one value distorted the layer, unlike After Effects, whose Scale control keeps the chain link. It now draws the link (on by default) with one field per axis; linked, an edit scales the others in proportion (Alt edits one), using the Timeline's constrained() (moved to widgets). The link state is the property's own, shared with the Timeline's chain link. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f --- crates/ui-egui/src/panels/essential.rs | 31 ++++++++++++++++++ crates/ui-egui/src/panels/timeline.rs | 22 +------------ crates/ui-egui/src/widgets.rs | 20 ++++++++++++ crates/ui-egui/tests/ui_panels.rs | 44 ++++++++++++++++++++++++++ 4 files changed, 96 insertions(+), 21 deletions(-) diff --git a/crates/ui-egui/src/panels/essential.rs b/crates/ui-egui/src/panels/essential.rs index 26d458d4..2496b238 100644 --- a/crates/ui-egui/src/panels/essential.rs +++ b/crates/ui-egui/src/panels/essential.rs @@ -375,6 +375,37 @@ fn value_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, auto: &str, ty: Opt }); r.response.rect } + // Scale keeps its Constrain Proportions link, on by default, as in After Effects (#338). + // The link is the property's own, shared with the Timeline's chain link. + ControlType::Point if p.match_id == "scale" && matches!(p.ui, ParamUi::Percent) => { + let comps = v.components(); + let linked = !app.ui.timeline.unlinked.contains(&p.uid); + let (lr, link) = ui.allocate_exact_size(egui::vec2(14.0, 14.0), egui::Sense::click()); + let t = app.tokens; + crate::icons::paint(ui.painter(), lr, crate::icons::Icon::Link, if linked { t.accent } else { t.text_faint }); + let link = link.on_hover_text(tr("Constrain Proportions")); + app.auto.add(&format!("{auto}.link"), lr, "Constrain Proportions"); + if link.clicked() && !app.ui.timeline.unlinked.remove(&p.uid) { + app.ui.timeline.unlinked.insert(p.uid); + } + let mut rect = lr; + for (d, &old) in comps.iter().enumerate() { + let mut x = old; + let r = ui.add(egui::DragValue::new(&mut x).speed(0.5).suffix(if d + 1 == comps.len() { "%" } else { "" }).max_decimals(1)); + app.auto.add(&format!("{auto}.{d}"), r.rect, &p.name); + rect = rect.union(r.rect); + if r.changed() { + // Alt edits one value of a linked pair, as in the Timeline. + let next = if linked && !ui.input(|i| i.modifiers.alt) { + widgets::constrained(&comps, d, x) + } else { + comps.iter().enumerate().map(|(e, &c)| if e == d { x } else { c }).collect() + }; + out = Some(json!(next)); + } + } + rect + } ControlType::Point => { let mut a = v.as_vec2(); let r1 = ui.add(egui::DragValue::new(&mut a[0]).speed(1.0).max_decimals(1)); diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 7df3439b..15149560 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -3440,26 +3440,6 @@ fn fmt_num(v: f64, d: usize) -> String { format!("{v:.d$}") } -/// Linked values after component `d` of `c` became `v`: the others scale by the same ratio -/// (Constrain Proportions); from 0 the others that are 0 too follow the value. -fn constrained(c: &[f64], d: usize, v: f64) -> Vec { - let Some(&old) = c.get(d) else { return c.to_vec() }; - c.iter() - .enumerate() - .map(|(e, &x)| { - if e == d { - v - } else if old.abs() > 1e-9 { - x * v / old - } else if x.abs() <= 1e-9 { - v - } else { - x - } - }) - .collect() -} - /// Inline value editor for a property row; pushes `prop.set` actions. Returns the rects of a /// multi-dimension value's fields, one per dimension (none for other values). fn value_editor( @@ -3535,7 +3515,7 @@ fn value_editor( // Alt edits one value of a linked pair. let shown = if linked && !ui.input(|i| i.modifiers.alt) { n.min(c.len()) } else { 1 }; let from = if shown == 1 { d } else { 0 }; - for (e, v) in constrained(c.get(from..from + shown).unwrap_or_default(), d - from, nv).into_iter().enumerate() { + for (e, v) in widgets::constrained(c.get(from..from + shown).unwrap_or_default(), d - from, nv).into_iter().enumerate() { if let Some(slot) = nc.get_mut(from + e) { *slot = v; } diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 8f12e52b..fdb8287b 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -559,3 +559,23 @@ pub fn popup_is_open(ui: &Ui, id: egui::Id) -> bool { pub fn open_popup(ui: &Ui, id: egui::Id) { ui.data_mut(|d| d.insert_temp(id.with("open"), true)); } + +/// Linked values after component `d` of `c` became `v`: the others scale by the same ratio +/// (Constrain Proportions); from 0 the others that are 0 too follow the value. +pub(crate) fn constrained(c: &[f64], d: usize, v: f64) -> Vec { + let Some(&old) = c.get(d) else { return c.to_vec() }; + c.iter() + .enumerate() + .map(|(e, &x)| { + if e == d { + v + } else if old.abs() > 1e-9 { + x * v / old + } else if x.abs() <= 1e-9 { + v + } else { + x + } + }) + .collect() +} diff --git a/crates/ui-egui/tests/ui_panels.rs b/crates/ui-egui/tests/ui_panels.rs index d58c2146..0ecefa48 100644 --- a/crates/ui-egui/tests/ui_panels.rs +++ b/crates/ui-egui/tests/ui_panels.rs @@ -271,3 +271,47 @@ fn panels_snapshot() { img.save(format!("{dir}/{panel}{scope}.png")).unwrap(); } } + +/// Drag the automation element `id` horizontally by `dx` points. +fn drag_x(h: &mut Harness<'_, EffectcraftApp>, id: &str, dx: f32) { + let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}")).clone(); + let from: Pos2 = pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0); + h.input_mut().events.push(Event::PointerMoved(from)); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.step(); + for k in 1..=10 { + h.input_mut().events.push(Event::PointerMoved(from + egui::vec2(dx * k as f32 / 10.0, 0.0))); + h.step(); + } + let to = from + egui::vec2(dx, 0.0); + h.input_mut().events.push(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + h.run_steps(3); +} + +/// #338: an Essential Graphics Scale control keeps After Effects' Constrain Proportions link, on +/// by default: dragging X scales Y with it; unlinked, X moves alone. +#[test] +fn essential_graphics_scale_keeps_constrain_proportions() { + let mut h = harness(); + let layer = h.state().session.active_comp().unwrap().layers[0].id.0; + h.state_mut().session.execute("prop.set", json!({"layer": layer, "path": "transform/scale", "value": [100.0, 50.0]})).unwrap(); + let c = + h.state_mut().session.execute("essential.addProperty", json!({"layer": layer, "path": "transform/scale"})).unwrap()["controls"][0].as_u64().unwrap(); + open(&mut h, "essentialGraphics"); + let value = format!("essential.control.{c}.value"); + let scale = |h: &Harness<'_, EffectcraftApp>| { + let comp = h.state().session.active_comp().unwrap(); + let l = comp.layers.iter().find(|l| l.id.0 == layer).unwrap(); + l.props.prop("transform/scale").unwrap().value.components() + }; + assert!(h.state().auto.find(&format!("{value}.link")).is_some(), "the chain link is drawn"); + drag_x(&mut h, &format!("{value}.0"), 40.0); + let linked = scale(&h); + assert!(linked[0] > 100.0, "{linked:?}"); + assert!((linked[1] / linked[0] - 0.5).abs() < 1e-6, "Y follows X in proportion: {linked:?}"); + click(&mut h, &format!("{value}.link")); + drag_x(&mut h, &format!("{value}.0"), 40.0); + let unlinked = scale(&h); + assert!(unlinked[0] > linked[0] && unlinked[1] == linked[1], "unlinked, X moves alone: {unlinked:?}"); +} From 69bf806ff5acfae4028d30198057de76b7b40adf Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 19:16:48 +0000 Subject: [PATCH 053/107] =?UTF-8?q?Graph=20Editor:=20buttons=20that=20don'?= =?UTF-8?q?t=20fit=20the=20bar=20move=20to=20a=20=C2=BB=20menu=20instead?= =?UTF-8?q?=20of=20being=20cut=20off=20(#457)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Graph Editor's bottom bar lays out 17 text buttons in one row (about 1100 px) on a painter clipped to the graph area, so on a normal-width Timeline the right-hand ones (keyframe interpolation and easing) were cut off and unreachable. Buttons that would pass the bar's right edge now go, in order, to a » menu at its end; picking one there runs the same action on the next frame. Hidden buttons register as timeline.graph.more. while the menu is open. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f --- crates/ui-egui/src/panels/graph.rs | 121 ++++++++++++++++++++++----- crates/ui-egui/tests/ui_keyframes.rs | 55 ++++++++++++ 2 files changed, 155 insertions(+), 21 deletions(-) diff --git a/crates/ui-egui/src/panels/graph.rs b/crates/ui-egui/src/panels/graph.rs index 7f19d91b..2dbb9826 100644 --- a/crates/ui-egui/src/panels/graph.rs +++ b/crates/ui-egui/src/panels/graph.rs @@ -14,8 +14,8 @@ use egui::{Align2, Color32, Pos2, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; use serde_json::json; use super::timeline::TMap; -use crate::EffectcraftApp; use crate::theme::Tokens; +use crate::{EffectcraftApp, widgets}; type Actions = Vec<(String, serde_json::Value)>; @@ -162,10 +162,88 @@ fn curves<'a>(app: &EffectcraftApp, comp: &'a Comp, ectx: &EvalCtx, tm: TMap, pl out } -fn text_button(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, x: &mut f32, y: f32, label: &str, on: bool, id: &str, tip: &str) -> bool { +/// The Graph Editor's button bar, laid out left to right. Buttons that would run past the right +/// edge (a Timeline narrower than the whole row, #457) go to the "»" menu at its end instead of +/// being clipped, so every one stays reachable. +struct ButtonBar { + x: f32, + /// Where the next button must end to stay on the bar (the "»" button's room is kept free). + limit: f32, + /// Buttons moved to the "»" menu: (automation id, label, on, tooltip). + hidden: Vec<(String, String, bool, String)>, + /// The id picked from the "»" menu last frame, run by its button this frame. + chosen: Option, +} + +/// The "»" menu's popup and the id it picked (read by the next frame's buttons). +const MORE_POPUP: &str = "graph-more"; + +impl ButtonBar { + const MORE_W: f32 = 24.0; + + fn new(ctx: &egui::Context, bar: Rect) -> Self { + let chosen = ctx.data_mut(|d| d.remove_temp::(egui::Id::new(MORE_POPUP).with("chosen"))); + ButtonBar { x: bar.min.x + 8.0, limit: bar.max.x - 8.0 - Self::MORE_W - 4.0, hidden: Vec::new(), chosen } + } + + /// The "»" button and its menu, when buttons did not fit. + fn more(&mut self, app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, bar: Rect) { + if self.hidden.is_empty() { + return; + } + let t = app.tokens; + let r = Rect::from_min_size(pos2(bar.max.x - 8.0 - Self::MORE_W, bar.center().y - 9.0), vec2(Self::MORE_W, 18.0)); + let pid = egui::Id::new(MORE_POPUP); + let resp = ui.interact(r, pid.with("button"), Sense::click()).on_hover_text(crate::i18n::tr("More")); + p.rect_filled(r, 3.0, if resp.hovered() { t.hover } else { Color32::from_rgb(0x2c, 0x2c, 0x2c) }); + p.text(r.center(), Align2::CENTER_CENTER, "»", Tokens::ui(12.0), t.text); + app.auto.add("timeline.graph.more", r, "More"); + if resp.clicked() { + let open = widgets::popup_is_open(ui, pid); + ui.data_mut(|d| d.insert_temp(pid.with("open"), !open)); + } + if !widgets::popup_is_open(ui, pid) { + return; + } + let rows = self.hidden.len(); + let pos = r.left_top() - vec2(0.0, rows as f32 * 22.0 + 18.0); + let chosen = widgets::popup_list(ui, pid, pos, r, rows, 0, |ui| { + let mut chosen = None; + for (id, label, on, tip) in &self.hidden { + let item = ui.selectable_label(*on, label.as_str()).on_hover_text(tip.as_str()); + app.auto.add(&format!("timeline.graph.more.{id}"), item.rect, tip); + if item.clicked() { + chosen = Some(id.clone()); + } + } + chosen + }); + if let Some(id) = chosen { + ui.data_mut(|d| d.insert_temp(pid.with("chosen"), id)); + ui.ctx().request_repaint(); + } + } +} + +fn text_button( + app: &mut EffectcraftApp, + ui: &mut egui::Ui, + p: &egui::Painter, + bar: &mut ButtonBar, + y: f32, + label: &str, + on: bool, + id: &str, + tip: &str, +) -> bool { let t = app.tokens; let w = p.layout_no_wrap(label.to_string(), Tokens::ui(11.0), t.text).size().x + 14.0; - let r = Rect::from_min_size(pos2(*x, y - 9.0), vec2(w, 18.0)); + // Once one button overflows, the rest follow it into the menu (they keep their order). + if !bar.hidden.is_empty() || bar.x + w > bar.limit { + bar.hidden.push((id.to_string(), label.to_string(), on, tip.to_string())); + return bar.chosen.as_deref() == Some(id); + } + let r = Rect::from_min_size(pos2(bar.x, y - 9.0), vec2(w, 18.0)); let resp = ui.interact(r, egui::Id::new(("graph-btn", id)), Sense::click()).on_hover_text(tip); p.rect_filled( r, @@ -180,7 +258,7 @@ fn text_button(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, x ); p.text(r.center(), Align2::CENTER_CENTER, label, Tokens::ui(11.0), if on { Color32::WHITE } else { t.text }); app.auto.add(&format!("timeline.graph.{id}"), r, tip); - *x += w + 4.0; + bar.x += w + 4.0; resp.clicked() } @@ -446,14 +524,14 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte p.rect_filled(bar, 0.0, t.panel_bg); p.line_segment([bar.left_top(), bar.right_top()], Stroke::new(1.0, t.separator)); let y = bar.center().y; - let mut x = bar.min.x + 8.0; + let mut buttons = ButtonBar::new(&ctx, bar); let tl = &app.ui.timeline; let (auto_type, show_sel, auto_on) = (tl.graph_mode == "auto", tl.graph_show_selected, tl.graph_auto_zoom); if text_button( app, ui, p, - &mut x, + &mut buttons, y, "Auto", auto_type, @@ -463,45 +541,45 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte app.ui.timeline.graph_mode = if auto_type { if speed { "speed" } else { "value" } } else { "auto" }.into(); } // Choosing a graph type turns Auto-Select off, as in After Effects. - if text_button(app, ui, p, &mut x, y, "Value", !speed, "valueGraph", "Edit Value Graph") { + if text_button(app, ui, p, &mut buttons, y, "Value", !speed, "valueGraph", "Edit Value Graph") { app.ui.timeline.graph_mode = "value".into(); } - if text_button(app, ui, p, &mut x, y, "Speed", speed, "speedGraph", "Edit Speed Graph") { + if text_button(app, ui, p, &mut buttons, y, "Speed", speed, "speedGraph", "Edit Speed Graph") { app.ui.timeline.graph_mode = "speed".into(); } - x += 6.0; - if text_button(app, ui, p, &mut x, y, "Selected", show_sel, "showSelected", "Show Selected Properties (else all animated properties)") { + buttons.x += 6.0; + if text_button(app, ui, p, &mut buttons, y, "Selected", show_sel, "showSelected", "Show Selected Properties (else all animated properties)") { app.ui.timeline.graph_show_selected = !show_sel; } - if text_button(app, ui, p, &mut x, y, "Auto-zoom", auto_on, "autoZoom", "Auto-zoom graph height") { + if text_button(app, ui, p, &mut buttons, y, "Auto-zoom", auto_on, "autoZoom", "Auto-zoom graph height") { app.ui.timeline.graph_auto_zoom = !auto_on; } - if text_button(app, ui, p, &mut x, y, "Fit", false, "fit", "Fit all graphs to view") { + if text_button(app, ui, p, &mut buttons, y, "Fit", false, "fit", "Fit all graphs to view") { app.ui.timeline.graph_auto_zoom = true; app.ui.timeline.pps = None; } - if text_button(app, ui, p, &mut x, y, "Fit Selection", false, "fitSelection", "Fit selection to view") { + if text_button(app, ui, p, &mut buttons, y, "Fit Selection", false, "fitSelection", "Fit selection to view") { fit_selection(app, &cs, tm, plot); } - x += 6.0; + buttons.x += 6.0; let (snap, reference, tbox) = (app.ui.timeline.graph_snap, app.ui.timeline.graph_reference, app.ui.timeline.graph_transform_box); - if text_button(app, ui, p, &mut x, y, "Snap", snap, "snap", "Snap (key drags snap to the current time and other keys)") { + if text_button(app, ui, p, &mut buttons, y, "Snap", snap, "snap", "Snap (key drags snap to the current time and other keys)") { app.ui.timeline.graph_snap = !snap; } - if text_button(app, ui, p, &mut x, y, "Reference", reference, "reference", "Show Reference Graph") { + if text_button(app, ui, p, &mut buttons, y, "Reference", reference, "reference", "Show Reference Graph") { app.ui.timeline.graph_reference = !reference; } - if text_button(app, ui, p, &mut x, y, "Transform Box", tbox, "transformBox", "Show Transform Box when multiple keys are selected") { + if text_button(app, ui, p, &mut buttons, y, "Transform Box", tbox, "transformBox", "Show Transform Box when multiple keys are selected") { app.ui.timeline.graph_transform_box = !tbox; } - x += 6.0; + buttons.x += 6.0; let pos_sel = app.session.state.selected_props.iter().find_map(|(l, u)| super::timeline::is_position(comp.layer(*l)?, *u).then_some(l.0)); - if text_button(app, ui, p, &mut x, y, "Separate Dimensions", false, "separateDimensions", "Separate Dimensions (Position)") + if text_button(app, ui, p, &mut buttons, y, "Separate Dimensions", false, "separateDimensions", "Separate Dimensions (Position)") && let Some(l) = pos_sel.or(app.session.state.selected_layers.first().map(|l| l.0)) { actions.push(("prop.separateDimensions".into(), json!({"layer": l}))); } - x += 6.0; + buttons.x += 6.0; for (label, cmd, params, tip) in [ ("Hold", "keys.interpolation", json!({"interpolation": "hold"}), "Convert selected keyframes to Hold"), ("Linear", "keys.interpolation", json!({"interpolation": "linear"}), "Convert selected keyframes to Linear"), @@ -511,10 +589,11 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte ("Ease Out", "keys.easyEaseOut", json!({}), "Easy Ease Out (Ctrl+Shift+F9)"), ] { let id = cmd.trim_start_matches("keys.").to_string() + label.split_whitespace().next().unwrap_or(""); - if text_button(app, ui, p, &mut x, y, label, false, &id, tip) { + if text_button(app, ui, p, &mut buttons, y, label, false, &id, tip) { actions.push((cmd.into(), params)); } } + buttons.more(app, ui, p, bar); } /// Zoom time and value to the selected keys (and their handles). diff --git a/crates/ui-egui/tests/ui_keyframes.rs b/crates/ui-egui/tests/ui_keyframes.rs index 50ccf1ef..3ca85f8f 100644 --- a/crates/ui-egui/tests/ui_keyframes.rs +++ b/crates/ui-egui/tests/ui_keyframes.rs @@ -293,3 +293,58 @@ fn a_graph_handle_drag_moves_the_handles_of_every_selected_key() { assert_eq!(h.state().session.history.undo.len(), undo + 1, "one undo step"); assert!((handle(&h, 0).x - k0.x - px_per_s * 0.2).abs() < 2.0, "key 0's handle moved on screen"); } + +/// #457: on a Timeline narrower than the Graph Editor's whole button row, the buttons that don't +/// fit go to a "»" menu at the end of the bar instead of being cut off; each stays reachable and +/// works from there. +#[test] +fn graph_editor_buttons_that_do_not_fit_move_to_the_more_menu() { + let (mut h, _, _) = harness(); + h.set_size(egui::vec2(1280.0, 800.0)); + h.state_mut().ui.timeline.graph_editor = true; + h.run_steps(4); + let ids = [ + "autoSelectGraphType", + "valueGraph", + "speedGraph", + "showSelected", + "autoZoom", + "fit", + "fitSelection", + "snap", + "reference", + "transformBox", + "separateDimensions", + "interpolationHold", + "interpolationLinear", + "interpolationAuto", + "easyEaseEasy", + "easyEaseInEase", + "easyEaseOutEase", + ]; + let graph = h.state().auto.find("timeline.graph").expect("the Graph Editor is shown").rect; + let right = graph[0] + graph[2]; + let shown: Vec<&str> = ids.iter().copied().filter(|id| h.state().auto.find(&format!("timeline.graph.{id}")).is_some()).collect(); + for id in &shown { + let r = h.state().auto.find(&format!("timeline.graph.{id}")).unwrap().rect; + assert!(r[0] + r[2] <= right, "{id} is drawn past the bar's right edge"); + } + let more = h.state().auto.find("timeline.graph.more").expect("the buttons that don't fit are in a menu").rect; + assert!(more[0] + more[2] <= right); + let p = pos2(more[0] + more[2] / 2.0, more[1] + more[3] / 2.0); + let click = |h: &mut Harness<'_, EffectcraftApp>, p: Pos2| { + h.input_mut().events.push(Event::PointerMoved(p)); + h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + h.run_steps(3); + }; + click(&mut h, p); + let hidden: Vec<&str> = ids.iter().copied().filter(|id| h.state().auto.find(&format!("timeline.graph.more.{id}")).is_some()).collect(); + assert_eq!(shown.len() + hidden.len(), ids.len(), "every button is on the bar or in the menu: {shown:?} + {hidden:?}"); + assert!(hidden.contains(&"transformBox"), "{hidden:?}"); + let before = h.state().ui.timeline.graph_transform_box; + let item = h.state().auto.find("timeline.graph.more.transformBox").unwrap().rect; + click(&mut h, pos2(item[0] + item[2] / 2.0, item[1] + item[3] / 2.0)); + assert_eq!(h.state().ui.timeline.graph_transform_box, !before, "the menu item runs the button"); +} From 3bde85dfdb738dc1066c92d84b0c617e84b10919 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 19:16:48 +0000 Subject: [PATCH 054/107] Timeline: Ctrl/Cmd+wheel and a trackpad pinch zoom time (#428) egui turns Ctrl/Cmd+wheel and a pinch into a zoom factor (zoom_delta) rather than a scroll, and the Timeline only zoomed on Alt+wheel, so both did nothing over the layers. They now zoom time around the pointer, like Alt+wheel. (B stays Set Work Area Start as in After Effects; Split Layer is Ctrl/Cmd+Shift+D.) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f --- crates/ui-egui/src/panels/timeline.rs | 9 ++++++-- crates/ui-egui/tests/ui_timeline_zoom.rs | 28 ++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 15149560..ef2d40fe 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -1614,9 +1614,14 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let max_scroll = (total_h - rows_rect.height() + rh).max(0.0); if ui.rect_contains_pointer(rows_rect) { let (dy, dx, zoom) = ui.input(|i| (i.smooth_scroll_delta.y, i.smooth_scroll_delta.x, i.modifiers.alt)); - if zoom && dy.abs() > 0.0 { + // Ctrl/Cmd+wheel and a trackpad pinch reach egui as a zoom factor rather than a scroll + // (#428); like Alt+wheel they zoom time. + let pinch = ui.input(|i| i.zoom_delta()) as f64; + let at_x = ui.input(|i| i.pointer.hover_pos()).map(|p| p.x).unwrap_or(graph_x0); + if (pinch - 1.0).abs() > 1e-4 { + set_zoom(&mut app.ui.timeline, &comp, tm.pps * pinch, fit_pps, tm.t(at_x), (at_x - tm.x0) as f64); + } else if zoom && dy.abs() > 0.0 { // Alt+wheel zooms around the pointer, out until the whole comp shows. - let at_x = ui.input(|i| i.pointer.hover_pos()).map(|p| p.x).unwrap_or(graph_x0); set_zoom(&mut app.ui.timeline, &comp, tm.pps * (dy as f64 / 200.0).exp(), fit_pps, tm.t(at_x), (at_x - tm.x0) as f64); } else { let over_outline = ui.input(|i| i.pointer.hover_pos()).is_some_and(|p| p.x < graph_x0); diff --git a/crates/ui-egui/tests/ui_timeline_zoom.rs b/crates/ui-egui/tests/ui_timeline_zoom.rs index 48b19cb8..5fb16943 100644 --- a/crates/ui-egui/tests/ui_timeline_zoom.rs +++ b/crates/ui-egui/tests/ui_timeline_zoom.rs @@ -59,6 +59,34 @@ fn alt_wheel_zooms_out_until_the_whole_comp_shows() { assert!(h.state().ui.timeline.pps.is_some()); } +/// #428: Ctrl/Cmd+wheel and a trackpad pinch zoom time over the layers, as Alt+wheel does (egui +/// hands them over as a zoom factor, which the Timeline ignored). +#[test] +fn ctrl_wheel_and_pinch_zoom_time() { + let (mut h, layer) = harness(); + h.state_mut().ui.timeline.pps = Some(200.0); + h.run_steps(2); + let pps = |h: &Harness<'_, EffectcraftApp>| h.state().ui.timeline.pps.unwrap_or(0.0); + let start = pps(&h); + assert!(start > 0.0); + let at = over_bar(&h, layer); + h.event(Event::PointerMoved(at)); + h.event(Event::Zoom(0.5)); + h.step(); + let pinched = pps(&h); + assert!(pinched > 0.0 && pinched < start * 0.75, "a pinch in zooms out: {start} -> {pinched}"); + let at = over_bar(&h, layer); + h.event(Event::PointerMoved(at)); + h.event(Event::ModifiersChanged(Modifiers::COMMAND)); + h.event(Event::MouseWheel { unit: MouseWheelUnit::Point, delta: vec2(0.0, 240.0), modifiers: Modifiers::COMMAND, phase: egui::TouchPhase::Move }); + h.step(); + h.event(Event::ModifiersChanged(Modifiers::NONE)); + h.step(); + assert!(pps(&h) > pinched, "Ctrl/Cmd+wheel up zooms in: {pinched} -> {}", pps(&h)); + // Over the layers, so the rows did not scroll. + assert_eq!(h.state().ui.timeline.scroll_y, 0.0); +} + fn rect(h: &Harness<'_, EffectcraftApp>, id: &str) -> egui::Rect { let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}")).clone(); egui::Rect::from_min_size(pos2(e.rect[0], e.rect[1]), vec2(e.rect[2], e.rect[3])) From 485e4bb59dcf246ca13148153e0886472081c0a4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 19:17:26 +0000 Subject: [PATCH 055/107] Interface: Hangul is probed after the other scripts, so a catch-all face never draws Arabic (#616 follow-up) Without a Korean font the Hangul lookup can land on Unifont, which also covers Arabic but can't join it; registered first, it took Arabic too. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f --- crates/ui-egui/src/theme.rs | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index b12251ce..928dca9b 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -339,8 +339,8 @@ const SCRIPT_PROBES: &[(char, &str)] = &[ /// Japanese-locale machine would be drawn with the Japanese face. The interface language is the /// better signal, so the probe for its script comes first — kana for Japanese, a Han character for /// the Chinese catalogs. Both scripts are registered, so file, layer and template names in the -/// other one still render. Hangul comes next, since the Japanese and Chinese faces may lack it -/// (Hiragino Sans on macOS has none); then [`SCRIPT_PROBES`]. The web build has no system fonts +/// other one still render; then [`SCRIPT_PROBES`], then Hangul, since the Japanese and Chinese faces +/// may lack it (Hiragino Sans on macOS has none). The web build has no system fonts /// and installs nothing. fn install_script_fallbacks(family_fonts: &mut FontDefinitions, language: &str) -> Vec { #[cfg(target_arch = "wasm32")] @@ -355,16 +355,17 @@ fn install_script_fallbacks(family_fonts: &mut FontDefinitions, language: &str) // language's face draws them. const KANA: (char, &str) = ('あ', "japanese-system"); const HAN: (char, &str) = ('文', "chinese-system"); - // Hangul is probed after kana and Han so their order is unchanged; a face that already covers it - // is skipped as seen. + // Hangul is probed last: with no Korean font installed the lookup can land on a catch-all + // face (Unifont) that also covers Arabic or Indic text but can't shape it, so the + // script-specific faces must be registered ahead of it. A face already seen is skipped. const HANGUL: (char, &str) = ('한', "korean-system"); // Han text follows the interface language, not the operating system's locale. fonts::set_cjk_locale(cjk_locale(language)); let base = fonts::resolve("Inter", "Regular").face; - let cjk = if is_chinese(language) { [HAN, KANA, HANGUL] } else { [KANA, HAN, HANGUL] }; + let cjk = if is_chinese(language) { [HAN, KANA] } else { [KANA, HAN] }; let mut names = Vec::new(); let mut seen = Vec::new(); - for &(probe, name) in cjk.iter().chain(SCRIPT_PROBES) { + for &(probe, name) in cjk.iter().chain(SCRIPT_PROBES).chain(std::iter::once(&HANGUL)) { let id = fonts::fallback_for(probe, base); if seen.contains(&id) { continue; From 21b732875fb544d9a8842bece190b02f18a9456f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Kozlovsk=C3=BD?= <100806068+MatejKozlovsky@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:05:00 +0200 Subject: [PATCH 056/107] Render: Renderer::precomp_source and run_layer_effects_on, so an accelerator can render a precomp layer's nested comp itself (#640) --- crates/render/src/lib.rs | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 46e10866..d2ed0f4b 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -623,6 +623,30 @@ impl<'a> Renderer<'a> { } } + /// A precomp layer's nested comp for an accelerator to render resident (no CPU render and + /// upload of the nested frame): the renderer one level down, the comp, its time and the + /// buffer scale, exactly as [`Self::layer_source`]'s comp branch would render it. `None` + /// when that branch does more (composition proxy, Essential Properties overrides, frame + /// blending, recursion) or draws nothing (outside the nested comp's span): use the CPU path. + pub fn precomp_source(&self, ctx: &EvalCtx, layer: &Layer) -> Option<(Renderer<'a>, ItemId, Tick, f64)> { + let LayerSource::Comp { item } = layer.source else { return None }; + if layer.switches.adjustment || self.project.comp_contains(item, ctx.comp_id) { + return None; + } + let nc = self.project.comp(item)?; + let lt = ctx.nested_time(layer); + if self.content_times(ctx, layer).is_some() { + return None; + } + if !nc.covers(lt) || self.proxy_for(item).is_some() || !essential_overrides(ctx, layer).is_empty() || frame_blend_mode(ctx, layer) != FrameBlend::Off { + return None; + } + let s = self.opts.scale; + let bs = if s < 1.0 && nc.preserve_resolution { 1.0 } else { s }; + let base = self.nested_through(layer); + Some((Renderer { opts: RenderOpts { scale: bs, ..base.opts }, ..base }, item, lt, bs)) + } + /// The nested comp of a precomp layer whose transformations collapse into this comp: the /// Collapse Transformations switch is on and the layer has no masks, effects or layer /// styles (those force a flattened render of the precomp, as in After Effects). @@ -1988,6 +2012,15 @@ impl<'a> Renderer<'a> { self.run_effect_stack(ctx, layer, adjustment, usize::MAX, None, [0.0; 2], target); } + /// [`Renderer::run_effects_on`] for a (non-adjustment) layer's own pixels: `target` holds + /// them in effect space (as [`Renderer::layer_input`] hands them out) and the result stays + /// there. Returns the effect-space origin in layer coordinates (rebase by its negation). + pub fn run_layer_effects_on(&self, ctx: &EvalCtx, layer: &Layer, target: &mut dyn FxTarget) -> [f64; 2] { + let origin = ctx.effect_bounds(layer).1; + self.run_effect_stack(ctx, layer, false, usize::MAX, None, origin, target); + origin + } + /// An adjustment layer's footprint (its source with masks applied, layer space): where /// its effects show. `None` = the layer changes nothing. pub fn adjustment_footprint(&self, ctx: &EvalCtx, layer: &Layer) -> Option> { From 8321d099d25f113130b83e09fe939834026c4f32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Kozlovsk=C3=BD?= <100806068+MatejKozlovsky@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:05:00 +0200 Subject: [PATCH 057/107] GPU: precomp layers render their nested comp resident on the GPU instead of on the CPU and uploading it every frame (#640) --- crates/gpu/src/tests.rs | 40 ++++++++++++++++++++++++++ crates/gpu/src/walk.rs | 64 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+) diff --git a/crates/gpu/src/tests.rs b/crates/gpu/src/tests.rs index 39be29a9..0234f793 100644 --- a/crates/gpu/src/tests.rs +++ b/crates/gpu/src/tests.rs @@ -745,3 +745,43 @@ fn within_memory_returns_the_work_when_there_is_room() { let img = g.within_memory(|| g.ctx.image(64, 32)).expect("room for a 64×32 texture"); assert_eq!((img.width, img.height), (64, 32)); } + +/// Precomp layers (not collapsed) render their nested comp resident on the GPU +/// (`walk::draw_precomp`): match the CPU (nested blend modes, effects inside and outside, +/// transforms, opacity, a blend mode on the precomp layer, half resolution, +/// two instances at different times of the same nested comp). +#[test] +fn nested_precomps_render_resident() { + for depth in [BitDepth::Bpc8, BitDepth::Bpc32] { + let mut s = Scene::new(depth); + let inner = Comp::new(60, 40, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); + let inner_id = s.p.add_item("Inner", Label::Sandstone, None, ItemKind::Comp(inner.clone().into())); + { + let mut ins = Scene { p: std::mem::take(&mut s.p), cid: inner_id, comp: inner }; + let mut a = ins.footage(50, 30); + ins.effect(&mut a, "ec.blur.gaussian", &[("blurriness", n(3.0))]); + ins.push(a); + let mut b = ins.solid([0.2, 0.7, 0.4], 30, 20); + b.blend_mode = BlendMode::Difference; + set(&mut b, "transform/rotation", Value::Scalar(15.0)); + ins.push(b); + s.p = ins.p; + } + let bg = s.footage(97, 61); + s.push(bg); + let mut pre = build::layer(&mut s.p, &s.comp, "Inner", LayerSource::Comp { item: inner_id }, (60, 40), None); + set(&mut pre, "transform/rotation", Value::Scalar(25.0)); + set(&mut pre, "transform/opacity", Value::Scalar(80.0)); + s.effect(&mut pre, "ec.color.levels", &[("gamma", n(1.3))]); + pre.blend_mode = BlendMode::Screen; + s.push(pre); + // A second instance, later in the nested comp's time. + let mut pre2 = build::layer(&mut s.p, &s.comp, "Inner 2", LayerSource::Comp { item: inner_id }, (60, 40), None); + set(&mut pre2, "transform/position", v3(70.0, 40.0)); + pre2.start_time = Tick::from_seconds_f64(-0.5); + s.push(pre2); + check(&format!("nested precomps {depth:?}"), compare_at(&s, opts(), Tick::from_seconds_f64(0.2)), 0.0); + let half = RenderOpts { scale: 0.5, ..opts() }; + check(&format!("nested precomps half {depth:?}"), compare_at(&s, half, Tick::from_seconds_f64(0.2)), 0.0); + } +} diff --git a/crates/gpu/src/walk.rs b/crates/gpu/src/walk.rs index b5bd166c..e2b2910f 100644 --- a/crates/gpu/src/walk.rs +++ b/crates/gpu/src/walk.rs @@ -214,6 +214,9 @@ fn draw_layer(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, layer: &Layer, canvas: & if opacity <= 0.0 { return Some(()); } + if let Some(done) = draw_precomp(e, r, ctx, layer, canvas, opacity) { + return done; + } let Some(st) = r.styled_layer(ctx, layer) else { return Some(()) }; if st.plain { return composite_layer(e, r, ctx, layer, &st.body, canvas, opacity); @@ -243,6 +246,50 @@ fn draw_layer(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, layer: &Layer, canvas: & Some(()) } +/// A 2D precomp layer, its nested comp rendered resident on the GPU (`walk::render` one level +/// down) instead of by the CPU compositor and uploaded every frame (`Renderer::layer_source` +/// renders nested comps on the CPU: depth > 0). Then the layer's effects (resident), its +/// placement, track matte / Preserve Transparency and blend, as for any layer. `None`: not this +/// kind of layer, or the nested comp needs the CPU path (masks, layer styles, proxies, +/// overrides, frame blending, nested motion-blur sampling; see `Renderer::precomp_source`); +/// `Some(None)`: the GPU failed. +fn draw_precomp(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, layer: &Layer, canvas: &mut GpuImage, opacity: f32) -> Option> { + if layer.masks().is_some_and(|m| !m.children.is_empty()) || styles::active(ctx, layer) { + return None; + } + let (sub, item, lt, bs) = r.precomp_source(ctx, layer)?; + let Some(mut src) = render(e, &sub, item, lt) else { return Some(None) }; + let pipe = r.pipe(); + // `masked_source` quantises a non-solid source to the project's depth. + if let Some(l) = pipe.levels { + src = ops::quantize(e, &src, l); + } + let (mut img, offset, scale) = if layer.switches.effects && layer.effects().is_some_and(|f| !f.children.is_empty()) { + // Effects run in effect space (0.7.0): from the layer bounds' corner. + let o = ctx.effect_bounds(layer).1; + let mut fx = crate::effects::GpuFx::new(e, src, [o[0] * bs, o[1] * bs], bs, pipe.levels); + r.run_layer_effects_on(ctx, layer, &mut fx); + let Some((img, off, sc)) = fx.finish() else { return Some(None) }; + (img, [off[0] - o[0] * sc, off[1] - o[1] * sc], sc) + } else { + (src, [0.0; 2], bs) + }; + if img.width == 0 || img.height == 0 { + return Some(Some(())); + } + if let Some(c) = pipe.to_blend() { + img = ops::convert(e, &img, &c); + } + let shape = Buf { img: Image { width: img.width, height: img.height, data: Vec::new() }, offset, scale }; + if r.track_matte(ctx, layer).is_some() || layer.preserve_transparency { + let Some(iso) = place_src(e, r, ctx, layer, &shape, img, &e.zeros(canvas.width, canvas.height), BlendMode::Normal, 1.0, None) else { + return Some(None); + }; + return Some(composite_iso(e, r, ctx, layer, iso, canvas, opacity)); + } + Some(place_src(e, r, ctx, layer, &shape, img, canvas, layer.blend_mode, opacity, pipe.levels).map(|c| *canvas = c)) +} + /// Adjustment layer (`Renderer::draw_adjustment`): the effect stack runs on the comp below, /// resident on the GPU (non-GPU effects read back, run on the CPU and upload), and the result /// replaces the canvas inside the layer's footprint × opacity. `None` = draw it on the CPU. @@ -342,6 +389,23 @@ fn place( return Some(target.clone()); } let src = e.g.upload_buf(buf)?; + place_src(e, r, ctx, layer, buf, src, target, mode, opacity, levels) +} + +/// [`place`] of a GPU image `src` shaped like `buf`. +#[allow(clippy::too_many_arguments)] +fn place_src( + e: &mut Enc, + r: &Renderer, + ctx: &EvalCtx, + layer: &Layer, + buf: &Buf, + src: GpuImage, + target: &GpuImage, + mode: BlendMode, + opacity: f32, + levels: Option, +) -> Option { let pl = r.placement(ctx, layer, buf); if let [m] = pl.matrices.as_slice() { return Some(ops::warp_q(e, target, &src, m, pl.sampling, mode, opacity, pl.seed, None, levels)); From ced9e97c4a2127b5c0e756f4844fbc411065a2fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Kozlovsk=C3=BD?= <100806068+MatejKozlovsky@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:05:30 +0200 Subject: [PATCH 058/107] GPU: log why the GPU compositor declined a frame before the CPU renders it, instead of falling back silently (#640) --- crates/gpu/src/lib.rs | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 8879ab86..4d3f6c77 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -287,6 +287,17 @@ impl Gpu { } } + /// Why a frame was not rendered (best effort: device health, else size / support). + fn decline_reason(&self) -> String { + match self.ctx.check_health() { + Err(e) => format!("device failure / out of memory: {e}"), + Ok(()) => format!( + "the GPU compositor did not handle the frame (unsupported content, a frame over the {} px texture limit, or an allocation failed)", + self.ctx.max_dim + ), + } + } + /// Wait for submitted GPU work with the readback deadline (benchmarks). pub fn wait(&self) { let _ = self.ctx.poll_readbacks(wgpu::PollType::Wait { submission_index: None, timeout: Some(crate::readback::TIMEOUT) }); @@ -299,7 +310,13 @@ impl Accelerator for Gpu { } fn comp_frame(&self, r: &Renderer, comp: ItemId, t: Tick) -> Option { - self.render(r, comp, t) + let img = self.render(r, comp, t); + if img.is_none() { + // The caller renders the frame on the CPU; say so and why, or an export that falls + // back frame after frame (a nested comp the GPU declines) is only mysteriously slow. + log::warn!(target: "effectcraft_gpu", "GPU declined the frame at {:.3} s, the CPU renders it: {}", t.seconds(), self.decline_reason()); + } + img } fn supports_effect(&self, id: &str) -> bool { From e96638093c60e15f36f6eab400e1ecbe424c0cb0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 19:32:18 +0000 Subject: [PATCH 059/107] Workspaces: saved workspaces last between sessions (#320) Window > Workspace > Save as New Workspace and Save Changes to this Workspace kept the layout only in memory (`UiState::saved_workspaces` / `saved_floating`): nothing wrote them, so they were gone after a restart. They are now written to `workspaces.json` in the settings store (the config folder on desktop, OPFS on the web) when a workspace is saved, renamed or deleted, and read back when the app starts; a file that doesn't read is ignored. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f --- crates/ui-egui/src/lib.rs | 39 +++++++++++++++++++++++++++++++ crates/ui-egui/src/menus.rs | 4 ++++ crates/ui-egui/tests/ui_panels.rs | 36 ++++++++++++++++++++++++++++ 3 files changed, 79 insertions(+) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index dd0efc0c..1ca35f26 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -295,6 +295,17 @@ pub struct EffectcraftApp { pub(crate) template_list: Option<(Vec, Vec)>, } +/// The saved workspaces in the settings store ([`effectcraft_engine::config::ConfigStore`]). +pub const WORKSPACES_FILE: &str = "workspaces.json"; + +#[derive(serde::Serialize, serde::Deserialize)] +struct SavedWorkspaces { + #[serde(default)] + workspaces: std::collections::BTreeMap, + #[serde(default)] + floating: std::collections::BTreeMap>, +} + impl EffectcraftApp { pub fn new(session: Session) -> Self { EffectcraftApp { @@ -353,6 +364,34 @@ impl EffectcraftApp { template_list: None, } .with_ui_commands() + .with_saved_workspaces() + } + + /// The workspaces saved in an earlier session (Window ▸ Workspace ▸ Save as New Workspace, + /// Save Changes to this Workspace), from the settings store. A file that doesn't read is + /// ignored. + fn with_saved_workspaces(mut self) -> Self { + let text = self.session.config.as_ref().and_then(|c| c.read(WORKSPACES_FILE)); + match text.map(|t| serde_json::from_str::(&t)) { + Some(Ok(w)) => { + self.ui.saved_workspaces = w.workspaces; + self.ui.saved_floating = w.floating; + } + Some(Err(e)) => log::warn!("{WORKSPACES_FILE}: {e}"), + None => {} + } + self + } + + /// Keep the saved workspaces for the next session (see [`EffectcraftApp::new`]): After + /// Effects' saved workspaces last until they are deleted (#320). + pub(crate) fn store_saved_workspaces(&self) { + let Some(c) = &self.session.config else { return }; + let w = SavedWorkspaces { workspaces: self.ui.saved_workspaces.clone(), floating: self.ui.saved_floating.clone() }; + let written = serde_json::to_string(&w).map_err(std::io::Error::other).and_then(|t| c.write(WORKSPACES_FILE, &t)); + if let Err(e) = written { + log::warn!("{WORKSPACES_FILE}: {e}"); + } } /// Offer the frontend's commands (tools, timeline navigation…) for keyboard shortcuts. diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 7851779f..6b21c0c5 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -943,6 +943,7 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu let name = app.ui.workspace.clone(); app.ui.saved_workspaces.insert(name.clone(), app.ui.dock.clone()); app.ui.saved_floating.insert(name.clone(), app.ui.floating.clone()); + app.store_saved_workspaces(); json!({"workspace": name}) } "window.saveWorkspaceAs" => { @@ -959,6 +960,7 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu app.ui.saved_workspaces.insert(name.clone(), app.ui.dock.clone()); app.ui.saved_floating.insert(name.clone(), app.ui.floating.clone()); app.ui.workspace = name.clone(); + app.store_saved_workspaces(); json!({"workspace": name}) } "window.editWorkspaces" => { @@ -983,6 +985,7 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu if app.ui.saved_workspaces.remove(&name).is_none() && !builtin { return Err(format!("no workspace `{name}`")); } + app.store_saved_workspaces(); if app.ui.workspace == name { app.set_workspace("Default"); } @@ -1000,6 +1003,7 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu if app.ui.workspace == name { app.ui.workspace = new.to_string(); } + app.store_saved_workspaces(); return Ok(json!({"renamed": new})); } json!({"workspaces": app.workspace_names()}) diff --git a/crates/ui-egui/tests/ui_panels.rs b/crates/ui-egui/tests/ui_panels.rs index d58c2146..920e4948 100644 --- a/crates/ui-egui/tests/ui_panels.rs +++ b/crates/ui-egui/tests/ui_panels.rs @@ -117,6 +117,42 @@ fn hover(h: &mut Harness<'_, EffectcraftApp>, p: Pos2) { h.run_steps(3); } +/// Issue #320: saved workspaces last between sessions (they were only kept in memory): a new +/// app on the same settings store lists them and brings their layout back; renamed and deleted +/// ones stay that way. +#[test] +fn saved_workspaces_persist_across_sessions() { + use effectcraft_engine::config::{ConfigStore, MemoryConfig}; + use effectcraft_ui_egui::menus::invoke; + let store = std::sync::Arc::new(MemoryConfig::default()); + let launch = || { + let s = Session { config: Some(store.clone()), ..Default::default() }; + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.run_steps(2); + h + }; + let mut h = launch(); + let ctx = h.ctx.clone(); + invoke(h.state_mut(), &ctx, "window.workspace", json!({"name": "Minimal"})).unwrap(); + invoke(h.state_mut(), &ctx, "window.saveWorkspaceAs", json!({"name": "My Layout"})).unwrap(); + invoke(h.state_mut(), &ctx, "window.saveWorkspaceAs", json!({"name": "Scratch"})).unwrap(); + let saved = h.state().ui.dock.clone(); + drop(h); + let mut h = launch(); + assert_eq!(h.state().saved_workspace_names(), ["My Layout", "Scratch"]); + assert_eq!(h.state().ui.workspace, "Default"); + let ctx = h.ctx.clone(); + invoke(h.state_mut(), &ctx, "window.workspace", json!({"name": "My Layout"})).unwrap(); + assert_eq!(h.state().ui.dock, saved); + invoke(h.state_mut(), &ctx, "window.editWorkspaces", json!({"name": "Scratch", "delete": true})).unwrap(); + invoke(h.state_mut(), &ctx, "window.editWorkspaces", json!({"name": "My Layout", "rename": "Edit Bay"})).unwrap(); + drop(h); + assert_eq!(launch().state().saved_workspace_names(), ["Edit Bay"]); + // A damaged file is ignored. + store.write(effectcraft_ui_egui::WORKSPACES_FILE, "{not json").unwrap(); + assert!(launch().state().saved_workspace_names().is_empty()); +} + /// Issue #191: a workspace saved with Save as New Workspace is listed in Window ▸ Workspace /// (below the built-ins, where the submenu used to be cut off) and choosing it there brings its /// layout back. From fbe0f7e1ce6a9e35720a7279fb43c8b5bd314a3e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 19:32:18 +0000 Subject: [PATCH 060/107] Cache bars: cached frames show at 1/3 resolution (#595) The cache bars count the frames of `shown_series`, which rebuilds the series from the frame key's scale in thousandths (333 -> 0.333). The key's `opts` hashed the render options with the f64 scale, and the frames had been rendered at 0.3333..., so the series never matched and the bars stayed empty at Third (and any resolution that isn't a whole number of thousandths, such as Custom 1/7). `opts_hash` now hashes the scale rounded to the key's thousandths, which the key carries anyway. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f --- crates/ui-egui/src/frames.rs | 5 ++++- crates/ui-egui/tests/ui_ram_preview.rs | 20 ++++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/frames.rs b/crates/ui-egui/src/frames.rs index 55c75f0b..33fd676d 100644 --- a/crates/ui-egui/src/frames.rs +++ b/crates/ui-egui/src/frames.rs @@ -280,8 +280,11 @@ fn comp_content_with(project: &Project, comp: ItemId, addr: impl Fn(ItemId, &Arc h.finish() } -/// Hash of render options (part of [`FrameKey`] and of the disk key). +/// Hash of render options (part of [`FrameKey`] and of the disk key). The scale counts as the +/// key's thousandths ([`FrameKey::scale`]): a series rebuilt from the key (the cache bars) has +/// the same options as the frames rendered at 1/3 = 0.333… (#595). pub fn opts_hash(o: &RenderOpts) -> u64 { + let o = RenderOpts { scale: (o.scale * 1000.0).round() / 1000.0, ..*o }; let mut h = disk_cache::Hash128::default(); h.write(format!("{o:?}").as_bytes()); h.finish() as u64 diff --git a/crates/ui-egui/tests/ui_ram_preview.rs b/crates/ui-egui/tests/ui_ram_preview.rs index 84610b7c..5459be8c 100644 --- a/crates/ui-egui/tests/ui_ram_preview.rs +++ b/crates/ui-egui/tests/ui_ram_preview.rs @@ -125,3 +125,23 @@ fn prefetch_waits_while_edits_keep_coming() { let series = h.state().shown_series(ItemId(c)); assert!(h.state().frames.cached_frames(&series).len() > 1, "prefetch resumed"); } + +/// #595: at Third (and other resolutions that aren't a whole number of thousandths) the cache +/// bars find the rendered frames. The series they count was rebuilt from the frame key's scale +/// (333 → 0.333), whose render options hashed apart from the frames rendered at 0.3333…. +#[test] +fn cached_frames_are_counted_at_one_third_resolution() { + use effectcraft_ui_egui::state::Resolution; + for res in [Resolution::Third, Resolution::Custom(7)] { + let mut s = Session::default(); + let c = s.execute("comp.new", json!({"name": "Third", "width": 64, "height": 36, "duration": 1})).unwrap()["comp"].as_u64().unwrap(); + s.execute("layer.newSolid", json!({"color": "#3080ff"})).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.state_mut().ui.viewer.res = res; + settle(&mut h); + let series = h.state().shown_series(ItemId(c)); + assert_eq!(series.scale, (res.scale(1.0, 1.0) * 1000.0).round() as u32, "{res:?}"); + assert!(shown_cached(&h, c), "{res:?}: the shown frame counts as cached"); + assert!(!h.state().frames.cached_frames(&series).is_empty(), "{res:?}"); + } +} From 7e0e641606efe5f78b7496a4ffe3888293539166 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 18:30:46 +0000 Subject: [PATCH 061/107] Edit: Copy with a property selected copies that property, and Paste sets it on the selected layers (#493) Cause: edit.copy handled text editing, selected keyframes, effects and shape items, then fell back to copying the selected layers. Selecting a property selects its keys, so an animated property copied, but an unanimated one (or the Transform group, whose keys prop.select doesn't select) copied the whole layer, and Paste added a duplicate layer instead of setting the property. Fix: with properties (or the Transform group) selected, Edit > Copy copies, as After Effects does, each animated property's keys (the selected ones, else all of them, through keys.copy) and each unanimated property's value into a new value clipboard keyed by match path. Edit > Paste sets those values on the same properties of the selected layers (a key at the CTI where the target is animated) and pastes the keys at the CTI, as one undo step (keys.paste now honours `merge`). Keys alone still answer with their count, as before. Test: copy_a_selected_property_pastes_onto_the_same_property (static Position, then the Transform group with animated Opacity: values and keys land on layer B, no layer is added, one undo step each). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f (cherry picked from commit 8267519ebe20e889196e538de23b85b04a1fa76a) --- crates/engine/src/commands/anim.rs | 2 +- crates/engine/src/commands/edit.rs | 110 ++++++++++++++++++++++++++- crates/engine/src/lib.rs | 4 + crates/engine/src/tests_menu_cmds.rs | 47 ++++++++++++ 4 files changed, 160 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/commands/anim.rs b/crates/engine/src/commands/anim.rs index 3d087e07..3ea27b0d 100644 --- a/crates/engine/src/commands/anim.rs +++ b/crates/engine/src/commands/anim.rs @@ -105,7 +105,7 @@ fn paste(s: &mut Session, p: &Value) -> Result { if plan.is_empty() { return Err(bad("keys.paste", "no compatible property to paste into (select a property of the same type)")); } - s.edit("Paste Keyframes", None, |proj, st| { + s.edit("Paste Keyframes", merge_p(p), |proj, st| { let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; let mut sel = vec![]; for (lid, uid, ci) in &plan { diff --git a/crates/engine/src/commands/edit.rs b/crates/engine/src/commands/edit.rs index 45ab1395..e5d2f912 100644 --- a/crates/engine/src/commands/edit.rs +++ b/crates/engine/src/commands/edit.rs @@ -6,7 +6,7 @@ use effectcraft_time::Tick; use serde_json::{Value, json}; use super::{CommandSpec, has_comp, has_layers, layers_p, match_path_of, selected_leaf_props, str_p}; -use crate::{EngineError, LinkClip, Result, Session, cmd}; +use crate::{EngineError, KeyRef, LinkClip, Result, Session, cmd}; fn can_undo(s: &Session) -> std::result::Result<(), String> { if s.history.undo.is_empty() { Err("nothing to undo".into()) } else { Ok(()) } @@ -25,6 +25,7 @@ fn has_clip(s: &Session) -> std::result::Result<(), String> { && s.state.effect_clipboard.is_empty() && s.state.contents_clipboard.is_empty() && s.state.link_clipboard.is_none() + && s.state.value_clipboard.is_empty() { Err("the clipboard is empty".into()) } else { @@ -210,14 +211,21 @@ fn clear_clipboards(s: &mut Session) { s.state.effect_clipboard.clear(); s.state.contents_clipboard.clear(); s.state.link_clipboard = None; + s.state.value_clipboard.clear(); s.state.clip_is_keys = false; } -/// Edit ▸ Copy: selected keyframes (when any) go to the keyframe clipboard, else layers. +/// Edit ▸ Copy: selected properties copy their keyframes and values, selected keyframes go +/// to the keyframe clipboard, else layers. fn copy(s: &mut Session, p: &Value) -> Result { if s.state.text_edit.is_some() && p.get("layers").is_none() { return super::text_edit::copy(s); } + if p.get("layers").is_none() + && let Some(r) = copy_properties(s)? + { + return Ok(r); + } // Keyframes selected → copy keys (pasted at the CTI). if !s.state.selected_keys.is_empty() && p.get("layers").is_none() { s.state.link_clipboard = None; @@ -247,6 +255,101 @@ fn copy(s: &mut Session, p: &Value) -> Result { Ok(json!(s.state.clipboard.len())) } +/// Edit ▸ Copy with properties selected (Position, or the Transform group), as in After +/// Effects: an animated property copies its keyframes (the selected ones, else all of them) and +/// an unanimated one its value, for Paste onto the same properties of other layers (#493). +/// `None` when no property (or Transform group) is selected. +fn copy_properties(s: &mut Session) -> Result> { + let Some(comp) = s.active_comp() else { return Ok(None) }; + let mut leaves = vec![]; + for (lid, uid) in &s.state.selected_props { + let Some(l) = comp.layer(*lid) else { continue }; + if l.props.find(*uid).is_some() { + leaves.push((*lid, *uid)); + } else if let Some(g) = l.props.find_group(*uid) + && l.props.match_path_of(*uid).as_deref() == Some("transform") + { + g.walk("", &mut |_, pr| leaves.push((*lid, pr.uid))); + } + } + if leaves.is_empty() { + return Ok(None); + } + let mut keys = s.state.selected_keys.clone(); + let mut values = vec![]; + for (lid, uid) in &leaves { + let Some(l) = comp.layer(*lid) else { continue }; + let Some(pr) = l.props.find(*uid) else { continue }; + if pr.keys.is_empty() { + if let Some(path) = l.props.match_path_of(*uid) + && !values.iter().any(|(p, _)| *p == path) + { + values.push((path, pr.value.clone())); + } + } else if !keys.iter().any(|k| k.layer == *lid && k.prop == *uid) { + keys.extend(pr.keys.iter().map(|k| KeyRef { layer: *lid, prop: *uid, time: k.time })); + } + } + clear_clipboards(s); + let mut n = 0; + if !keys.is_empty() { + let selected = std::mem::replace(&mut s.state.selected_keys, keys); + let r = s.execute("keys.copy", json!({})); + s.state.selected_keys = selected; + n = r?.as_u64().unwrap_or(0); + } + let v = values.len(); + s.state.value_clipboard = values; + // Keys alone answer as `keys.copy` does (their count). + Ok(Some(if v == 0 { json!(n) } else { json!({"keys": n, "values": v}) })) +} + +/// Paste copied property values (and keyframes, at the CTI) onto the same properties of the +/// selected layers, as one undo step. +fn paste_properties(s: &mut Session, p: &Value) -> Result { + let cid = s.active_comp_id().ok_or(EngineError::NoComp)?; + let ids = s.state.selected_layers.clone(); + if ids.is_empty() { + return Err(EngineError::Other("select a layer to paste into".into())); + } + let values = s.state.value_clipboard.clone(); + let keys = s.state.clip_is_keys && !s.state.key_clipboard.is_empty(); + let t = s.time(); + // The values and the keys are one step: the keys' paste merges into the values' step. + let merge = "edit.paste.properties"; + s.history.merge_key = None; + let n = s.edit("Paste", keys.then_some(merge), |proj, _| { + let mut n = 0; + for lid in &ids { + let l = super::layer_mut(proj, cid, *lid)?; + if l.switches.locked { + continue; + } + let lt = l.layer_time(t); + for (path, v) in &values { + if let Some(pr) = l.props.prop_mut(path) + && let Some(v) = pr.value.coerce_json(&v.to_json()) + { + pr.set_value_at(lt, v); + n += 1; + } + } + } + Ok(n) + })?; + let mut r = json!({"values": n}); + if keys { + let mut kp = p.clone(); + if let Some(o) = kp.as_object_mut() { + o.insert("merge".into(), json!(merge)); + } + let pasted = s.execute("keys.paste", kp); + s.history.merge_key = None; + r["keys"] = pasted?; + } + Ok(r) +} + /// The expression accessor for a property, e.g. `comp("Main").layer("Solid").transform("Position")`. pub(crate) fn link_expression(s: &Session, lid: LayerId, uid: effectcraft_project::Uid, relative: bool) -> Option { let cid = s.active_comp_id()?; @@ -392,6 +495,9 @@ fn paste(s: &mut Session, p: &Value) -> Result { if let Some(clip) = s.state.link_clipboard.clone() { return paste_links(s, clip); } + if !s.state.value_clipboard.is_empty() { + return paste_properties(s, p); + } if s.state.clip_is_keys && !s.state.key_clipboard.is_empty() { return s.execute("keys.paste", p.clone()); } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index d09cbf2b..eae96a81 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -216,6 +216,10 @@ pub struct EditorState { /// The last copy was keyframes (Paste pastes keys at the CTI). #[serde(skip)] pub clip_is_keys: bool, + /// Values of selected unanimated properties (match path, value), copied with Edit ▸ Copy; + /// Paste sets the same properties of the selected layers. + #[serde(skip)] + pub value_clipboard: Vec<(String, effectcraft_keyframe::Value)>, /// Copied effect instances (Edit ▸ Copy with effects selected); Paste adds them to the /// selected layers. #[serde(skip)] diff --git a/crates/engine/src/tests_menu_cmds.rs b/crates/engine/src/tests_menu_cmds.rs index df0af369..c7252acb 100644 --- a/crates/engine/src/tests_menu_cmds.rs +++ b/crates/engine/src/tests_menu_cmds.rs @@ -346,6 +346,53 @@ fn keyframe_clipboard_paste_and_paste_reversed() { assert_eq!((keys[0].value.as_f64(), keys[1].value.as_f64()), (0.0, 100.0)); } +/// #493: Edit ▸ Copy with a property selected copies that property (its value when it isn't +/// animated, its keys when it is), and Paste sets the same property of the selected layer, as +/// in After Effects, instead of copying the whole layer. +#[test] +fn copy_a_selected_property_pastes_onto_the_same_property() { + let mut s = comp(); + let a = solid(&mut s, "#ff0000"); + let b = solid(&mut s, "#00ff00"); + let n_layers = s.active_comp().unwrap().layers.len(); + s.execute("prop.set", json!({"layer": a, "path": "transform/position", "value": [10, 20]})).unwrap(); + s.execute("prop.set", json!({"layer": a, "path": "transform/rotation", "value": 30})).unwrap(); + s.execute("prop.select", json!({"layer": a, "path": "transform/position"})).unwrap(); + s.execute("edit.copy", json!({})).unwrap(); + assert!(s.state.clipboard.is_empty(), "the layer isn't copied"); + s.execute("layer.select", json!({"layers": [b]})).unwrap(); + let undo = s.history.undo.len(); + s.execute("edit.paste", json!({})).unwrap(); + assert_eq!(s.active_comp().unwrap().layers.len(), n_layers); + assert_eq!(layer(&s, b).props.prop("transform/position").unwrap().value.as_vec3()[..2], [10.0, 20.0]); + assert_eq!(layer(&s, b).props.prop("transform/rotation").unwrap().value.as_f64(), 0.0, "only the copied property"); + s.execute("edit.undo", json!({})).unwrap(); + assert_ne!(layer(&s, b).props.prop("transform/position").unwrap().value.as_vec3()[..2], [10.0, 20.0]); + assert_eq!(s.history.undo.len(), undo); + // The Transform group with an animated Opacity: its keys (at the CTI) and the other values, + // in one undo step. + s.execute("prop.toggleAnimation", json!({"layer": a, "path": "transform/opacity"})).unwrap(); + s.execute("time.set", json!({"time": 1.0})).unwrap(); + s.execute("prop.set", json!({"layer": a, "path": "transform/opacity", "value": 0})).unwrap(); + let transform = layer(&s, a).props.sub("transform").unwrap().uid; + s.execute("prop.select", json!({"layer": a, "prop": transform})).unwrap(); + s.execute("edit.copy", json!({})).unwrap(); + s.execute("layer.select", json!({"layers": [b]})).unwrap(); + s.execute("time.set", json!({"time": 2.0})).unwrap(); + let undo = s.history.undo.len(); + s.execute("edit.paste", json!({})).unwrap(); + assert_eq!(s.history.undo.len(), undo + 1); + let l = layer(&s, b); + let keys = &l.props.prop("transform/opacity").unwrap().keys; + assert_eq!(keys.len(), 2); + assert!((keys[0].time.seconds() - 2.0).abs() < 1e-6 && keys[1].value.as_f64() == 0.0); + assert_eq!(l.props.prop("transform/rotation").unwrap().value.as_f64(), 30.0); + assert_eq!(s.active_comp().unwrap().layers.len(), n_layers); + s.execute("edit.undo", json!({})).unwrap(); + let l = layer(&s, b); + assert!(l.props.prop("transform/opacity").unwrap().keys.is_empty() && l.props.prop("transform/rotation").unwrap().value.as_f64() == 0.0); +} + #[test] fn property_links_and_expression_only() { let mut s = comp(); From 77dfd44658421e6107b39d1f51d87bde586f25ae Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 18:30:46 +0000 Subject: [PATCH 062/107] Puppet: a pin added to a bent mesh holds it where it is; nothing jumps (#318) Cause: puppet::solve binds each moving pin to its nearest mesh vertex and fixes that vertex at position + (vertex - rest), an offset in rest space with no rotation or scale. puppet.addPin set a new pin's position to the click, so in a bent region the bound vertex was pulled to where it would be unrotated, and the mesh jumped as soon as the pin was added. Fix: addPin picks the position that keeps the bound vertex at its current deformed place: the vertex's deformed position minus the rest offset. On an unbent mesh that is the click itself (round-off is snapped back to it), so existing pins behave as before. Test: a_pin_added_to_a_bent_mesh_moves_nothing (two pins, one moved to bend the layer, then pins added at three places on the bent part: at most a few edge pixels change from solver round-off; before the fix about 250 did). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f (cherry picked from commit 16151203b304d565b425873fdd33ae082cae483b) --- crates/engine/src/commands/puppet.rs | 32 ++++++++++++++++++++++------ crates/engine/src/tests_paint.rs | 22 +++++++++++++++++++ 2 files changed, 48 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/commands/puppet.rs b/crates/engine/src/commands/puppet.rs index a333031e..320e1d41 100644 --- a/crates/engine/src/commands/puppet.rs +++ b/crates/engine/src/commands/puppet.rs @@ -21,6 +21,10 @@ use serde_json::{Value, json}; use super::{CommandSpec, bad, f_p, has_comp, layer_mut, layer_p, merge_p, str_p}; use crate::{EngineError, Result, Session, cmd}; +fn dist2(a: [f64; 2], b: [f64; 2]) -> f64 { + (a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) +} + fn pt(v: Option<&Value>) -> Option<[f64; 2]> { let a = v?.as_array()?; Some([a.first()?.as_f64()?, a.get(1)?.as_f64()?]) @@ -77,7 +81,8 @@ fn add_pin(s: &mut Session, p: &Value) -> Result { let fx_uid = find_puppet(s, cid, lid); let want_mesh = p.get("mesh").and_then(Value::as_u64); let new_mesh = p.get("newMesh").and_then(Value::as_bool).unwrap_or(false); - let mut target: Option<(Uid, [f64; 2])> = None; + // (mesh, the pin's rest point, where it is now) + let mut target: Option<(Uid, [f64; 2], [f64; 2])> = None; if let Some(fu) = fx_uid && !new_mesh && let Some((buf, params)) = puppet_eval(s, cid, lid, fu, t) @@ -90,7 +95,22 @@ fn add_pin(s: &mut Session, p: &Value) -> Result { let inside = puppet::contains(mesh, def, at); let near = def.iter().map(|v| (v[0] - at[0]).hypot(v[1] - at[1])).fold(f64::MAX, f64::min) < 12.0; if inside || near || want_mesh.is_some() { - target = Some((*uid, puppet::unmap(mesh, def, at))); + let rest = puppet::unmap(mesh, def, at); + // A moving pin holds its nearest mesh vertex at `position + (vertex - rest)` + // (puppet::solve). Choose the position that keeps that vertex where it is now, so + // adding a pin to a bent mesh moves nothing (After Effects, #318); on an unbent + // mesh that is the click itself. + let now = (0..mesh.verts.len().min(def.len())) + .filter(|_| kind.moves()) + .min_by(|a, b| dist2(mesh.verts[*a], rest).total_cmp(&dist2(mesh.verts[*b], rest))) + .and_then(|v| { + let (r, d) = (mesh.verts.get(v)?, def.get(v)?); + Some([d[0] - (r[0] - rest[0]), d[1] - (r[1] - rest[1])]) + }) + // Round-off only (an unbent mesh): exactly the click. + .filter(|n| dist2(*n, at) > 1e-12) + .unwrap_or(at); + target = Some((*uid, rest, now)); break; } } @@ -131,14 +151,14 @@ fn add_pin(s: &mut Session, p: &Value) -> Result { } }; let pg = fxg.find_group_mut(fx_uid).ok_or_else(|| bad(cmd, "Puppet effect gone"))?; - let (mesh_uid, rest) = match target { - Some((m, r)) => (m, r), + let (mesh_uid, rest, now) = match target { + Some(t) => t, None => { let n = puppet::meshes(pg).count(); let g = puppet::mesh_group(&mut ids, &format!("Mesh {}", n + 1), at, &opts); let u = g.uid; pg.children.push(g.into()); - (u, at) + (u, at, at) } }; let name = puppet::next_pin_name(pg, kind); @@ -146,7 +166,7 @@ fn add_pin(s: &mut Session, p: &Value) -> Result { let mut pin = puppet::pin_group(&mut ids, &name, kind, rest); if let Some(pos) = pin.get_mut("position") { if kind.moves() { - pos.value = KV::Vec2(at); + pos.value = KV::Vec2(now); pos.set_animated(true, lt); } else { pos.value = KV::Vec2(rest); diff --git a/crates/engine/src/tests_paint.rs b/crates/engine/src/tests_paint.rs index 4e05027a..db831ca9 100644 --- a/crates/engine/src/tests_paint.rs +++ b/crates/engine/src/tests_paint.rs @@ -203,6 +203,28 @@ fn first_puppet_pin_leaves_the_layer_still() { } } +/// #318: a pin added to an already-bent mesh holds the mesh where it is: nothing jumps. +#[test] +fn a_pin_added_to_a_bent_mesh_moves_nothing() { + let (mut s, id) = setup(); + s.execute("puppet.addPin", json!({"layer": id, "position": [5, 30]})).unwrap(); + s.execute("puppet.addPin", json!({"layer": id, "position": [95, 30]})).unwrap(); + s.set_time(Tick::from_seconds_f64(1.0)); + s.execute("puppet.movePin", json!({"layer": id, "pin": "Puppet Pin 2", "position": [80, 70]})).unwrap(); + let cid = s.active_comp_id().unwrap(); + let t = Tick::from_seconds_f64(1.0); + let before = s.render(cid, t, RenderOpts::default()); + for at in [[50, 54], [30, 42], [70, 62]] { + s.execute("puppet.addPin", json!({"layer": id, "position": at})).unwrap(); + let after = s.render(cid, t, RenderOpts::default()); + let moved = after.data.iter().zip(&before.data).filter(|(a, b)| (a[3] - b[3]).abs() > 0.25).count(); + // (Before #318 the pin at [50, 54] moved about 250 edge pixels; the solver's round-off + // still touches a few.) + assert!(moved <= 16, "a pin at {at:?} moved {moved} pixels"); + s.undo(); + } +} + #[test] fn puppet_pin_kinds_and_mesh_options() { let (mut s, id) = setup(); From 9b7f8cf005a9055538b992889e27c0eee49fcc89 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 18:30:54 +0000 Subject: [PATCH 063/107] Effects: Write-on draws a stroke along the animated brush, honouring Stroke Length and Brush Spacing (#536) Cause: write_on_plan drew a single dab at the current Brush Position. Stroke Length, Brush Spacing and the Paint/Brush Time Properties were declared but unused, so an animated brush showed a moving dot that dropped every earlier mark. Fix: as After Effects describes Write-on, a dab is laid every Brush Spacing seconds from the layer's start up to the current time, each lasting Stroke Length seconds (0: for ever). Their positions come from the host's parameters at their times (EffectHost::params_at, which the renderer implements). Paint Time Properties > Opacity and Brush Time Properties > Size (or Size & Hardness) keep each dab's own opacity / size; otherwise the current values apply to all. One frame draws at most 4096 dabs (longer strokes space them out and join them). Without a host the current dab draws, as before. Colour and hardness stay one per stroke (the paint plan has one colour and hardness), so Paint Time Properties > Color and per-dab hardness aren't modelled. The GPU path uses the same plan. Test: write_on_accumulates_a_stroke_along_the_animated_brush (the report's fixture: start and midpoint marks stay at 0.5 s with the end still clear, all marks at 1 s, Stroke Length 0.25 keeps only the recent trail, Brush Spacing 0.5 gives three separate marks, per-dab opacity). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f (cherry picked from commit c82c9aeb685eab0cb36ca69c0b337781f4bad258) --- crates/effects/src/generate3.rs | 128 ++++++++++++++++++++++++++++++-- 1 file changed, 120 insertions(+), 8 deletions(-) diff --git a/crates/effects/src/generate3.rs b/crates/effects/src/generate3.rs index 926846a9..eaa92411 100644 --- a/crates/effects/src/generate3.rs +++ b/crates/effects/src/generate3.rs @@ -15,7 +15,7 @@ use effectcraft_raster::{Image, Px}; use rayon::prelude::*; use crate::util::{Plane, gauss_plane, hash1, layer_or_self, morph_frac, poly_length, premul, smoothstep, unpremul}; -use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider}; +use crate::{Buf, EffectCtx, EffectSpec, Params, col, num, p, popup, slider}; fn spec(id: &'static str, name: &'static str, params: Vec, render: crate::RenderFn) -> EffectSpec { EffectSpec { id, name, category: "Generate", params, render, gpu: false, float: true } @@ -1074,23 +1074,66 @@ fn vegas_plan(ctx: &EffectCtx, b: &Buf) -> PaintPlan { // ---------------------------------------------------------------- Write-on / Glue Gun -/// Brush dab history: only the brush position at the current time is known to an effect -/// (parameter histories are not passed in), so the stroke is drawn as the dabs laid along -/// the current position — a single dab when the brush is static. Deterministic. +/// Write-on draws a stroke along the animated Brush Position, as in After Effects: a dab every +/// Brush Spacing seconds from the layer's start up to now, each one lasting Stroke Length +/// seconds (0: for ever). The dabs' positions come from the host's parameters at their times; +/// with Paint Time Properties ▸ Opacity or Brush Time Properties ▸ Size each dab keeps its own +/// opacity or size, else the current ones apply to every dab. (Colour and hardness are one per +/// stroke: the current ones.) Without a host (or dabs) the current dab draws. Deterministic. fn write_on(ctx: &EffectCtx, mut b: Buf) -> Buf { let plan = write_on_plan(ctx, &b); draw_paint(&mut b, &plan); b } +/// The most dabs one Write-on frame draws; longer strokes lay them further apart, joined. +const WRITE_ON_MAX_DABS: i64 = 4096; + fn write_on_plan(ctx: &EffectCtx, b: &Buf) -> PaintPlan { let pr = ctx.params; - let q = b.to_px(pr.v2("brushPosition")); - let r = (pr.f("brushSize") * b.scale * 0.5).max(0.05); + let own_opacity = pr.e("paintTimeProperties") == 1; + let own_size = matches!(pr.e("brushTimeProperties"), 1 | 3); + let opacity = |p: &Params| (p.f("brushOpacity") / 100.0).clamp(0.0, 1.0) as f32; + let radius = |p: &Params| (p.f("brushSize") * b.scale * 0.5).max(0.05); + let dab = |p: &Params| { + let q = b.to_px(p.v2("brushPosition")); + ([q.0, q.1], radius(if own_size { p } else { pr }), if own_opacity { opacity(p) } else { 1.0 }) + }; + let mut segs = vec![]; + let t = ctx.time; + let spacing = pr.f("brushSpacing").max(0.001); + let life = pr.f("strokeLength").max(0.0); + if let Some(host) = ctx.env.host + && t.is_finite() + && t >= 0.0 + { + let start = if life > 0.0 { (t - life).max(0.0) } else { 0.0 }; + let k0 = (start / spacing - 1e-9).ceil().max(0.0) as i64; + let k1 = (t / spacing + 1e-9).floor().max(0.0) as i64; + let n = k1.saturating_sub(k0).saturating_add(1); + let stride = (n / WRITE_ON_MAX_DABS).saturating_add(1).max(1); + let mut prev: Option<[f64; 2]> = None; + let mut k = k0; + while k <= k1 { + let tk = k as f64 * spacing; + let at = if (tk - t).abs() < 1e-9 { Some(dab(pr)) } else { host.params_at(tk).map(|p| dab(&p)) }; + if let Some((q, r, v)) = at { + // Dabs laid further apart than the brush spacing are joined into a line. + let a = if stride > 1 { prev.unwrap_or(q) } else { q }; + segs.push(Seg { a, b: q, r, v }); + prev = Some(q); + } + k = k.saturating_add(stride); + } + } + if segs.is_empty() { + let (q, r, v) = dab(pr); + segs.push(Seg { a: q, b: q, r, v }); + } PaintPlan { - cov: Coverage::Segs { segs: vec![Seg { a: [q.0, q.1], b: [q.0, q.1], r, v: 1.0 }], hardness: pr.f("brushHardness") / 100.0 }, + cov: Coverage::Segs { segs, hardness: pr.f("brushHardness") / 100.0 }, color: pr.color("color"), - opacity: (pr.f("brushOpacity") / 100.0) as f32, + opacity: if own_opacity { 1.0 } else { opacity(pr) }, style: pr.e("paintStyle"), } } @@ -2204,6 +2247,75 @@ mod tests { assert_eq!(g.img.get(0, 0), src.get(0, 0)); } + /// #536: an animated Brush Position draws a stroke that stays (Stroke Length 0), expires + /// after Stroke Length, and lays its dabs Brush Spacing seconds apart. + #[test] + fn write_on_accumulates_a_stroke_along_the_animated_brush() { + // Brush Position from [8, 16] at 0 s to [56, 16] at 1 s; red, hard, 6 px. + struct Brush(Vec<(&'static str, Value)>); + impl Brush { + fn at(&self, t: f64) -> Vec<(&'static str, Value)> { + let mut v = self.0.clone(); + v.push(("brushPosition", Value::Vec2([8.0 + 48.0 * t.clamp(0.0, 1.0), 16.0]))); + v + } + } + impl EffectHost for Brush { + fn layer(&self, _: u64, _: bool) -> Option { + None + } + fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> { + None + } + fn params_at(&self, t: f64) -> Option { + let s = crate::find("ec.generate.writeon").unwrap(); + let mut p = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }; + for (k, v) in self.at(t) { + p.values.insert(k.to_string(), v); + } + Some(p) + } + } + let render = |extra: &[(&'static str, Value)], t: f64| { + let mut set = vec![("color", col(1.0, 0.0, 0.0)), ("brushSize", num(6.0)), ("brushHardness", num(100.0)), ("brushSpacing", num(0.05))]; + set.extend(extra.iter().cloned()); + let host = Brush(set); + let env = EffectEnv { host: Some(&host), ..Default::default() }; + let at = host.at(t); + run_env("ec.generate.writeon", &at, Image::new(64, 32), t, env).img + }; + let on = |img: &Image, x: i64| img.get(x, 16)[3] > 0.9; + let half = render(&[], 0.5); + assert!(on(&half, 8) && on(&half, 20) && on(&half, 32) && !on(&half, 44) && !on(&half, 56)); + let end = render(&[], 1.0); + assert!([8, 20, 32, 44, 56].iter().all(|x| on(&end, *x))); + // Stroke Length 0.25 s: only the last quarter second's trail. + let trail = render(&[("strokeLength", num(0.25))], 1.0); + assert!(!on(&trail, 8) && !on(&trail, 32) && on(&trail, 48) && on(&trail, 56)); + // Brush Spacing 0.5 s: three separate marks. + let spaced = render(&[("brushSpacing", num(0.5))], 1.0); + assert!(on(&spaced, 8) && on(&spaced, 32) && on(&spaced, 56) && !on(&spaced, 20) && !on(&spaced, 44)); + // Paint Time Properties ▸ Opacity: each dab keeps the opacity it was laid with. + struct Fade; + impl EffectHost for Fade { + fn layer(&self, _: u64, _: bool) -> Option { + None + } + fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> { + None + } + fn params_at(&self, t: f64) -> Option { + let mut p = Brush(vec![("brushHardness", num(100.0)), ("brushSpacing", num(0.05)), ("paintTimeProperties", Value::Enum(1))]).params_at(t)?; + p.values.insert("brushOpacity".into(), num(if t < 0.5 { 100.0 } else { 20.0 })); + Some(p) + } + } + let p1 = Fade.params_at(1.0).unwrap(); + let set: Vec<(&str, Value)> = p1.values.iter().map(|(k, v)| (k.as_str(), v.clone())).collect(); + let faded = run_env("ec.generate.writeon", &set, Image::new(64, 32), 1.0, EffectEnv { host: Some(&Fade), ..Default::default() }).img; + assert!(faded.get(8, 16)[3] > 0.9 && (faded.get(56, 16)[3] - 0.2).abs() < 0.05, "{:?} {:?}", faded.get(8, 16), faded.get(56, 16)); + } + #[test] fn paint_bucket_fills_enclosed_region_only() { let mut img = Image::filled(32, 32, [1.0, 1.0, 1.0, 1.0]); From 86d47dc3fe3467bed58dc4facf1c815a02d70748 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 18:31:40 +0000 Subject: [PATCH 064/107] Snapping and Align: shape layers meet at their visual edges, with no gap (#352) Cause: render::content_bounds (used by snapping's targets and source, Align, Distribute, the viewer's bounding box and handles) took shape layers' bounds from draw_bounds, the rasteriser's safety rectangle: 1 px of anti-aliasing padding around fills and width x miter limit / 2 + 1 around strokes. Two squares snapped edge to edge therefore stayed 2 px apart, or much more with strokes. Fix: shapes::content_bounds uses visual bounds, as After Effects' layer bounds do: the path bounds plus half the stroke width (through the draw's group scale), with no anti-aliasing or miter padding. Rendering keeps draw_bounds. Tests: render shape_content_bounds_are_the_visual_edges (a 100 x 100 rectangle is exactly +-50, +-55 with a 10 px stroke); engine shape_layers_align_by_their_visual_edges (widths 100 / 110, Align Left puts both edges exactly at 0). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f (cherry picked from commit bfd0abd7437ad1d38652e3aef51f9ba5dea9100b) --- crates/engine/src/commands/align.rs | 21 +++++++++++++++++++++ crates/render/src/shapes.rs | 22 ++++++++++++++++++---- crates/render/src/tests.rs | 23 +++++++++++++++++++++++ 3 files changed, 62 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/commands/align.rs b/crates/engine/src/commands/align.rs index c225b1e6..2c2b3458 100644 --- a/crates/engine/src/commands/align.rs +++ b/crates/engine/src/commands/align.rs @@ -265,6 +265,27 @@ mod tests { assert!(s.execute("layer.align", json!({"edge": "diagonal", "layers": [a]})).is_err()); } + /// #352: shape layers align and snap by their visual edges (half the stroke, no + /// anti-aliasing padding), so two rectangles lined up edge to edge touch with no gap. + #[test] + fn shape_layers_align_by_their_visual_edges() { + let mut s = session(); + let shape = |s: &mut Session, extra: Value| { + let mut p = json!({"kind": "rect", "size": [100, 100], "fill": "#ff0000"}); + p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone()); + s.execute("layer.newShape", p).unwrap()["layer"].as_u64().unwrap() + }; + let a = shape(&mut s, json!({})); + let b = shape(&mut s, json!({"stroke": "#ffffff", "strokeWidth": 10})); + let width = |s: &Session, id| { + let r = bounds(s, id); + r[2] - r[0] + }; + assert!((width(&s, a) - 100.0).abs() < 1e-6 && (width(&s, b) - 110.0).abs() < 1e-6, "{} {}", width(&s, a), width(&s, b)); + s.execute("layer.align", json!({"edge": "left", "layers": [a, b]})).unwrap(); + assert!(bounds(&s, a)[0].abs() < 1e-6 && bounds(&s, b)[0].abs() < 1e-6); + } + #[test] fn align_honours_parent_scale_and_rotation() { let mut s = session(); diff --git a/crates/render/src/shapes.rs b/crates/render/src/shapes.rs index 6bd8686b..e3cbca4a 100644 --- a/crates/render/src/shapes.rs +++ b/crates/render/src/shapes.rs @@ -517,6 +517,20 @@ fn draw_bounds(d: &Draw, paths: &[BezPath]) -> Option { Some(b.inflate(grow, grow)) } +/// Local-space visual bounds of a draw, as After Effects' layer bounds (`sourceRectAtTime` with +/// extents): the path bounds, plus half the stroke width for strokes. No anti-aliasing or miter +/// padding, so snapping and Align put edges exactly together (#352). +fn visual_bounds(d: &Draw, paths: &[BezPath]) -> Option { + let b = effectcraft_path::bounds(paths)?; + let stroke = match &d.paint { + Paint::Stroke { style, .. } => Some(style), + Paint::Gradient { stroke: Some(style), .. } => Some(style), + _ => None, + }; + let grow = stroke.map_or(0.0, |style| style.width * 0.5 * d.xf.mean_scale()); + Some(if grow.is_finite() && grow > 0.0 { b.inflate(grow, grow) } else { b }) +} + /// Maximum number of coverage pixels rasterised concurrently (bounds peak memory). const BATCH_PIXELS: usize = 8 << 20; @@ -639,16 +653,16 @@ pub fn stroke_region(paths: &[BezPath], style: &StrokeStyle) -> Option (!r.elements().is_empty()).then_some(r) } -/// Layer-space bounds of a shape layer's painted contents (strokes included), without -/// rasterising. +/// Layer-space visual bounds of a shape layer's painted contents (half the stroke width +/// included, no anti-aliasing padding), without rasterising. pub fn content_bounds(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup) -> Option { let mut arena = Vec::new(); let (draws, _) = collect(ctx, layer, contents, &mut arena); draws .iter() .filter_map(|d| { - let paths: Vec = d.slots.iter().map(|&i| arena[i].clone()).filter(|p| !p.elements().is_empty()).collect(); - draw_bounds(d, &paths) + let paths: Vec = d.slots.iter().filter_map(|&i| arena.get(i).cloned()).filter(|p| !p.elements().is_empty()).collect(); + visual_bounds(d, &paths) }) .reduce(|a, b| a.union(b)) } diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 730c5b89..7e065391 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -808,3 +808,26 @@ fn precomp_dependencies_hash_nested_sources_and_evaluated_animation() { assert_same(&render_cached(&p, cid, Tick::ZERO, Some(&cache)), &fresh, "precomp source edit"); assert_ne!(before.get(50, 50), fresh.get(50, 50)); } + +/// #352: a shape layer's bounds (snapping, Align) are its visual edges: a 100×100 rectangle is +/// exactly ±50, and a 10 px stroke adds half its width, with no anti-aliasing padding. +#[test] +fn shape_content_bounds_are_the_visual_edges() { + for (stroke, half) in [(None, 50.0), (Some(10.0), 55.0)] { + let (mut p, cid, comp) = setup(); + let mut l = build::layer(&mut p, &comp, "Shape Layer 1", LayerSource::Shape, (200, 100), None); + let mut next = p.next_id; + let mut ids = Ids(&mut next); + let mut items = vec![build::shape_rect(&mut ids, [100.0, 100.0], [0.0, 0.0], 0.0), build::shape_fill(&mut ids, [1.0, 0.0, 0.0, 1.0])]; + if let Some(w) = stroke { + items.push(build::shape_stroke(&mut ids, [1.0, 1.0, 1.0, 1.0], w)); + } + let g = build::shape_group(&mut ids, "Rectangle 1", items); + p.next_id = next; + l.props.sub_mut("contents").unwrap().children.push(g.into()); + p.comp_mut(cid).unwrap().layers.push(l.clone()); + let ctx = crate::EvalCtx::new(&p, cid, p.comp(cid).unwrap(), Tick::ZERO); + let b = crate::content_bounds(&ctx, &l).unwrap(); + assert!(b.iter().zip([-half, -half, half, half]).all(|(a, e)| (a - e).abs() < 1e-6), "{stroke:?}: {b:?}"); + } +} From 40e87709d56c43848ca7b9eb6acb1499787966e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 19:28:59 +0000 Subject: [PATCH 065/107] Import: numbered stills import as stills unless the Sequence option is on; dropped folders make sequences (#431) Cause: SequenceOptions::from_params defaulted `sequence` to true for file.import, so any numbered still (IMG_0001.jpg) brought its whole numbered run from its folder, and several picked or dropped stills with a common prefix became one sequence. Dropped files never asked, and the Import dialog's " Sequence" checkbox started ticked. Fix, matching After Effects: file.import takes picked and dropped files as stills unless `sequence: true` (the Import dialog's Sequence checkbox, which now starts unticked; Force Alphabetical Order implies it), while the numbered stills inside a picked or dropped folder still import as sequences (SequenceOptions::folders), and `sequence: false` (Alt-drag of a folder) imports a folder's files one by one. Replace Footage, reload, proxies and render outputs keep detecting their sequence (SequenceOptions::default and from_params are unchanged), and mediaBrowser.import keeps bringing a numbered still's run unless `sequence: false`. docs/footage.md and docs/agents.md describe the new defaults. Tests: sequence import_takes_stills_unless_asked_for_a_sequence and engine numbered_stills_import_as_stills_by_default (one dropped still and two picked IMG_ stills stay stills, a dropped folder makes one sequence, the checkbox brings the run); the existing sequence tests pass `sequence: true`; ui import_dialog_offers_the_image_sequence_option (unticked by default: a still; ticked: the run) and a_dropped_folder_imports_as_one_sequence_with_sequence_interpretation (two dropped frames: two stills; the folder: one sequence). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f (cherry picked from commit aa3c31db9ab3a774ee2f8ba867e1bafc1fce7aba) --- crates/engine/src/commands/file.rs | 4 +- crates/engine/src/commands/panels_cmds.rs | 7 ++- crates/engine/src/sequence.rs | 69 +++++++++++++++++------ crates/engine/src/tests_sequence.rs | 26 +++++++-- crates/ui-egui/src/panels/forms.rs | 7 ++- crates/ui-egui/src/prefs_live.rs | 8 ++- crates/ui-egui/tests/ui_drag_drop.rs | 28 +++++---- docs/agents.md | 7 ++- docs/footage.md | 17 ++++-- 9 files changed, 123 insertions(+), 50 deletions(-) diff --git a/crates/engine/src/commands/file.rs b/crates/engine/src/commands/file.rs index f0396e5d..fb557de0 100644 --- a/crates/engine/src/commands/file.rs +++ b/crates/engine/src/commands/file.rs @@ -376,7 +376,7 @@ fn prepare_import(s: &mut Session, p: &Value) -> Result { } // Numbered stills as image sequences (the Import dialog's " Sequence" and Force // Alphabetical Order), at `frameRate` or Settings ▸ Import ▸ Sequence Footage. - let opts = SequenceOptions::from_params(p); + let opts = SequenceOptions::for_import(p); let fps = match p.get("frameRate") { None | Some(Value::Null) => s.prefs.import.sequence_fps, Some(v) => v.as_f64().filter(|x| *x > 0.0 && *x <= 999.0).ok_or_else(|| bad("file.import", "frameRate: frames per second, up to 999"))?, @@ -680,7 +680,7 @@ pub fn specs() -> Vec { "File...", ["File", "Import"], Some("Cmd+I"), - "{paths: [string] (files or folders), sequence?: bool (default true: numbered stills of one run import as one image sequence; one picked file brings its whole run, several picked files that range), alphabetical?: bool (Force Alphabetical Order: every image of that type in the folder, by name), frameRate?: fps (image sequences; default Settings ▸ Import ▸ Sequence Footage), importAs?: footage|composition|compositionLayerSizes (Photoshop, PDF, Illustrator and EPS files), layer?: name|index (footage of one Photoshop layer), page?: number from 1 (PDF / Illustrator page), drag?: bool (dropped files: Settings ▸ Import ▸ Default Drag Import As), addToComp?: bool (also add them to the active comp, at time?, index?, position? as in layer.addItem), background?: bool (probe the files in a background job, jobs.list / jobs.wait; returns {job})}", + "{paths: [string] (files or folders), sequence?: bool (the Sequence checkbox: true imports numbered stills of one run as one image sequence, one picked file bringing its whole run and several picked files that range; by default picked files import as stills and folders' numbered stills as sequences; false imports a folder's files individually), alphabetical?: bool (Force Alphabetical Order: every image of that type in the folder, by name), frameRate?: fps (image sequences; default Settings ▸ Import ▸ Sequence Footage), importAs?: footage|composition|compositionLayerSizes (Photoshop, PDF, Illustrator and EPS files), layer?: name|index (footage of one Photoshop layer), page?: number from 1 (PDF / Illustrator page), drag?: bool (dropped files: Settings ▸ Import ▸ Default Drag Import As), addToComp?: bool (also add them to the active comp, at time?, index?, position? as in layer.addItem), background?: bool (probe the files in a background job, jobs.list / jobs.wait; returns {job})}", always, import_cmd ), diff --git a/crates/engine/src/commands/panels_cmds.rs b/crates/engine/src/commands/panels_cmds.rs index bcdb7a73..40241e6f 100644 --- a/crates/engine/src/commands/panels_cmds.rs +++ b/crates/engine/src/commands/panels_cmds.rs @@ -105,7 +105,10 @@ fn browser_import(s: &mut Session, p: &Value) -> Result { return Ok(json!({"pending": true, "paths": list})); } let target = s.active_comp_id(); - let mut r = s.execute("file.import", json!({"paths": paths}))?; + // A numbered still picked in the browser still brings its image sequence (`sequence: false` + // for the picked stills alone); `file.import` alone imports stills. + let sequence = p.get("sequence").and_then(Value::as_bool).unwrap_or(true); + let mut r = s.execute("file.import", json!({"paths": paths, "sequence": sequence}))?; // Dragged into the Timeline or the Composition viewer: into the comp, where they were dropped. let errors = super::file::add_to_comp(s, &r, target, p); if !errors.is_empty() @@ -236,7 +239,7 @@ pub fn specs() -> Vec { "Import", [], None, - "{paths, addToComp?, time?, index?, position? (where the layers go, as in layer.addItem)}", + "{paths, sequence? (default true: a numbered still brings its image sequence), addToComp?, time?, index?, position? (where the layers go, as in layer.addItem)}", browser_enabled, browser_import ), diff --git a/crates/engine/src/sequence.rs b/crates/engine/src/sequence.rs index 22180350..79142955 100644 --- a/crates/engine/src/sequence.rs +++ b/crates/engine/src/sequence.rs @@ -20,15 +20,17 @@ use crate::{Importer, Services}; /// How an import treats numbered stills. #[derive(Clone, Copy, Debug)] pub struct SequenceOptions { - /// The " Sequence" checkbox: numbered stills of one run import as a sequence. + /// The " Sequence" checkbox: picked numbered stills of one run import as a sequence. pub sequence: bool, + /// The numbered stills inside a picked or dropped folder import as sequences. + pub folders: bool, /// Force Alphabetical Order. pub alphabetical: bool, } impl Default for SequenceOptions { fn default() -> Self { - SequenceOptions { sequence: true, alphabetical: false } + SequenceOptions { sequence: true, folders: true, alphabetical: false } } } @@ -36,7 +38,19 @@ impl SequenceOptions { /// From command parameters `sequence?` (default on) and `alphabetical?`. pub fn from_params(p: &serde_json::Value) -> SequenceOptions { let flag = |k: &str| p.get(k).and_then(serde_json::Value::as_bool); - SequenceOptions { sequence: flag("sequence").unwrap_or(true), alphabetical: flag("alphabetical").unwrap_or(false) } + let sequence = flag("sequence").unwrap_or(true); + SequenceOptions { sequence, folders: sequence, alphabetical: flag("alphabetical").unwrap_or(false) } + } + + /// `file.import`'s options, as in After Effects: picked or dropped files import as stills + /// unless `sequence: true` (the Import dialog's " Sequence" checkbox, which is off + /// unless ticked), while a folder's numbered stills import as sequences unless + /// `sequence: false` (Alt-drag of a folder). + pub fn for_import(p: &serde_json::Value) -> SequenceOptions { + let flag = |k: &str| p.get(k).and_then(serde_json::Value::as_bool); + let (sequence, alphabetical) = (flag("sequence"), flag("alphabetical").unwrap_or(false)); + // Force Alphabetical Order is part of the Sequence option. + SequenceOptions { sequence: sequence.unwrap_or(alphabetical), folders: sequence.unwrap_or(true), alphabetical } } } @@ -120,28 +134,27 @@ fn run_files(services: &dyn Services, path: &str, alphabetical: bool) -> Vec Vec { - let mut files: Vec = vec![]; + // Each file, and whether it may join a sequence. + let mut files: Vec<(String, bool)> = vec![]; for p in paths { if services.is_dir(p) { let mut inside: Vec = services.list_dir(p).into_iter().filter(|f| matches!(crate::media_browser::kind_of(f), Some(k) if k != "project")).collect(); inside.sort_by_key(|f| file_name(f).to_lowercase()); - files.extend(inside); + files.extend(inside.into_iter().map(|f| (f, opts.folders))); } else { - files.push(p.clone()); + files.push((p.clone(), opts.sequence)); } } - if !opts.sequence { - return files.into_iter().map(Source::File).collect(); - } // Runs in the order their first file was picked. let mut runs: Vec<((String, String, String), Vec)> = vec![]; let mut out: Vec> = vec![]; let mut slot: Vec = vec![]; - for f in files { - match run_key(&f, opts.alphabetical) { + for (f, joins) in files { + match run_key(&f, opts.alphabetical).filter(|_| joins) { Some(key) => match runs.iter_mut().find(|(k, _)| *k == key) { Some((_, run)) => run.push(f), None => { @@ -155,8 +168,10 @@ pub fn group(services: &dyn Services, paths: &[String], opts: SequenceOptions) - } for ((_, mut picked), at) in runs.into_iter().zip(slot) { sort_run(&mut picked, opts.alphabetical); - // One file: its whole run from the folder. - if let [one] = picked.as_slice() { + // One picked file: its whole run from the folder. + if let [one] = picked.as_slice() + && opts.sequence + { let all = run_files(services, one, opts.alphabetical); if all.len() >= 2 { picked = all; @@ -176,7 +191,7 @@ pub fn group(services: &dyn Services, paths: &[String], opts: SequenceOptions) - /// The image sequences importing `paths` would make (the Import dialog asks about them). pub fn sequences(services: &dyn Services, paths: &[String], alphabetical: bool) -> Vec> { - group(services, paths, SequenceOptions { sequence: true, alphabetical }) + group(services, paths, SequenceOptions { sequence: true, folders: true, alphabetical }) .into_iter() .filter_map(|s| match s { Source::Sequence { files, .. } => Some(files), @@ -282,7 +297,10 @@ mod tests { ] ); // Sequence off: every file on its own. - assert_eq!(group(&t, &strs(&["/s/a_0001.png"]), SequenceOptions { sequence: false, alphabetical: false }), [Source::File("/s/a_0001.png".into())]); + assert_eq!( + group(&t, &strs(&["/s/a_0001.png"]), SequenceOptions { sequence: false, folders: false, alphabetical: false }), + [Source::File("/s/a_0001.png".into())] + ); // A folder: its media files, runs as sequences. let g = group(&t, &strs(&["/s"]), on); assert_eq!(g.len(), 4, "{g:?}"); @@ -290,12 +308,29 @@ mod tests { assert_eq!(g[0].name(), "a_[0001-0010].png"); assert!(!g.iter().any(|s| s.path().ends_with(".txt"))); // Force Alphabetical Order: every PNG in the folder, by name. - let a = group(&t, &strs(&["/s/c.png"]), SequenceOptions { sequence: true, alphabetical: true }); + let a = group(&t, &strs(&["/s/c.png"]), SequenceOptions { sequence: true, folders: true, alphabetical: true }); assert_eq!(a, [Source::Sequence { files: strs(&["/s/a_0001.png", "/s/a_0002.png", "/s/a_0010.png", "/s/b_1.png", "/s/c.png"]), alphabetical: true }]); assert_eq!(a[0].name(), "a_0001.png"); assert_eq!(sequences(&t, &strs(&["/s/a_0001.png", "/s/c.png"]), false).len(), 1); } + /// #431: `file.import` brings picked or dropped numbered stills in as stills; only a folder or + /// an explicit `sequence: true` makes a sequence, as After Effects' Sequence checkbox does. + #[test] + fn import_takes_stills_unless_asked_for_a_sequence() { + let t = Table(vec!["/s/a_0001.png", "/s/a_0002.png", "/s/a_0003.png", "/s/IMG_0001.jpg", "/s/IMG_0042.jpg"]); + let opts = |p: serde_json::Value| SequenceOptions::for_import(&p); + let none = opts(serde_json::json!({"drag": true})); + assert_eq!(group(&t, &strs(&["/s/a_0002.png"]), none), [Source::File("/s/a_0002.png".into())]); + assert_eq!(group(&t, &strs(&["/s/IMG_0001.jpg", "/s/IMG_0042.jpg"]), none).len(), 2); + // A dropped folder: its runs as sequences (Alt-drag: `sequence: false`, files on their own). + assert_eq!(group(&t, &strs(&["/s"]), none).len(), 2); + assert_eq!(group(&t, &strs(&["/s"]), opts(serde_json::json!({"sequence": false}))).len(), 5); + // The Sequence checkbox: one picked file brings its whole run. + let on = group(&t, &strs(&["/s/a_0002.png"]), opts(serde_json::json!({"sequence": true}))); + assert!(matches!(on.as_slice(), [Source::Sequence { files, .. }] if files.len() == 3), "{on:?}"); + } + #[test] fn windows_paths_and_reports() { let t = Table(vec!["C:\\r\\f_01.exr", "C:\\r\\f_02.exr", "C:\\r\\f_05.exr"]); diff --git a/crates/engine/src/tests_sequence.rs b/crates/engine/src/tests_sequence.rs index cd406726..d59a9298 100644 --- a/crates/engine/src/tests_sequence.rs +++ b/crates/engine/src/tests_sequence.rs @@ -54,7 +54,7 @@ const RUN: [&str; 5] = ["shot_0001.png", "shot_0002.png", "shot_0004.png", "shot fn one_picked_numbered_file_imports_its_run_as_one_sequence() { let dir = folder(&RUN); let mut s = session(); - let r = s.execute("file.import", json!({"paths": [at(&dir, "shot_0002.png")]})).unwrap(); + let r = s.execute("file.import", json!({"paths": [at(&dir, "shot_0002.png")], "sequence": true})).unwrap(); assert_eq!(r["items"].as_array().unwrap().len(), 1, "{r}"); let (name, f) = footage(&s, &r["items"][0]); assert_eq!(name, "shot_[0001-0005].png"); @@ -75,17 +75,33 @@ fn picking_every_frame_imports_one_sequence() { let dir = folder(&RUN); let mut s = session(); let all: Vec = RUN[..4].iter().map(|n| at(&dir, n)).collect(); - let r = s.execute("file.import", json!({"paths": all, "frameRate": 24})).unwrap(); + let r = s.execute("file.import", json!({"paths": all, "sequence": true, "frameRate": 24})).unwrap(); assert_eq!(r["items"].as_array().unwrap().len(), 1, "{r}"); let (_, f) = footage(&s, &r["items"][0]); assert_eq!(f.sequence.len(), 4); // `frameRate` sets the sequence's rate on import. assert_eq!((f.frame_rate, f.duration), (FrameRate::FPS_24, FrameRate::FPS_24.tick_of(5))); // A picked range is just that range. - let r = s.execute("file.import", json!({"paths": [at(&dir, "shot_0004.png"), at(&dir, "shot_0005.png")]})).unwrap(); + let r = s.execute("file.import", json!({"paths": [at(&dir, "shot_0004.png"), at(&dir, "shot_0005.png")], "sequence": true})).unwrap(); let (name, f) = footage(&s, &r["items"][0]); assert_eq!((name.as_str(), f.sequence.len()), ("shot_[0004-0005].png", 2)); - assert!(s.execute("file.import", json!({"paths": all, "frameRate": 0})).is_err()); + assert!(s.execute("file.import", json!({"paths": all, "sequence": true, "frameRate": 0})).is_err()); +} + +/// #431: without the Sequence option, picked or dropped numbered stills import as stills (one +/// item each), as in After Effects; a dropped folder still makes its runs sequences. +#[test] +fn numbered_stills_import_as_stills_by_default() { + let dir = folder(&RUN); + let mut s = session(); + let r = s.execute("file.import", json!({"paths": [at(&dir, "shot_0002.png")], "drag": true})).unwrap(); + let (name, f) = footage(&s, &r["items"][0]); + assert_eq!((name.as_str(), f.kind), ("shot_0002.png", FootageKind::Still)); + let r = s.execute("file.import", json!({"paths": [at(&dir, "shot_0001.png"), at(&dir, "shot_0004.png")]})).unwrap(); + assert_eq!(r["items"].as_array().unwrap().len(), 2, "{r}"); + let r = s.execute("file.import", json!({"paths": [dir.clone()], "drag": true})).unwrap(); + let (name, f) = footage(&s, &r["items"][0]); + assert_eq!((name.as_str(), f.kind), ("shot_[0001-0005].png", FootageKind::Sequence)); } #[test] @@ -127,7 +143,7 @@ fn force_alphabetical_order_takes_every_image_of_the_type() { fn interpret_footage_start_timecode_relabels_source_time() { let dir = folder(&RUN); let mut s = session(); - let item = s.execute("file.import", json!({"paths": [at(&dir, "shot_0001.png")]})).unwrap()["items"][0].clone(); + let item = s.execute("file.import", json!({"paths": [at(&dir, "shot_0001.png")], "sequence": true})).unwrap()["items"][0].clone(); s.execute("comp.new", json!({"name": "Main", "width": 64, "height": 32, "frameRate": 30, "duration": 10})).unwrap(); let layer = s.execute("layer.addItem", json!({"item": item})).unwrap()["layer"].as_u64().unwrap(); let out = |s: &Session| s.active_comp().unwrap().layer(effectcraft_project::LayerId(layer)).unwrap().out_point; diff --git a/crates/ui-egui/src/panels/forms.rs b/crates/ui-egui/src/panels/forms.rs index 93e05105..dfc00642 100644 --- a/crates/ui-egui/src/panels/forms.rs +++ b/crates/ui-egui/src/panels/forms.rs @@ -154,8 +154,9 @@ fn is_pdf(p: &Value) -> bool { /// File ▸ Import ▸ File… picked numbered stills that import as image sequences: ask, as After /// Effects' " Sequence" checkbox in its Import dialog does (rfd's native dialog can't /// carry a checkbox), with Force Alphabetical Order and the frame rate. `p`: the import's -/// parameters. Returns whether the dialog opened. Dropped files and agents' `file.import` -/// don't ask (`sequence` defaults to on). +/// parameters. Returns whether the dialog opened. The checkbox starts off, as in After Effects: +/// unticked, the picked files import as stills. Dropped files and agents' `file.import` don't +/// ask (files import as stills, folders' runs as sequences). pub fn open_import_sequence(app: &mut EffectcraftApp, p: &Value) -> bool { if has(p, &["sequence", "alphabetical"]) { return false; @@ -187,7 +188,7 @@ fn sequence_fields(app: &EffectcraftApp, runs: &[Vec]) -> Vec { }; vec![ Field::note("note", ¬e), - Field::bool("sequence", &format!("{ext} Sequence"), true), + Field::bool("sequence", &format!("{ext} Sequence"), false), Field::bool("alphabetical", "Force alphabetical order", false), Field::num("frameRate", "Frame rate (fps)", app.session.prefs.import.sequence_fps), Field::note("after", "Change the frame rate, alpha and start frame later with File ▸ Interpret Footage."), diff --git a/crates/ui-egui/src/prefs_live.rs b/crates/ui-egui/src/prefs_live.rs index ed497dc4..b18d3e87 100644 --- a/crates/ui-egui/src/prefs_live.rs +++ b/crates/ui-egui/src/prefs_live.rs @@ -88,7 +88,10 @@ pub fn dropped_files(app: &mut EffectcraftApp, ui: &egui::Ui, bounds: Rect) { None }; - let params = json!({ + // Dropped files import as stills and folders' numbered stills as sequences; Alt-drag + // imports a folder's files one by one (After Effects). + let alt = ctx.input(|i| i.modifiers.alt); + let mut params = json!({ "paths": rest, "drag": true, "importAs": app.session.prefs.drag_import_as(), @@ -96,6 +99,9 @@ pub fn dropped_files(app: &mut EffectcraftApp, ui: &egui::Ui, bounds: Rect) { "position": at, "background": true }); + if alt { + params["sequence"] = json!(false); + } if let Err(e) = crate::menus::invoke(app, &ctx, "file.import", params) { errors.push(crate::i18n::tr_args("Cannot import dropped files: {}", &[&e])); diff --git a/crates/ui-egui/tests/ui_drag_drop.rs b/crates/ui-egui/tests/ui_drag_drop.rs index 3dee2727..d9e04ec3 100644 --- a/crates/ui-egui/tests/ui_drag_drop.rs +++ b/crates/ui-egui/tests/ui_drag_drop.rs @@ -408,7 +408,7 @@ fn footage_items(h: &Harness<'_, EffectcraftApp>) -> Vec<(String, usize)> { /// #297: File ▸ Import ▸ File… on one numbered still asks, as After Effects' "PNG Sequence" /// checkbox does, whether to import its whole run as one image sequence (and at what frame -/// rate); unticked, the file imports as a still. +/// rate); unticked (the default, as in After Effects), the file imports as a still. #[test] fn import_dialog_offers_the_image_sequence_option() { let (mut h, _) = harness(); @@ -424,35 +424,41 @@ fn import_dialog_offers_the_image_sequence_option() { assert_eq!(label(&h, "form.field.sequence").as_deref(), Some("PNG Sequence")); assert!(label(&h, "form.field.alphabetical").is_some() && label(&h, "form.field.frameRate").is_some()); assert!(footage_items(&h).is_empty(), "nothing imported before OK"); + // Unticked (the default, #431): the picked file alone, as a still. click_id(&mut h, "form.ok"); step_until(&mut h, |h| !footage_items(h).is_empty()); - assert_eq!(footage_items(&h), [("frame_[0001-0003].png".to_string(), 3)]); + assert_eq!(footage_items(&h), [("frame_0002.png".to_string(), 0)]); - // Unticked: the picked file alone, as a still. + // Ticked: the whole run as one sequence. effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "file.import", json!({})).unwrap(); h.run_steps(2); click_id(&mut h, "form.field.sequence"); click_id(&mut h, "form.ok"); step_until(&mut h, |h| footage_items(h).len() == 2); - assert_eq!(footage_items(&h)[1], ("frame_0002.png".to_string(), 0)); + assert_eq!(footage_items(&h)[1], ("frame_[0001-0003].png".to_string(), 3)); let _ = std::fs::remove_dir_all(&dir); } -/// #297: every frame of a sequence dropped on the window arrives as one image sequence (it used -/// to make one item per frame), and Interpret Footage shows its frames and Start Timecode (missing frames -/// always show colour bars, as in After Effects: there is no Missing Frames choice). +/// #297: a folder of frames dropped on the window arrives as one image sequence, and Interpret +/// Footage shows its frames and Start Timecode (missing frames always show colour bars, as in +/// After Effects: there is no Missing Frames choice). #431: dropped files import as stills, one +/// item each, as in After Effects. #[test] -fn dropped_frames_import_as_one_sequence_with_sequence_interpretation() { +fn a_dropped_folder_imports_as_one_sequence_with_sequence_interpretation() { let (mut h, _) = harness(); let dir = frames("drop", 4); h.state_mut().session.importer = Some(Arc::new(Stills)); - for k in 1..=4 { + for k in 1..=2 { h.input_mut().dropped_files.push(Arc::new(Dropped(dir.join(format!("frame_{k:04}.png"))))); } h.run_steps(2); - step_until(&mut h, |h| !footage_items(h).is_empty()); + step_until(&mut h, |h| footage_items(h).len() == 2); + assert_eq!(footage_items(&h), [("frame_0001.png".to_string(), 0), ("frame_0002.png".to_string(), 0)]); + h.input_mut().dropped_files.push(Arc::new(Dropped(dir.clone()))); h.run_steps(2); - assert_eq!(footage_items(&h), [("frame_[0001-0004].png".to_string(), 4)]); + step_until(&mut h, |h| footage_items(h).len() == 3); + h.run_steps(2); + assert_eq!(footage_items(&h)[2], ("frame_[0001-0004].png".to_string(), 4)); assert!(h.state().dialog.is_none(), "drops don't ask"); // Interpret Footage on it (the import selected it). let ctx = h.ctx.clone(); diff --git a/docs/agents.md b/docs/agents.md index a0f4e7d0..62fcd216 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -251,9 +251,10 @@ build real projects through these interfaces; they are worked examples of everyt then `keys.velocity {"outSpeed":0,"outInfluence":33.33}`. * **Gradients**: `set_property {"path":"contents/gfill/colors","value":["#0080ff","#ffff00"]}` or `{"colors":[[0,"#0080ff"],[1,[1,1,0]]],"opacities":[[0,1],[1,0.5]]}`. -* **Image sequences**: `file.import {"paths":["/r/shot_0001.png"]}` imports the file's whole - numbered run as one item (several picked frames: that range; a folder: each run in it; - `"sequence": false`: one still; `"alphabetical": true`: Force Alphabetical Order, the files +* **Image sequences**: `file.import {"paths":["/r/shot_0001.png"], "sequence": true}` imports the + file's whole numbered run as one item (several picked frames: that range; a folder, even without + `sequence`: each run in it; without `sequence` picked files import as stills, as in After + Effects; `"alphabetical": true`: Force Alphabetical Order, the files play one after another) at `frameRate` or 30 fps (Settings ▸ Import). Missing frames show colour bars, as in After Effects. `file.interpretFootage {"items":[id], "frameRate":12, "startTimecode":1001}` conforms them and sets Override Start (the item and layers that ran to its end get the new diff --git a/docs/footage.md b/docs/footage.md index 784a75ad..d25dcc34 100644 --- a/docs/footage.md +++ b/docs/footage.md @@ -26,7 +26,7 @@ dialog shows the run it found (and any missing frames) with these options: | Option | What it does | |---|---| -| **PNG Sequence** (EXR, TIFF, JPG… after the file type) | On: the whole run imports as one sequence. Off: just the picked file imports, as a still. | +| **PNG Sequence** (EXR, TIFF, JPG… after the file type) | On: the whole run imports as one sequence. Off (the default, as in After Effects): the picked files import as stills. | | **Force alphabetical order** | Imports every image of that type in the folder in name order, whatever the numbering, and plays the files one after another (gaps in the numbering don't count). Use it for files that aren't numbered consistently. | | **Frame rate (fps)** | The sequence's frame rate. The default comes from Settings ▸ Import ▸ Sequence Footage (30 fps). | @@ -34,10 +34,14 @@ Other ways in: - **Pick part of a run** (Shift-click the first and the last frame in the file dialog) to import just that range as one sequence. -- **Pick or drop every frame** of the run: it arrives as one sequence, not one item per frame. +- **Pick every frame** of the run with the Sequence option on: it arrives as one sequence, not + one item per frame. - **Drop a folder**: its media files import, and each numbered run in it becomes a sequence. -- **In the browser** (the web app) there are no folders to look in, so pick or drop all the - frames of the sequence at once. + Alt-drag the folder to import its files one by one. +- **Dropped files** import as stills, one item each, as in After Effects; drop the folder (or use + File ▸ Import ▸ File… with the Sequence option) for a sequence. +- **In the browser** (the web app) there are no folders to look in, so pick all the frames of + the sequence at once with the Sequence option on. Stills of any format EffectCraft reads can form a sequence: PNG, JPEG, TIFF, OpenEXR, BMP and WebP. Layered and vector documents (PSD, SVG, PDF) import one by one. @@ -48,8 +52,9 @@ Effects does, so every other frame stays at its number. Render or copy the missi folder and choose File ▸ Reload Footage to fill the gaps. Agents: `file.import {"paths": ["/shots/shot_0001.png"], "sequence": true, "frameRate": 24}`. -`sequence` is on by default; `"sequence": false` imports the file as a still, and -`"alphabetical": true` is Force Alphabetical Order. +Without `sequence`, picked files import as stills and a folder's numbered runs as sequences; +`"sequence": true` is the Sequence option (one file brings its whole run), `"sequence": false` +imports a folder's files one by one, and `"alphabetical": true` is Force Alphabetical Order. ## Interpret Footage From 3ba43b20c7c7d7a06ae2094f32993707e3ce21cd Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 19:29:15 +0000 Subject: [PATCH 066/107] =?UTF-8?q?Effect=20Controls:=20file=20parameters?= =?UTF-8?q?=20get=20Choose=E2=80=A6,=20Apply=20Color=20LUT=20asks=20for=20?= =?UTF-8?q?its=20LUT=20on=20apply,=20and=20text=20parameters=20get=20a=20f?= =?UTF-8?q?ield=20(#514,=20#473)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cause: Apply Color LUT's only parameter, `lut`, was ParamUi::Hidden (set only through commands), so the effect could not be used from the UI. Effect Controls also had no widget for any ParamUi::Text parameter (only EXtractoR's channels had one), so Cryptomatte's Selection, the OCIO effects' config file / file / config names and Colorama's Output Cycle could not be set either. Fix: a new ParamUi::File { filters } for paths. Effect Controls shows the file's name (None when empty) and a Choose... button that opens the host's file dialog with those extensions and sets the path (prop.set, so agents use the same command). As in After Effects, applying an effect with an empty file parameter from the UI (Effect menu, Last Effect, drops, Effects & Presets) opens the file dialog straight away; cancelling keeps the effect without a file. Apply Color LUT's LUT (.cube) and the OCIO Config File (.ocio) and File (.cube/.3dl/.csp/.cc/.ccc/.cdl/.spi1d/.spi3d/.spimtx) use it. Text parameters get a single-line field that sets the value when it loses the focus (Esc keeps the old text). Saved projects are upgraded on open: a Hidden or Text parameter whose spec is now a File parameter takes the File UI and keeps its path (effects::migrate). docs/effects.md regenerated. This covers #473's first part (the Selection field); picking mattes in the viewer, the layer popup from the file's metadata, the GPU path and quoted commas are separate. Tests: effects hidden_and_text_paths_become_file_parameters (migration); ui file_and_text_parameters_have_widgets (the LUT is asked for on apply and inverts red to cyan, the Choose... button picks another LUT, typing into Cryptomatte's Selection sets it). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f (cherry picked from commit 403863f703999d19d3a83d9976f76bdc7577e682) --- crates/effects/src/lib.rs | 4 + crates/effects/src/migrate.rs | 20 +++++ crates/effects/src/ocio.rs | 6 +- crates/effects/src/utility.rs | 5 +- crates/project/src/props.rs | 5 ++ crates/ui-egui/src/menus.rs | 3 +- crates/ui-egui/src/panels/effect_controls.rs | 93 ++++++++++++++++++++ crates/ui-egui/src/panels/effects_presets.rs | 3 +- crates/ui-egui/tests/ui_effect_controls.rs | 82 +++++++++++++++++ docs/effects.md | 2 +- 10 files changed, 215 insertions(+), 8 deletions(-) diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 4bce0636..45e2be1c 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -430,6 +430,10 @@ pub(crate) fn p(id: &'static str, name: &'static str, default: Value, ui: ParamU pub(crate) fn slider(min: f64, max: f64, smin: f64, smax: f64, decimals: u8) -> ParamUi { ParamUi::Slider { min, max, slider_min: smin, slider_max: smax, decimals } } +/// A file parameter offering files with these extensions (Effect Controls' Choose…). +pub(crate) fn file_ui(exts: &[&str]) -> ParamUi { + ParamUi::File { filters: exts.iter().map(|s| s.to_string()).collect() } +} pub(crate) fn popup(opts: &[&str]) -> ParamUi { ParamUi::Popup { options: opts.iter().map(|s| s.to_string()).collect() } } diff --git a/crates/effects/src/migrate.rs b/crates/effects/src/migrate.rs index c161677b..0c0dad9d 100644 --- a/crates/effects/src/migrate.rs +++ b/crates/effects/src/migrate.rs @@ -236,6 +236,9 @@ fn refresh(pr: &mut Property, ps: &ParamSpec, spec: &EffectSpec) { } // A parameter that became internal (superseded by a new one) is hidden. (_, ParamUi::Hidden) => pr.ui = ParamUi::Hidden, + // A path typed as text, or set only by commands, that got a Choose… button (Apply Color + // LUT's LUT, the OCIO files, #514). + (ParamUi::Hidden | ParamUi::Text, ParamUi::File { .. }) => pr.ui = ps.ui.clone(), // Same kind of value: take the spec's hints (units, ranges). (a, b) if std::mem::discriminant(a) == std::mem::discriminant(b) || is_numeric_ui(a) && is_numeric_ui(b) => pr.ui = ps.ui.clone(), _ => {} @@ -494,6 +497,23 @@ mod tests { assert_eq!(pr.ui, popup.ui); } + /// #514: Apply Color LUT's LUT was hidden (set only by commands) and the OCIO files were + /// plain text; old projects get the Choose… file parameter, keeping the path. + #[test] + fn hidden_and_text_paths_become_file_parameters() { + for (fx, pid, old) in [("ec.utility.applylut", "lut", ParamUi::Hidden), ("ec.color.ociofile", "file", ParamUi::Text)] { + let mut next = 1; + let (spec, mut g) = instance(fx, &mut next); + let pr = g.get_mut(pid).unwrap(); + pr.ui = old; + pr.value = Value::Str("/luts/look.cube".into()); + assert!(upgrade_instance(spec, &mut g, &mut Ids(&mut next), [100.0, 100.0]), "{fx}"); + let pr = g.get(pid).unwrap(); + assert!(matches!(&pr.ui, ParamUi::File { filters } if filters.iter().any(|f| f == "cube")), "{fx}: {:?}", pr.ui); + assert_eq!(pr.value, Value::Str("/luts/look.cube".into())); + } + } + #[test] fn upgrade_moves_params_into_twirl_down_groups() { let spec = find(crate::warp_stab::ID).unwrap(); diff --git a/crates/effects/src/ocio.rs b/crates/effects/src/ocio.rs index c8fcf848..74816b11 100644 --- a/crates/effects/src/ocio.rs +++ b/crates/effects/src/ocio.rs @@ -1427,7 +1427,7 @@ pub fn specs() -> Vec { cc, vec![ p("config", "Configuration", Value::Enum(0), popup(CONFIGS)), - p("configFile", "Config File (.ocio)", Value::Str(String::new()), ParamUi::Text), + p("configFile", "Config File (.ocio)", Value::Str(String::new()), crate::file_ui(&["ocio"])), space_param("source", "Source", lin709), p("sourceName", "Source (config name)", Value::Str(String::new()), ParamUi::Text), space_param("destination", "Destination", srgb), @@ -1442,7 +1442,7 @@ pub fn specs() -> Vec { cc, vec![ p("config", "Configuration", Value::Enum(0), popup(CONFIGS)), - p("configFile", "Config File (.ocio)", Value::Str(String::new()), ParamUi::Text), + p("configFile", "Config File (.ocio)", Value::Str(String::new()), crate::file_ui(&["ocio"])), space_param("source", "Input Color Space", lin709), p("sourceName", "Source (config name)", Value::Str(String::new()), ParamUi::Text), p("display", "Display Device", Value::Enum(0), popup(&DISPLAYS.iter().map(|d| d.0).collect::>())), @@ -1459,7 +1459,7 @@ pub fn specs() -> Vec { "OCIO File Transform", cc, vec![ - p("file", "File", Value::Str(String::new()), ParamUi::Text), + p("file", "File", Value::Str(String::new()), crate::file_ui(&["cube", "3dl", "csp", "cc", "ccc", "cdl", "spi1d", "spi3d", "spimtx"])), p("cccId", "CCC ID", Value::Str(String::new()), ParamUi::Text), p("interpolation", "Interpolation", Value::Enum(3), popup(&["Nearest", "Linear", "Tetrahedral", "Best"])), direction(), diff --git a/crates/effects/src/utility.rs b/crates/effects/src/utility.rs index 46780aec..1d3b3ee4 100644 --- a/crates/effects/src/utility.rs +++ b/crates/effects/src/utility.rs @@ -385,8 +385,9 @@ pub fn specs() -> Vec { ], cineon, ), - // `lut`: a path to a `.cube` file, or the `.cube` text itself (set via commands/automation). - spec("ec.utility.applylut", "Apply Color LUT", vec![p("lut", "LUT", Value::Str(String::new()), ParamUi::Hidden)], apply_lut), + // `lut`: a path to a `.cube` file (Effect Controls' Choose…, asked for when the effect is + // applied, as in After Effects), or the `.cube` text itself (commands/automation). + spec("ec.utility.applylut", "Apply Color LUT", vec![p("lut", "LUT", Value::Str(String::new()), crate::file_ui(&["cube"]))], apply_lut), ] } diff --git a/crates/project/src/props.rs b/crates/project/src/props.rs index cdacb3f1..f1d5149d 100644 --- a/crates/project/src/props.rs +++ b/crates/project/src/props.rs @@ -37,6 +37,11 @@ pub enum ParamUi { Mask, Path, Text, + /// A file path (a string value) chosen with a "Choose…" button, offering files with these + /// extensions: Apply Color LUT's LUT, an OCIO config. + File { + filters: Vec, + }, Gradient, /// Not shown in Effect Controls / the timeline (internal data). Hidden, diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index cfbd6e52..6e7ec9fc 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -640,8 +640,9 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V // up Effect Controls on that layer, as in After Effects. if matches!(id, "effect.apply" | "effect.applyLast") { let r = run_engine(app, ctx, id, params.clone()); - if r.is_ok() { + if let Ok(applied) = &r { crate::panels::effect_controls::reveal_applied(app, ¶ms); + crate::panels::effect_controls::choose_files_after_apply(app, applied); } return r; } diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index adab0247..6612187e 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -79,6 +79,46 @@ pub fn reveal_applied(app: &mut EffectcraftApp, params: &serde_json::Value) { app.raise_panel(crate::dock::PanelKind::EffectControls); } +/// After Effects asks for an effect's file as it applies it (Apply Color LUT opens the file +/// dialog for its LUT). For the instances `effect.apply` made (`applied`, its result), open the +/// file dialog for their first empty file parameter and set the file on each; cancelling +/// leaves the effect without one. Does nothing without a file dialog (tests, agents). +pub fn choose_files_after_apply(app: &mut EffectcraftApp, applied: &serde_json::Value) { + let Some(pick) = app.hooks.pick_files.as_ref() else { return }; + let Some(comp) = app.session.active_comp() else { return }; + let uids: Vec = applied.get("effects").and_then(|v| v.as_array()).map(|a| a.iter().filter_map(|v| v.as_u64()).collect()).unwrap_or_default(); + let mut targets = vec![]; + let mut filters: Vec = vec![]; + for uid in uids { + for l in &comp.layers { + let Some(g) = l.props.find_group(uid) else { continue }; + let mut found = None; + g.walk("", &mut |_, pr| { + if found.is_none() + && let ParamUi::File { filters } = &pr.ui + && matches!(&pr.value, Value::Str(s) if s.trim().is_empty()) + { + found = Some((pr.uid, filters.clone())); + } + }); + if let Some((prop, f)) = found { + targets.push((l.id.0, prop)); + filters = f; + } + } + } + if targets.is_empty() { + return; + } + let exts: Vec<&str> = filters.iter().map(String::as_str).collect(); + let Some(path) = pick(&exts).into_iter().next() else { return }; + for (layer, prop) in targets { + if let Err(e) = app.session.execute("prop.set", json!({"layer": layer, "prop": prop, "value": path})) { + app.ui.status = e.to_string(); + } + } +} + /// An undo-merge key for one gesture on `id`: a new key each time a drag starts, so separate /// drags are separate undo steps while one drag is one step. pub(super) fn gesture_key(ui: &egui::Ui, id: egui::Id, started: bool) -> String { @@ -417,6 +457,59 @@ fn prop_row( let dr = Rect::from_min_size(pos2(vx, cy - 9.0), vec2((r.max.x - vx - 56.0).clamp(80.0, 200.0), 18.0)); super::fx_editors::channel_popup(app, ui, layer, prop, s, dr, actions); } + // A file parameter (Apply Color LUT's LUT, an OCIO config): its file name and Choose…, + // as in After Effects. + Value::Str(s) if matches!(prop.ui, ParamUi::File { .. }) => { + let bw = 70.0; + let br = Rect::from_min_size(pos2(vx, cy - 9.0), vec2(bw, 18.0)); + if widgets::text_button(ui, br, crate::i18n::tr("Choose..."), false, &t, egui::Id::new(("ec-file", uid))).clicked() { + let filters: Vec<&str> = match &prop.ui { + ParamUi::File { filters } => filters.iter().map(String::as_str).collect(), + _ => vec![], + }; + match app.hooks.pick_files.as_ref() { + Some(pick) => { + if let Some(path) = pick(&filters).into_iter().next() { + actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": uid, "value": path}))); + } + } + None => app.ui.status = "no file dialog available: set the path with prop.set".into(), + } + } + app.auto.add(&format!("effectControls.prop.{uid}.choose"), br, &prop.name); + let shown = if s.trim().is_empty() { + crate::i18n::tr("None").to_string() + } else if s.contains('\n') { + prop.name.clone() + } else { + effectcraft_engine::sequence::file_name(s) + }; + let nr = Rect::from_min_max(pos2(br.max.x + 8.0, r.min.y), pos2(r.max.x - 8.0, r.max.y)); + let np = p.with_clip_rect(nr.intersect(p.clip_rect())); + np.text(pos2(nr.min.x, cy), Align2::LEFT_CENTER, &shown, Tokens::ui(12.0), if s.trim().is_empty() { t.text_dim } else { t.text }); + let hover = ui.interact(nr, egui::Id::new(("ec-file-name", uid)), Sense::hover()); + if !s.trim().is_empty() && !s.contains('\n') { + hover.on_hover_text(s.as_str()); + } + app.auto.add(&format!("effectControls.prop.{uid}.value"), nr, &shown); + } + // A text parameter (Cryptomatte's Selection, Colorama's Output Cycle, config names): + // typed in a field, set when it loses the focus (Esc keeps the old text). + Value::Str(s) if matches!(prop.ui, ParamUi::Text) => { + let fr = Rect::from_min_size(pos2(vx, cy - 9.0), vec2((r.max.x - vx - 12.0).clamp(80.0, 320.0), 18.0)); + let bid = egui::Id::new(("ec-text", uid)); + let mut buf: String = ui.data(|d| d.get_temp(bid)).unwrap_or_else(|| s.clone()); + let resp = widgets::text_field(ui, fr, &mut buf, "", &t); + app.auto.add(&format!("effectControls.prop.{uid}.value"), fr, &prop.name); + if resp.lost_focus() { + if buf != *s && !ui.input(|i| i.key_pressed(egui::Key::Escape)) { + actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": uid, "value": buf}))); + } + ui.data_mut(|d| d.remove::(bid)); + } else if resp.has_focus() { + ui.data_mut(|d| d.insert_temp(bid, buf)); + } + } Value::Gradient(g) => { let gr = Rect::from_min_size(pos2(vx, cy - 7.0), vec2((r.max.x - vx - 56.0).clamp(60.0, 180.0), 14.0)); let n = 48; diff --git a/crates/ui-egui/src/panels/effects_presets.rs b/crates/ui-egui/src/panels/effects_presets.rs index 7b68ba7f..e60354b1 100644 --- a/crates/ui-egui/src/panels/effects_presets.rs +++ b/crates/ui-egui/src/panels/effects_presets.rs @@ -422,11 +422,12 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { }; match app.session.execute(cmd, params) { Err(e) => app.ui.status = e.to_string(), - Ok(_) => { + Ok(applied) => { if let Apply::Effect(id) = &apply { push_recent(&mut app.ui.effects_recent, id); app.show_panel(crate::dock::PanelKind::EffectControls); app.ui.focused = crate::dock::PanelKind::EffectsPresets; + crate::panels::effect_controls::choose_files_after_apply(app, &applied); } } } diff --git a/crates/ui-egui/tests/ui_effect_controls.rs b/crates/ui-egui/tests/ui_effect_controls.rs index f6137f5f..551f8ad4 100644 --- a/crates/ui-egui/tests/ui_effect_controls.rs +++ b/crates/ui-egui/tests/ui_effect_controls.rs @@ -574,3 +574,85 @@ fn custom_effect_editors_activate_their_owner() { click(&mut h, graph); assert!(h.state().session.state.selected_props.contains(&(LayerId(x.small), x.curves))); } + +/// #514: Apply Color LUT asks for its LUT as it is applied (as in After Effects), Effect +/// Controls shows the file with a Choose… button that picks another, and the LUT changes the +/// pixels. #473: a text parameter (Cryptomatte's Selection) has a field that sets it. +#[test] +fn file_and_text_parameters_have_widgets() { + let dir = std::env::temp_dir().join(format!("ec-ui-lut-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + // A 2×2×2 LUT that inverts, and one that does nothing. + let cube = |inv: bool| { + let mut t = String::from("LUT_3D_SIZE 2\n"); + for b in 0..2 { + for g in 0..2 { + for r in 0..2 { + let f = |c: i32| if inv { 1 - c } else { c }; + t += &format!("{} {} {}\n", f(r), f(g), f(b)); + } + } + } + t + }; + let (invert, identity) = (dir.join("invert.cube"), dir.join("identity.cube")); + std::fs::write(&invert, cube(true)).unwrap(); + std::fs::write(&identity, cube(false)).unwrap(); + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "LUT", "width": 64, "height": 32, "frameRate": 30, "duration": 1})).unwrap(); + let l = s.execute("layer.newSolid", json!({"name": "Plate", "color": "#ff0000", "width": 64, "height": 32})).unwrap()["layer"].as_u64().unwrap(); + s.state.selected_layers = vec![LayerId(l)]; + let mut app = EffectcraftApp::new(s); + app.show_panel(PanelKind::EffectControls); + let picked = std::sync::Arc::new(std::sync::Mutex::new(invert.to_string_lossy().to_string())); + let pick = picked.clone(); + app.hooks.pick_files = Some(Box::new(move |exts: &[&str]| { + assert_eq!(exts, ["cube"]); + vec![pick.lock().unwrap().clone()] + })); + let mut h = Harness::builder().with_size(egui::vec2(1700.0, 1100.0)).build_eframe(|_| app); + settle(&mut h); + let ctx = h.ctx.clone(); + let r = effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "effect.apply", json!({"effect": "Apply Color LUT"})).unwrap(); + let fx = r["effects"][0].as_u64().unwrap(); + let lut = |h: &Harness<'_, EffectcraftApp>| { + let g = layer(&h.state().session, l).effects().and_then(|f| f.groups().find(|g| g.uid == fx).cloned()).unwrap(); + let p = g.get("lut").unwrap().clone(); + ( + p.uid, + match p.value { + KV::Str(s) => s, + _ => String::new(), + }, + ) + }; + assert_eq!(lut(&h).1, invert.to_string_lossy(), "the LUT was asked for on apply"); + let px = |h: &Harness<'_, EffectcraftApp>| { + let s = &h.state().session; + s.render(s.active_comp_id().unwrap(), effectcraft_engine::time::Tick::ZERO, Default::default()).get(10, 10) + }; + assert!(px(&h)[0] < 0.05 && px(&h)[1] > 0.95, "red inverted to cyan: {:?}", px(&h)); + settle(&mut h); + let uid = lut(&h).0; + assert!(h.state().auto.find(&format!("effectControls.prop.{uid}.value")).is_some_and(|e| e.label == "invert.cube")); + *picked.lock().unwrap() = identity.to_string_lossy().to_string(); + let at = rect_of(&h, &format!("effectControls.prop.{uid}.choose")).center(); + click(&mut h, at); + assert_eq!(lut(&h).1, identity.to_string_lossy()); + assert!(px(&h)[0] > 0.95, "{:?}", px(&h)); + + // A text parameter: type into its field; it is set when the field loses the focus. + let crypto = h.state_mut().session.execute("effect.apply", json!({"layers": [l], "effect": "ec.3d.cryptomatte"})).unwrap()["effects"][0].as_u64().unwrap(); + settle(&mut h); + let sel = + layer(&h.state().session, l).effects().and_then(|f| f.groups().find(|g| g.uid == crypto).and_then(|g| g.get("selection")).map(|p| p.uid)).unwrap(); + let at = rect_of(&h, &format!("effectControls.prop.{sel}.value")).center(); + click(&mut h, at); + h.event(egui::Event::Text("left*".into())); + h.step(); + h.key_press(egui::Key::Enter); + h.run_steps(3); + let v = layer(&h.state().session, l).props.find(sel).unwrap().value.clone(); + assert_eq!(v, KV::Str("left*".into())); + let _ = std::fs::remove_dir_all(dir); +} diff --git a/docs/effects.md b/docs/effects.md index 4073251a..e498cf1a 100644 --- a/docs/effects.md +++ b/docs/effects.md @@ -412,7 +412,7 @@ Effects are addressed by id (`ec..`) or display name in commands | Effect | Id | Params | GPU | 32 | Status | |---|---|---:|:-:|:-:|---| -| Apply Color LUT | `ec.utility.applylut` | 0 | GPU | 32 | Implemented | +| Apply Color LUT | `ec.utility.applylut` | 1 | GPU | 32 | Implemented | | CC Overbrights | `ec.utility.ccoverbrights` | 2 | GPU | 32 | Implemented | | Cineon Converter | `ec.utility.cineon` | 7 | GPU | 32 | Implemented | | Color Profile Converter | `ec.utility.colorprofileconverter` | 7 | GPU | 32 | Implemented | From 1bea11c9f1c83204382cb5b8aac69e9e49f14967 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 19:29:15 +0000 Subject: [PATCH 067/107] Roto Brush: Cmd/Ctrl-drag resizes the brush, and the tool options show its Diameter (#509) Cause: the Roto Brush and Refine Edge diameters live in the editor state and the roto.options command sets them, but the UI never did: the Layer panel only painted strokes and the bottom bar only set the view mode, so the brush size could not be changed. Fix, as in After Effects: Cmd-drag (Ctrl-drag on Windows and Linux) in the Layer panel resizes the active brush (Roto Brush or Refine Edge) without painting. The circle stays at the press point, its edge follows the pointer, and the size is shown while dragging. The tools bar shows a Diameter field (header.roto.diameter) while a Roto Brush tool is active. Both go through roto.options, and the next stroke uses the new radius. Diameter is in all four interface catalogs. Test: roto_brush_ctrl_drag_resizes_the_brush (a 20 px Cmd/Ctrl-drag makes a 40 px brush and paints nothing, the next stroke's radius is half of it, and the Diameter field is registered). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f (cherry picked from commit b4183c28eea34ba097d7a16153b84fcf7aeea045) --- crates/ui-egui/src/header.rs | 23 ++++++++++++++++ crates/ui-egui/src/i18n/ui.rs | 4 +++ crates/ui-egui/src/panels/roto_tool.rs | 26 ++++++++++++++++++ crates/ui-egui/tests/ui_viewer.rs | 37 ++++++++++++++++++++++++++ 4 files changed, 90 insertions(+) diff --git a/crates/ui-egui/src/header.rs b/crates/ui-egui/src/header.rs index f013e5b7..40472f52 100644 --- a/crates/ui-egui/src/header.rs +++ b/crates/ui-egui/src/header.rs @@ -164,6 +164,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if app.ui.tool.puppet_kind().is_some() { x = puppet_options(app, ui, &p, x, cy); } + if app.ui.tool.roto_kind(false).is_some() { + x = roto_options(app, ui, &p, x, cy); + } let snap = Rect::from_min_size(pos2(x, cy - 10.0), vec2(20.0, 20.0)); // The engine owns snapping (View ▸ Snapping); the checkbox mirrors it. app.ui.snapping = app.session.state.snapping; @@ -481,6 +484,26 @@ fn paint_swatch(p: &egui::Painter, r: Rect, paint: &ToolPaint, hovered: bool, t: p.rect_stroke(r, 2.0, Stroke::new(1.0, if hovered { t.text } else { t.field_border }), StrokeKind::Inside); } +/// Roto Brush / Refine Edge tool options: the brush Diameter (⌘/Ctrl-drag in the Layer panel +/// sets it too, as in After Effects). Returns the next x. +fn roto_options(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, mut x: f32, cy: f32) -> f32 { + let t = app.tokens; + let refine = app.ui.tool.roto_kind(false).is_some_and(|k| k.starts_with("refine")); + let o = &app.session.state.roto; + let (key, v) = if refine { ("refineDiameter", o.refine_diameter) } else { ("diameter", o.diameter) }; + let label = tr("Diameter:"); + let lr = p.text(pos2(x, cy), Align2::LEFT_CENTER, label, Tokens::ui(12.0), t.text_dim); + x = lr.max.x + 6.0; + let (r, nv, _) = widgets::hot_number_at(ui, pos2(x, cy - 9.0), egui::Id::new(("roto-opt", key)), v, 0.5, (1.0, 5000.0), 0, " px", &t); + app.auto.add("header.roto.diameter", r, label); + if let Some(nv) = nv + && let Err(e) = app.session.execute("roto.options", json!({key: nv})) + { + app.ui.status = e.to_string(); + } + r.max.x + 12.0 +} + /// Puppet tool options: Mesh: Show, Expansion, Density (for new meshes and the selected /// layer's meshes). Returns the next x. fn puppet_options(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, mut x: f32, cy: f32) -> f32 { diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index d1e81f1e..ce7c2a99 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -286,6 +286,7 @@ const JAPANESE: &[(&str, &str)] = &[ ("Maximize Panel", "パネルを最大化"), ("Media Replacement", "メディアの置き換え"), ("Mesh:", "メッシュ:"), + ("Diameter:", "直径:"), ("Method Used: {} Average Error: {} pixels", "使用した手法:{} 平均誤差:{}ピクセル"), ("Models folder: {}", "モデルフォルダー:{}"), ("Modifiers:", "修飾キー:"), @@ -721,6 +722,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("Maximize Panel", "最大化面板"), ("Media Replacement", "媒体替换"), ("Mesh:", "网格:"), + ("Diameter:", "直径:"), ("Method Used: {} Average Error: {} pixels", "使用方法:{} 平均误差:{} 像素"), ("Models folder: {}", "模型文件夹:{}"), ("Modifiers:", "修饰键:"), @@ -1244,6 +1246,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("Maximize Panel", "最大化面板"), ("Media Replacement", "媒體替換"), ("Mesh:", "格點:"), + ("Diameter:", "直徑:"), ("Method Used: {} Average Error: {} pixels", "使用方法:{} 平均誤差:{} 像素"), ("Models folder: {}", "模型資料夾:{}"), ("Modifiers:", "修飾鍵:"), @@ -1757,6 +1760,7 @@ const UKRAINIAN: &[(&str, &str)] = &[ ("Maximize Panel", "Розгорнути панель"), ("Media Replacement", "Заміна матеріалу"), ("Mesh:", "Сітка:"), + ("Diameter:", "Діаметр:"), ("Method Used: {} Average Error: {} pixels", "Використаний метод: {} Середня похибка: {} пікселів"), ("Models folder: {}", "Тека моделей: {}"), ("Modifiers:", "Модифікатори:"), diff --git a/crates/ui-egui/src/panels/roto_tool.rs b/crates/ui-egui/src/panels/roto_tool.rs index f9090981..fbd7119c 100644 --- a/crates/ui-egui/src/panels/roto_tool.rs +++ b/crates/ui-egui/src/panels/roto_tool.rs @@ -168,6 +168,32 @@ pub fn interact( let sign = if kind == "bg" || kind == "refineErase" { "−" } else { "+" }; painter.text(hp, Align2::CENTER_CENTER, sign, Tokens::semibold(12.0), col); } + // ⌘/Ctrl-drag resizes the brush (After Effects): the circle stays at the press point and its + // edge follows the pointer. No stroke is painted. + let rid = egui::Id::new("layer-panel-roto-resize"); + if resp.drag_started() + && mods.command + && let Some(pos) = ui.input(|i| i.pointer.press_origin()).or(resp.interact_pointer_pos()) + { + ctx.data_mut(|d| d.insert_temp(rid, pos)); + } + if let Some(origin) = ctx.data(|d| d.get_temp::(rid)) { + if let Some(pos) = resp.interact_pointer_pos() + && zoom > 0.0 + { + let dia = (2.0 * f64::from(origin.distance(pos)) / zoom).clamp(1.0, 5000.0).round(); + let key = if refine { "refineDiameter" } else { "diameter" }; + if let Err(e) = app.session.execute("roto.options", json!({key: dia})) { + app.ui.status = e.to_string(); + } + painter.circle_stroke(origin, (dia * 0.5 * zoom) as f32, Stroke::new(1.5, col)); + painter.text(origin + vec2(0.0, -14.0), Align2::CENTER_BOTTOM, format!("{dia:.0} px"), Tokens::ui(11.0), col); + } + if resp.drag_stopped() || !resp.dragged() { + ctx.data_mut(|d| d.remove::(rid)); + } + return true; + } let did = egui::Id::new("layer-panel-roto-drag"); if resp.drag_started() && let Some(pos) = ui.input(|i| i.pointer.press_origin()).or(resp.interact_pointer_pos()) diff --git a/crates/ui-egui/tests/ui_viewer.rs b/crates/ui-egui/tests/ui_viewer.rs index fd12d555..bada77a7 100644 --- a/crates/ui-egui/tests/ui_viewer.rs +++ b/crates/ui-egui/tests/ui_viewer.rs @@ -1837,3 +1837,40 @@ fn full_resolution_frames_wider_than_the_texture_limit_fit() { assert!(full(&mut h), "the full-size frame is shown (and stays readable): {:?}", h.state().ui.viewer.res); assert_eq!(h.state().viewer_texture_size(), Some([800, 134]), "2400×400 averaged by 3"); } + +/// #509: ⌘/Ctrl-drag in the Layer panel resizes the Roto Brush (as in After Effects) without +/// painting, the next stroke uses the new size, and the tool options show the Diameter. +#[test] +fn roto_brush_ctrl_drag_resizes_the_brush() { + let mut h = harness(); + let id = h.state().session.active_comp().unwrap().layers.iter().find(|l| l.name == "Box").unwrap().id.0; + h.state_mut().session.execute("layer.select", json!({"layers": [id]})).unwrap(); + h.state_mut().ui.layer_panel = Some(id); + h.state_mut().show_panel(effectcraft_ui_egui::dock::PanelKind::Layer); + h.state_mut().ui.tool = Tool::RotoBrush; + h.run_steps(3); + assert!(h.state().auto.find("header.roto.diameter").is_some(), "the Diameter field"); + let canvas = rect(&h, "layerPanel.canvas"); + let zoom = canvas.width() / 80.0; + let c = canvas.center(); + h.input_mut().events.push(Event::ModifiersChanged(egui::Modifiers::COMMAND)); + drag(&mut h, c, c + vec2(20.0 * zoom, 0.0)); + h.input_mut().events.push(Event::ModifiersChanged(egui::Modifiers::NONE)); + h.run_steps(2); + let dia = h.state().session.state.roto.diameter; + assert!((dia - 40.0).abs() <= 1.0, "a 20 px drag makes a 40 px brush: {dia}"); + let strokes = |h: &Harness<'_, EffectcraftApp>| { + let l = h.state().session.active_comp().unwrap().layer(LayerId(id)).unwrap().clone(); + let roto = effectcraft_engine::effects::roto::ID; + let g = l + .effects() + .and_then(|fx| fx.groups().find(|g| matches!(&g.kind, effectcraft_engine::project::GroupKind::Effect { effect } if effect == roto)).cloned()); + g.map(|g| effectcraft_engine::effects::roto::data(&effectcraft_engine::effects::flatten_params(&g, &mut |p| p.value.clone())).strokes.clone()) + .unwrap_or_default() + }; + assert!(strokes(&h).is_empty(), "resizing paints nothing"); + drag(&mut h, c - vec2(10.0 * zoom, 0.0), c + vec2(10.0 * zoom, 0.0)); + let s = strokes(&h); + assert_eq!(s.len(), 1); + assert!((s[0].radius - dia / 2.0).abs() < 1e-6, "{} vs {dia}", s[0].radius); +} From f440adbb5a4931709a40609b9e209b46d6787c89 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 19:53:56 +0000 Subject: [PATCH 068/107] Dock: dragging a divider past a panel's minimum no longer crashes the app (#306) A split's first side was sized with `f32::clamp(20.min(avail), (avail - 20).max(0))`. `f32::clamp` panics when its lower bound exceeds its upper one, which happened for any split with 0 < avail < 40 pt. Dragging the viewer/Timeline divider up takes the area above it down to 40 pt, so the splits nested there (the right column's vertical split and others) got about 36 pt and the layout panicked; no guard covers egui's update, so the app closed with no dialog. The split size now goes through `split_first` / `clamp_ordered`, which never panic: when the room is too small for both minimums the lower bound wins, and non-finite sizes fall back to it. The gutter drag uses the same clamp. Tests: a dock unit test lays out every workspace in rects too short and too narrow for its minimums (it panicked before) and checks `split_first` on hostile sizes; a kittest drags the divider above the Timeline 2000 pt up and 3000 pt down. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f --- crates/ui-egui/src/dock.rs | 62 +++++++++++++++++++++++++++++---- crates/ui-egui/tests/ui_tabs.rs | 27 ++++++++++++++ 2 files changed, 82 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 65bca880..b586e8c9 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -878,12 +878,7 @@ pub fn layout(ui: &mut egui::Ui, node: &mut DockNode, rect: Rect, t: &Tokens, pa let g = t.gap; let total = if *vertical { rect.height() } else { rect.width() }; let avail = (total - g).max(0.0); - let first = match *size { - SplitSize::Ratio(r) => avail * r, - SplitSize::FixedA(px) => px.min(avail - 20.0), - SplitSize::FixedB(px) => avail - px.min(avail - 20.0), - } - .clamp(20.0_f32.min(avail), (avail - 20.0).max(0.0)); + let first = split_first(*size, avail); let (ra, gutter_rect, rb) = if *vertical { ( Rect::from_min_max(rect.min, pos2(rect.max.x, rect.min.y + first)), @@ -898,7 +893,7 @@ pub fn layout(ui: &mut egui::Ui, node: &mut DockNode, rect: Rect, t: &Tokens, pa ) }; if let Some(d) = gutter(ui, gutter_rect, *vertical, path, t, reg) { - let nf = (first + d).clamp(40.0, (avail - 40.0).max(40.0)); + let nf = clamp_ordered(first + d, 40.0, avail - 40.0); *size = match *size { SplitSize::Ratio(_) => SplitSize::Ratio(nf / avail.max(1.0)), SplitSize::FixedA(_) => SplitSize::FixedA(nf), @@ -965,6 +960,27 @@ fn stack_bodies(entries: &[StackEntry], avail: f32) -> Vec { .collect() } +/// `v` held to `lo..=hi`, never panicking: when the room is too small for both minimums +/// (`hi < lo`), `lo` wins; a non-finite `v` gives `lo`. +fn clamp_ordered(v: f32, lo: f32, hi: f32) -> f32 { + let lo = if lo.is_finite() { lo } else { 0.0 }; + let hi = if hi.is_finite() { hi.max(lo) } else { lo }; + if v.is_finite() { v.max(lo).min(hi) } else { lo } +} + +/// The size of a split's first side in `avail` points (both sides keep 20 pt when there is room). +/// `f32::clamp` panics when its bounds cross, which a split shorter than 40 pt did (#306). +fn split_first(size: SplitSize, avail: f32) -> f32 { + let avail = if avail.is_finite() { avail.max(0.0) } else { 0.0 }; + let first = match size { + SplitSize::Ratio(r) => avail * r, + SplitSize::FixedA(px) => px.min(avail - 20.0), + SplitSize::FixedB(px) => avail - px.min(avail - 20.0), + }; + let lo = 20.0_f32.min(avail); + clamp_ordered(first, lo, avail - 20.0).min(avail) +} + /// Drag the gap between the open stacked panels `above` and `below` (laid out at `bodies`) by /// `d` points: one grows as much as the other shrinks, neither below [`STACK_MIN`]. The new sizes /// become the panels' heights, except that the last panel without one keeps sharing the rest @@ -1328,6 +1344,38 @@ mod tests { } } + #[test] + fn short_splits_lay_out_without_panicking() { + // #306: a split 0 < avail < 40 pt tall made `f32::clamp` panic (its bounds crossed). + for avail in [0.0, 5.0, 19.0, 20.0, 30.0, 39.0, 40.0, 41.0, -10.0, f32::NAN, f32::INFINITY] { + for size in [SplitSize::Ratio(0.5), SplitSize::Ratio(f32::NAN), SplitSize::FixedA(300.0), SplitSize::FixedB(300.0), SplitSize::FixedA(-5.0)] { + let f = split_first(size, avail); + assert!(f.is_finite() && f >= 0.0, "{size:?} {avail}: {f}"); + if avail.is_finite() && avail > 0.0 { + assert!(f <= avail, "{size:?} {avail}: {f}"); + } + } + } + assert_eq!(split_first(SplitSize::FixedA(300.0), 30.0), 20.0); + assert_eq!(split_first(SplitSize::Ratio(0.5), 200.0), 100.0); + assert_eq!(clamp_ordered(5.0, 40.0, -4.0), 40.0); + // Every workspace, laid out in rects too short and too narrow for its minimums. + let ctx = egui::Context::default(); + let t = Tokens::for_kind(crate::theme::ThemeKind::Dark); + for w in WORKSPACES { + for (wd, ht) in [(30.0, 30.0), (800.0, 36.0), (800.0, 45.0), (36.0, 600.0), (1.0, 1.0), (0.0, 0.0)] { + let mut node = workspace(w); + let mut frame = ctx.run_ui(egui::RawInput::default(), |ui| { + let mut out = vec![]; + let mut reg = crate::automation::Registry::default(); + layout(ui, &mut node, Rect::from_min_size(pos2(0.0, 0.0), vec2(wd, ht)), &t, "", &mut out, &mut reg); + assert!(!out.is_empty(), "{w}"); + }); + frame.textures_delta.clear(); + } + } + } + #[test] fn drop_zone_math() { let r = Rect::from_min_size(pos2(100.0, 100.0), vec2(400.0, 300.0)); diff --git a/crates/ui-egui/tests/ui_tabs.rs b/crates/ui-egui/tests/ui_tabs.rs index d3a71ec3..207e5cf4 100644 --- a/crates/ui-egui/tests/ui_tabs.rs +++ b/crates/ui-egui/tests/ui_tabs.rs @@ -298,3 +298,30 @@ fn project_empty_area_double_click_and_dropped_files_import() { h.run_steps(2); assert_eq!(imports(&h), 2, "the dropped file was imported"); } + +/// Issue #306: dragging the viewer/Timeline divider far up made a nested split shorter than its +/// two 20 pt minimums, and `f32::clamp` panicked with crossed bounds, closing the app. +#[test] +fn dragging_the_viewer_timeline_divider_far_up_does_not_crash() { + let (mut h, _, _) = harness(); + // The horizontal gutter right above the Timeline. + let tl = rect(&h, "panel.Timeline"); + let gutter = |h: &Harness<'_, EffectcraftApp>| { + h.state() + .auto + .previous + .iter() + .filter(|e| e.id.starts_with("dock.gutter.") && e.rect[2] > e.rect[3]) + .map(|e| (e.id.clone(), egui::Rect::from_min_size(pos2(e.rect[0], e.rect[1]), egui::vec2(e.rect[2], e.rect[3])))) + .filter(|(_, g)| g.x_range().contains(tl.center().x) && g.max.y <= tl.min.y + 1.0) + .min_by(|a, b| (tl.min.y - a.1.max.y).total_cmp(&(tl.min.y - b.1.max.y))) + .expect("the gutter above the Timeline") + }; + let (id, g) = gutter(&h); + drag(&mut h, g.center(), g.center() - egui::vec2(0.0, 2000.0)); + let g2 = rect(&h, &id); + assert!(g2.center().y < g.center().y - 100.0, "the divider moved up: {g:?} → {g2:?}"); + // And back down past the bottom. + drag(&mut h, g2.center(), g2.center() + egui::vec2(0.0, 3000.0)); + h.run_steps(2); +} From 7b38d4d74e82881a71c2fe4c352ee3f7bdd476b6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 19:54:17 +0000 Subject: [PATCH 069/107] Dock: a click anywhere on a stacked panel's header bar expands or collapses it (#301) In the right-hand panel stack only the tab (the name plus its padding) answered clicks, so the rest of the header bar did nothing, a small target for short names like Audio. As in After Effects, the rest of the strip now toggles the panel too and focuses it. A stack group holds one panel, so the bar is unambiguous; the panel menu keeps its own click. The bar registers the automation id `panel.header.`. Test: a kittest clicks near the right end of Preview's header bar (collapses), its middle (expands) and its panel menu button (stays open). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f --- crates/ui-egui/src/dock.rs | 15 +++++++++++++++ crates/ui-egui/tests/ui_tabs.rs | 20 ++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index b586e8c9..d0daa86d 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -1177,6 +1177,21 @@ pub fn draw_group_chrome(ui: &mut egui::Ui, g: &Group, t: &Tokens, reg: &mut cra } x += w + 8.0; } + // A stacked panel's whole header bar expands/collapses it, not just its name (as in After + // Effects). A stack group holds one panel, so the rest of the strip is unambiguous. + if g.stacked.is_some() + && let Some(e) = entries.first() + { + let rest = Rect::from_min_max(pos2((x - 8.0).max(strip.min.x), strip.min.y), strip.max); + if rest.width() > 0.0 { + let resp = ui.interact(rest, egui::Id::new(("tab-rest", g.path.clone())), Sense::click()); + reg.add(&format!("panel.header.{}", e.panel.id()), rest, &e.label); + if resp.clicked() { + out.actions.push(DockAction::ToggleStacked(e.panel)); + out.actions.push(DockAction::Focus(e.panel)); + } + } + } } // clicking anywhere in the panel focuses it if let Some(p) = active_panel diff --git a/crates/ui-egui/tests/ui_tabs.rs b/crates/ui-egui/tests/ui_tabs.rs index 207e5cf4..8b52a72a 100644 --- a/crates/ui-egui/tests/ui_tabs.rs +++ b/crates/ui-egui/tests/ui_tabs.rs @@ -325,3 +325,23 @@ fn dragging_the_viewer_timeline_divider_far_up_does_not_crash() { drag(&mut h, g2.center(), g2.center() + egui::vec2(0.0, 3000.0)); h.run_steps(2); } + +/// Issue #301: clicking anywhere on a stacked panel's header bar expands/collapses it, not only +/// its name; the panel menu button keeps its own click. +#[test] +fn a_stacked_panels_whole_header_bar_toggles_it() { + let (mut h, _, _) = harness(); + let open = |h: &Harness<'_, EffectcraftApp>| h.state().ui.dock.is_visible(PanelKind::Preview); + assert!(open(&h)); + let bar = rect(&h, "panel.header.Preview"); + let tab = rect(&h, "panel.tab.Preview"); + assert!(bar.min.x >= tab.max.x - 1.0 && bar.width() > 40.0, "{bar:?} {tab:?}"); + click_n(&mut h, pos2(bar.max.x - 6.0, bar.center().y), 1); + assert!(!open(&h), "a click at the bar's right end collapses Preview"); + let bar = rect(&h, "panel.header.Preview"); + click_n(&mut h, bar.center(), 1); + assert!(open(&h), "and expands it again"); + let menu = rect(&h, "panel.menu.Preview"); + click_n(&mut h, menu.center(), 1); + assert!(open(&h), "the panel menu button doesn't toggle the panel"); +} From 3effa9a2ea80255270d9bbfdbfb758e0402edb15 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 19:54:17 +0000 Subject: [PATCH 070/107] Timeline: the view follows the CTI past its edges when scrubbing and during playback (#468) The ruler scrub set the time under the pointer and never moved the visible span, so with the timeline zoomed in a scrub couldn't go past the edge, and a preview ran the CTI out of view. As in After Effects: - zoomed in, a scrub held past either end of the ruler scrolls the timeline that way, faster the further past, with the CTI riding the edge (repainting while held); - during playback, when the CTI leaves the visible span the view pages to start at it (not past the comp's end). Tests: a unit test of the paging and edge speed, and a kittest that holds a scrub past the right end (the view scrolls, the CTI stays at the edge), then past the left end, then plays from beyond the view (it pages to the CTI). (#468's Shift+F and black-layer items need more information and aren't part of this.) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f --- crates/ui-egui/src/panels/timeline.rs | 60 +++++++++++++++++++++- crates/ui-egui/tests/ui_timeline_zoom.rs | 65 ++++++++++++++++++++++++ 2 files changed, 124 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 7df3439b..edf990cc 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -212,6 +212,29 @@ fn edge_scroll_speed(rows: Rect, y: f32, zone: f32) -> f32 { (depth / zone).clamp(-3.0, 3.0) * 400.0 } +/// Autoscroll speed (points per second, left negative) of a ruler scrub held at `x`, past the +/// left (`x0`) or right (`x1`) end of the visible time span: faster the further past. +fn edge_scroll_speed_x(x0: f32, x1: f32, x: f32) -> f32 { + let depth = if x < x0 { + x - x0 + } else if x > x1 { + x - x1 + } else { + return 0.0; + }; + (depth / 40.0).clamp(-3.0, 3.0) * 400.0 + depth.signum() * 100.0 +} + +/// The timeline's first visible time (`start`, showing `span` seconds of a `duration` comp) +/// paged so the CTI at `cti` is in view: unchanged while it is, else the page that starts at +/// the CTI (as After Effects pages the Timeline during a preview). +fn page_to_cti(start: f64, span: f64, duration: f64, cti: f64) -> f64 { + if !(start.is_finite() && span.is_finite() && cti.is_finite()) || (cti >= start && cti <= start + span) { + return start; + } + cti.min(duration - span).max(0.0) +} + /// The text of an expression being edited when its pick whip was pressed, and the selection in /// it (characters): the pick whip's reference goes in there. type WhipInto = (String, [usize; 2]); @@ -1217,6 +1240,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let pps = app.ui.timeline.pps.unwrap_or(fit_pps); if app.ui.timeline.pps.is_none() { app.ui.timeline.start = 0.0; + } else if app.playback.playing { + // During playback the view pages to keep the CTI in sight (After Effects). + let span = ((graph_x1 - graph_x0 - 6.0) as f64 / pps).max(0.0); + app.ui.timeline.start = page_to_cti(app.ui.timeline.start, span, comp.duration.seconds(), app.session.time().seconds()); } let tm = TMap { x0: graph_x0 + 6.0, start: app.ui.timeline.start, pps }; ctx.data_mut(|d| d.insert_temp(tl_map_id(), (tm.x0, tm.start, tm.pps))); @@ -1482,7 +1509,24 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if ((rresp.is_pointer_button_down_on() && ui.input(|i| i.pointer.primary_down())) || rresp.clicked()) && let Some(pt) = rresp.interact_pointer_pos() { - let mut secs = tm.t(pt.x).max(0.0); + let mut x = pt.x; + // Zoomed in, a scrub held past either end of the ruler scrolls the timeline that way + // (faster the further), with the CTI at the edge (After Effects). + let mut tm = tm; + if rresp.dragged() && app.ui.timeline.pps.is_some() { + let speed = edge_scroll_speed_x(tm.x0, graph_x1, x); + if speed != 0.0 { + let dt = f64::from(ui.input(|i| i.stable_dt).clamp(0.0, 0.1)); + let span = f64::from(graph_x1 - tm.x0) / pps; + let cur = app.ui.timeline.start; + let hi = (comp.duration.seconds() - span).max(0.0).max(cur); + app.ui.timeline.start = (cur + f64::from(speed) * dt / pps).clamp(0.0, hi); + tm.start = app.ui.timeline.start; + ui.ctx().request_repaint(); + } + x = x.clamp(tm.x0, graph_x1); + } + let mut secs = tm.t(x).max(0.0); // Shift-drag: snap to keyframes, in/out points, markers and the work area (8 px). if ui.input(|i| i.modifiers.shift) { let cands = snap_candidates(&comp, &build_rows(app, &comp)); @@ -3619,6 +3663,20 @@ fn value_editor( mod tests { use super::*; + /// #468: playback pages the view to the CTI; a scrub past either end scrolls that way. + #[test] + fn the_view_pages_to_the_cti() { + assert_eq!(page_to_cti(10.0, 5.0, 60.0, 12.0), 10.0, "in view: unchanged"); + assert_eq!(page_to_cti(10.0, 5.0, 60.0, 16.0), 16.0, "past the right end: the page from the CTI"); + assert_eq!(page_to_cti(10.0, 5.0, 60.0, 3.0), 3.0, "before the start"); + assert_eq!(page_to_cti(10.0, 5.0, 60.0, 58.0), 55.0, "not past the comp's end"); + assert_eq!(page_to_cti(10.0, 80.0, 60.0, 95.0), 0.0); + assert_eq!(page_to_cti(10.0, f64::NAN, 60.0, 95.0), 10.0); + assert_eq!(edge_scroll_speed_x(100.0, 500.0, 300.0), 0.0); + assert!(edge_scroll_speed_x(100.0, 500.0, 501.0) > 0.0 && edge_scroll_speed_x(100.0, 500.0, 99.0) < 0.0); + assert!(edge_scroll_speed_x(100.0, 500.0, 600.0) > edge_scroll_speed_x(100.0, 500.0, 520.0)); + } + /// A comp with two solids; "Box" has an animated Position and a Gaussian Blur. fn app() -> EffectcraftApp { let mut s = effectcraft_engine::Session::default(); diff --git a/crates/ui-egui/tests/ui_timeline_zoom.rs b/crates/ui-egui/tests/ui_timeline_zoom.rs index 48b19cb8..d2103811 100644 --- a/crates/ui-egui/tests/ui_timeline_zoom.rs +++ b/crates/ui-egui/tests/ui_timeline_zoom.rs @@ -311,3 +311,68 @@ fn dragging_out_of_the_marker_bin_adds_a_comp_marker() { h.run_steps(2); assert_eq!(markers(&h), vec![13.0, 20.0]); } + +/// #468: zoomed in, a CTI scrub held past the right end of the ruler scrolls the timeline (the +/// CTI rides the edge), past the left end scrolls it back; during playback the view pages to +/// keep the CTI in sight. +#[test] +fn the_timeline_follows_the_cti_past_its_edges() { + let (mut h, _) = harness(); + // (Opening the comp showed all of it.) + h.state_mut().ui.timeline.pps = Some(200.0); + h.run_steps(2); + let ruler = rect(&h, "timeline.ruler"); + let (s0, e0) = visible(&h); + assert_eq!(s0, 0.0); + let y = ruler.max.y - 4.0; + let from = pos2(ruler.center().x, y); + let past = pos2(ruler.max.x + 60.0, y); + h.event(Event::PointerMoved(from)); + h.step(); + h.event(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.step(); + for k in 1..=4 { + h.event(Event::PointerMoved(from + (past - from) * (k as f32 / 4.0))); + h.step(); + } + // Held still past the edge: it keeps scrolling (the view is read once the button is up). + h.run_steps(30); + let release = |h: &mut Harness<'_, EffectcraftApp>, at: Pos2| { + h.event(Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + }; + release(&mut h, past); + let (s1, e1) = visible(&h); + let cti = h.state().session.time().seconds(); + assert!(s1 > 0.5, "the view scrolled right: {s0}..{e0} → {s1}..{e1}"); + assert!(cti > e0 && cti >= s1 && cti <= e1 + 0.05, "the CTI rides the right edge: {cti} in {s1}..{e1}"); + // Pressed again and held past the left end: back to the left. + let left = pos2(ruler.min.x - 60.0, y); + h.event(Event::PointerMoved(from)); + h.step(); + h.event(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.step(); + for k in 1..=4 { + h.event(Event::PointerMoved(from + (left - from) * (k as f32 / 4.0))); + h.step(); + } + h.run_steps(5); + release(&mut h, left); + let (s2, _) = visible(&h); + let cti = h.state().session.time().seconds(); + assert!(s2 < s1 && (cti - s2).abs() < 0.05, "the view scrolled back left: {s1} → {s2}, CTI {cti}"); + + // Playback past the visible span pages the view to the CTI. + let (s3, e3) = visible(&h); + let t = e3 + 20.0; + h.state_mut().session.set_time(effectcraft_engine::time::Tick::from_seconds_f64(t)); + let now = h.ctx.input(|i| i.time); + h.state_mut().play(now); + assert!(h.state().playback.playing); + h.step(); + h.state_mut().stop(); + h.step(); + let cti = h.state().session.time().seconds(); + let (s4, e4) = visible(&h); + assert!(s4 > s3 && cti >= s4 && cti <= e4, "paged from {s3}..{e3} to {s4}..{e4} for the CTI at {cti}"); +} From a220b63bfbd816b94f5c75111e1fe38bf96444d5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 19:54:17 +0000 Subject: [PATCH 071/107] Help: the System Compatibility Report says why there is no GPU and names the window's adapter (#596) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With no GPU compositor the report only said "GPU: none", though the host had recorded the reason (`Session::accel_note`: the compositor's setup error, a retired GPU preview, a headless start) and the window's wgpu adapter was known. Users with a capable GPU (#596, an RTX 4070) couldn't tell why it wasn't used. The report now carries `gpuNote` (the reason, when there is no compositor) and `windowGraphics` (the adapter, backend, device type and driver the window draws with, which the desktop app records when it sets up the GPU), and the text shows "GPU: none (CPU compositing): " and "Window graphics: …"; the issue line names the reason too. Test: the compatibility report test sets a reason and an adapter and checks the JSON and text. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f --- crates/engine/src/commands/app_more.rs | 16 ++++++++++++++-- crates/engine/src/lib.rs | 4 ++++ crates/engine/src/tests_m145.rs | 9 +++++++++ crates/ui-egui/src/lib.rs | 9 +++++++++ 4 files changed, 36 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/commands/app_more.rs b/crates/engine/src/commands/app_more.rs index 34780274..b6101c5a 100644 --- a/crates/engine/src/commands/app_more.rs +++ b/crates/engine/src/commands/app_more.rs @@ -328,7 +328,10 @@ pub fn system_report(s: &Session) -> Value { let budgets = s.prefs.cache_budgets(mem.or(s.sys_memory)); let mut issues = vec![]; if gpu.is_none() { - issues.push("No GPU compositor: rendering uses the CPU (all features still work).".to_string()); + issues.push(match &s.accel_note { + Some(why) => format!("No GPU compositor ({why}): rendering uses the CPU (all features still work)."), + None => "No GPU compositor: rendering uses the CPU (all features still work).".to_string(), + }); } if mem.is_some_and(|m| m.total < 8 << 30) { issues.push("Less than 8 GB of memory: use lower preview resolutions for long or large comps.".to_string()); @@ -344,6 +347,8 @@ pub fn system_report(s: &Session) -> Value { "cores": crate::sysinfo::cpu_cores(), "memory": mem.map(|m| json!({"total": gb(m.total), "available": gb(m.available)})), "gpu": gpu, + "gpuNote": if gpu.is_none() { s.accel_note.clone() } else { None }, + "windowGraphics": s.window_adapter, "caches": {"layer": gb(budgets.layer as u64), "footage": gb(budgets.media as u64), "preview": gb(budgets.preview as u64)}, "import": IMPORT_FORMATS, "export": export, @@ -369,7 +374,14 @@ pub fn report_text(r: &Value) -> String { Some(m) => t.push_str(&format!("Memory: {} ({} available)\n", m["total"].as_str().unwrap_or("?"), m["available"].as_str().unwrap_or("?"))), None => t.push_str("Memory: unknown\n"), } - t.push_str(&format!("GPU: {}\n", r.get("gpu").and_then(Value::as_str).unwrap_or("none (CPU compositing)"))); + match (r.get("gpu").and_then(Value::as_str), r.get("gpuNote").and_then(Value::as_str)) { + (Some(gpu), _) => t.push_str(&format!("GPU: {gpu}\n")), + (None, Some(why)) => t.push_str(&format!("GPU: none (CPU compositing): {why}\n")), + (None, None) => t.push_str("GPU: none (CPU compositing)\n"), + } + if let Some(w) = r.get("windowGraphics").and_then(Value::as_str) { + t.push_str(&format!("Window graphics: {w}\n")); + } let c = &r["caches"]; t.push_str(&format!( "Cache budgets: layer {}, footage {}, preview {}\n", diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index a9ee6b89..ac805f4c 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -368,6 +368,9 @@ pub struct Session { /// Why there is no [`Session::accel`] (`render.backend`'s `why`): the host's reason, e.g. a /// headless start without `--gpu` or the compositor's setup error. pub accel_note: Option, + /// The graphics adapter and backend the window draws with (`NVIDIA GeForce RTX 4070 + /// (Vulkan)`), as the host reports it: the System Compatibility Report shows it. + pub window_adapter: Option, /// Expression syntax checker (set by the host that links the expression engine). pub expr_check: Option std::result::Result<(), String>>, pub importer: Option>, @@ -511,6 +514,7 @@ impl Default for Session { expr: None, accel: None, accel_note: None, + window_adapter: None, expr_check: None, importer: None, exporter: None, diff --git a/crates/engine/src/tests_m145.rs b/crates/engine/src/tests_m145.rs index b55442eb..2431c17c 100644 --- a/crates/engine/src/tests_m145.rs +++ b/crates/engine/src/tests_m145.rs @@ -455,6 +455,15 @@ fn compatibility_report_and_logging() { } let text = crate::commands::report_text_for_tests(&r); assert!(text.contains("Operating system") && text.contains("Import:")); + // #596: without a GPU compositor the report says why, and names the window's adapter. + s.accel_note = Some("GPU compositor unavailable: no storage textures".into()); + s.window_adapter = Some("NVIDIA GeForce RTX 4070 (Gl, DiscreteGpu)".into()); + let r = s.execute("help.systemReport", json!({"quiet": true})).unwrap(); + assert_eq!(r["gpuNote"], "GPU compositor unavailable: no storage textures"); + let text = crate::commands::report_text_for_tests(&r); + assert!(text.contains("GPU: none (CPU compositing): GPU compositor unavailable: no storage textures"), "{text}"); + assert!(text.contains("Window graphics: NVIDIA GeForce RTX 4070 (Gl, DiscreteGpu)"), "{text}"); + assert!(r["issues"][0].as_str().is_some_and(|i| i.contains("no storage textures")), "{r}"); s.drain_events(); s.execute("help.systemReport", json!({})).unwrap(); assert!(s.drain_events().iter().any(|e| matches!(e, crate::Event::Frontend { command, .. } if command == "app.showReport"))); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index dd0efc0c..f9175b66 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -612,6 +612,15 @@ impl EffectcraftApp { return; } let Some(rs) = frame.wgpu_render_state() else { return }; + // Help ▸ System Compatibility Report names the adapter and backend the window got. + let info = rs.adapter.get_info(); + self.session.window_adapter = Some(format!( + "{} ({:?}, {:?}{})", + info.name, + info.backend, + info.device_type, + if info.driver.is_empty() { String::new() } else { format!(", {} {}", info.driver, info.driver_info) } + )); // Backend validation can panic on shader compilation even when device creation // succeeded. Keep the document and the egui renderer, and use CPU compositing. let gpu = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| effectcraft_gpu::Gpu::new(&rs.adapter, rs.device.clone(), rs.queue.clone()))); From 8d32d67f0e19eaa6a2c48fd33508856d8f0046a8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 19:54:17 +0000 Subject: [PATCH 072/107] =?UTF-8?q?Color:=20Project=20Settings=20=E2=96=B8?= =?UTF-8?q?=20Working=20Gamma=202.2=20/=202.4=20with=20no=20working=20spac?= =?UTF-8?q?e,=20as=20in=20After=20Effects=20(#495)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After Effects' `app.project.workingGamma` (2.2 or 2.4, ignored with a working space) sets the curve an unmanaged project uses: at 2.2 the piecewise sRGB curve, at 2.4 a pure 2.4 power curve. EffectCraft always used sRGB, so linear EXRs (Interpret As Linear Light) from a 2.4 project came out darker in the shadows (0.18 → 0.461 instead of 0.490). - `ProjectSettings::working_gamma` (default 2.2, serde default for old projects). - The colour pipe's unmanaged curve is the Working Gamma's (sRGB, or Rec. 709's pure 2.4 curve): Interpret As Linear Light encodes untagged footage with it and Blend Colors Using 1.0 Gamma linearizes with it; it is part of the pipe's cache key. The GPU path takes the same conversions. - `file.projectSettings {workingGamma: 2.2|2.4}` (other values are refused, as in AE), `app.project.workingGamma` in scripts and a Working gamma choice in Project Settings. Test: render test with linear 0.051 / 0.18 / 0.5 footage (0.290 / 0.490 / 0.749 at 2.4, 0.250 / 0.461 / 0.735 at 2.2, the values measured in AE), linear blending at 2.4, a working space ignoring the setting, and the cache key. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f --- crates/engine/src/commands/file.rs | 10 +++++++- crates/project/src/lib.rs | 18 ++++++++++++++ crates/render/src/color.rs | 18 +++++++++++--- crates/render/src/lib.rs | 9 +++---- crates/render/src/tests_color.rs | 40 ++++++++++++++++++++++++++++++ crates/script/src/model.rs | 1 + crates/script/src/prelude.js | 2 ++ crates/ui-egui/src/panels/forms.rs | 2 ++ 8 files changed, 91 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/commands/file.rs b/crates/engine/src/commands/file.rs index f0396e5d..d3b9fec4 100644 --- a/crates/engine/src/commands/file.rs +++ b/crates/engine/src/commands/file.rs @@ -577,6 +577,14 @@ fn project_settings(s: &mut Session, p: &Value) -> Result { ), }; } + if let Some(g) = p.get("workingGamma") { + // After Effects takes 2.2 or 2.4 only (`app.project.workingGamma`). + proj.settings.working_gamma = match g.as_f64() { + Some(g) if (g - 2.2).abs() < 1e-6 => 2.2, + Some(g) if (g - 2.4).abs() < 1e-6 => 2.4, + _ => return Err(bad(cmd, format!("workingGamma: 2.2|2.4, not `{g}`"))), + }; + } if proj.settings.color_engine == ColorEngine::Ocio && !proj.settings.working_space.is_some_and(|w| w.is_linear()) { return Err(bad(cmd, "the OCIO built-in config's working spaces are acescg and aces2065")); } @@ -689,7 +697,7 @@ pub fn specs() -> Vec { "Project Settings...", ["File"], Some("Cmd+Alt+Shift+K"), - "{bitDepth?: 8|16|32, colorEngine?: adobe|ocio, workingSpace?: none|srgb|rec709|rec2020|p3|acescg|aces2065, linearize?, blendLinear?, hdr?: clip|compand|toneMap, outputSpace?: srgb|rec709|rec2020|p3|rec2100pq|rec2100hlg, renderer?: gpu|software, timeDisplay?: timecode|frames|feet35|feet16}", + "{bitDepth?: 8|16|32, colorEngine?: adobe|ocio, workingSpace?: none|srgb|rec709|rec2020|p3|acescg|aces2065, workingGamma?: 2.2|2.4 (with no working space), linearize?, blendLinear?, hdr?: clip|compand|toneMap, outputSpace?: srgb|rec709|rec2020|p3|rec2100pq|rec2100hlg, renderer?: gpu|software, timeDisplay?: timecode|frames|feet35|feet16}", always, project_settings ), diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index 064ac2e0..c1f110e0 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -219,6 +219,23 @@ pub struct ProjectSettings { /// sRGB; Rec. 2100 PQ / HLG give HDR output. #[serde(default)] pub output_space: Option, + /// Working Gamma, 2.2 or 2.4 (`app.project.workingGamma`): with no working space, the curve + /// Interpret As Linear Light footage is encoded with and Blend Colors Using 1.0 Gamma + /// linearizes with: the sRGB curve at 2.2, a pure 2.4 power curve at 2.4. Ignored with a + /// working space (as in After Effects). + #[serde(default = "gamma_22")] + pub working_gamma: f64, +} + +fn gamma_22() -> f64 { + 2.2 +} + +impl ProjectSettings { + /// Working Gamma is 2.4 (anything else reads as 2.2). + pub fn working_gamma_24(&self) -> bool { + (self.working_gamma - 2.4).abs() < 0.05 + } } impl Default for ProjectSettings { @@ -236,6 +253,7 @@ impl Default for ProjectSettings { color_engine: ColorEngine::Adobe, hdr: HdrMode::Clip, output_space: None, + working_gamma: 2.2, } } } diff --git a/crates/render/src/color.rs b/crates/render/src/color.rs index 19785722..a420256e 100644 --- a/crates/render/src/color.rs +++ b/crates/render/src/color.rs @@ -14,7 +14,8 @@ //! * **Blend Colors Using 1.0 Gamma**: only blending is linear. Each layer's finished pixels //! are linearised just before they are transformed and blended, the comp accumulates in linear //! and is encoded back to the working space when the comp is done (so precomps hand encoded -//! pixels to their parent). Without a working space the sRGB curve is used. +//! pixels to their parent). Without a working space the Working Gamma's curve is used (sRGB +//! at 2.2, a pure 2.4 power curve at 2.4); so is it for Interpret As Linear Light footage. use effectcraft_color::{ColorSpace, Conversion}; use effectcraft_project::{Footage, HdrMode, ProjectSettings}; @@ -36,6 +37,8 @@ pub struct Pipe { pub out: ColorSpace, /// HDR handling for standard-dynamic-range output. pub hdr: HdrMode, + /// Unmanaged with Working Gamma 2.4: the pure 2.4 power curve instead of sRGB's. + pub gamma24: bool, } impl Pipe { @@ -49,12 +52,20 @@ impl Pipe { linear_blend: s.blend_linear && !linear, out: s.output_space.unwrap_or(ColorSpace::Srgb), hdr: s.hdr, + gamma24: s.working_space.is_none() && s.working_gamma_24(), } } - /// The space whose curve encodes working-space pixels (sRGB when unmanaged). + /// The space whose curve encodes working-space pixels (unmanaged: the Working Gamma's, sRGB + /// at 2.2 and Rec. 709's pure 2.4 power curve at 2.4). fn curve(&self) -> ColorSpace { - self.space.unwrap_or(ColorSpace::Srgb) + self.space.unwrap_or(if self.gamma24 { ColorSpace::Rec709 } else { ColorSpace::Srgb }) + } + + /// The space Interpret As Linear Light encodes footage `f` in: its colour profile, else sRGB + /// (managed; the working space converts from there) or the Working Gamma's curve (unmanaged). + pub fn linear_footage_space(&self, f: &Footage) -> ColorSpace { + f.color_profile.unwrap_or(if self.space.is_some() { ColorSpace::Srgb } else { self.curve() }) } /// Footage `f` from its colour profile (`None` = sRGB) → working space; nothing for @@ -149,6 +160,7 @@ impl Pipe { k = k.wrapping_mul(31).wrapping_add(self.space.map_or(7, |s| s as u64 + 11)); k = k.wrapping_mul(31).wrapping_add(self.linear as u64 * 2 + self.linear_blend as u64); k = k.wrapping_mul(31).wrapping_add(self.out as u64 * 4 + self.hdr as u64); + k = k.wrapping_mul(31).wrapping_add(self.gamma24 as u64); k } } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 46e10866..79dcc21b 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -1200,12 +1200,12 @@ impl<'a> Renderer<'a> { 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))); + return Some(Arc::new(interpret_pixels(&field_frame(&img, parity), f, self.pipe.linear_footage_space(f)))); } // Interpretation is per pixel but not linear: it runs on the frame before resampling. let size = size.filter(|_| !(f.invert_alpha || f.linear_light_applies())); let img = self.footage_frame_raw(ctx, layer, item, f, t, size)?; - if f.invert_alpha || f.linear_light_applies() { Some(Arc::new(interpret_pixels(&img, f))) } else { Some(img) } + if f.invert_alpha || f.linear_light_applies() { Some(Arc::new(interpret_pixels(&img, f, self.pipe.linear_footage_space(f)))) } else { Some(img) } } fn footage_frame_raw(&self, ctx: &EvalCtx, layer: &Layer, item: ItemId, f: &Footage, t: Tick, size: Option<(u32, u32)>) -> Option> { @@ -2074,13 +2074,12 @@ pub fn field_frame(img: &Image, parity: usize) -> Image { } /// Interpret Footage ▸ Invert Alpha and Interpret As Linear Light on decoded (premultiplied) -/// pixels. -pub fn interpret_pixels(img: &Image, f: &Footage) -> Image { +/// pixels; linear light is encoded in `space` ([`color::Pipe::linear_footage_space`]). +pub fn interpret_pixels(img: &Image, f: &Footage, space: effectcraft_color::ColorSpace) -> Image { let linear = f.linear_light_applies(); if !f.invert_alpha && !linear { 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]; diff --git a/crates/render/src/tests_color.rs b/crates/render/src/tests_color.rs index a45fb69b..c9b8e56f 100644 --- a/crates/render/src/tests_color.rs +++ b/crates/render/src/tests_color.rs @@ -254,3 +254,43 @@ fn layer_cache_is_keyed_by_colour_settings() { assert!((frame(&p) - a).abs() < 1e-9); assert_eq!(cache.stats().hits, hits + 1); } + +/// #495: with no working space, Interpret As Linear Light footage is encoded with the project's +/// Working Gamma: the sRGB curve at 2.2, a pure 2.4 power curve at 2.4 (as After Effects does); +/// Blend Colors Using 1.0 Gamma linearizes with the same curve. A working space ignores it. +#[test] +fn working_gamma_24_encodes_linear_footage_with_a_power_curve() { + let render = |gamma: f64, ws: Option| { + let (mut p, cid, comp) = setup(BitDepth::Bpc32); + p.settings.working_gamma = gamma; + p.settings.working_space = ws; + let fid = p.add_item("F", Label::Aqua, None, ItemKind::Footage(Footage { linear_light: true, ..footage(None) })); + let l = build::layer(&mut p, &comp, "F", LayerSource::Footage { item: fid }, (40, 20), None); + p.comp_mut(cid).unwrap().layers.push(l); + let src = Flat([0.051, 0.18, 0.5]); + Renderer::new(&p, &src, RenderOpts::default()).comp_frame(cid, Tick::ZERO).get(5, 5) + }; + let near = |o: [f32; 4], want: [f32; 3]| (0..3).all(|i| (o[i] - want[i]).abs() < 2e-3); + let o = render(2.4, None); + assert!(near(o, [0.290, 0.490, 0.749]), "2.4: {o:?}"); + let o = render(2.2, None); + assert!(near(o, [0.250, 0.461, 0.735]), "2.2: {o:?}"); + // A working space ignores Working Gamma. + assert_eq!(render(2.4, Some(ColorSpace::Srgb)), render(2.2, Some(ColorSpace::Srgb))); + // Blending with 1.0 gamma: 50% white over black is 0.5 linear, encoded with the curve. + let blend = |gamma: f64| { + let (mut p, cid, _) = setup(BitDepth::Bpc32); + p.settings.working_gamma = gamma; + p.settings.blend_linear = true; + add_solid(&mut p, cid, [0.0, 0.0, 0.0], BlendMode::Normal, 100.0); + add_solid(&mut p, cid, [1.0, 1.0, 1.0], BlendMode::Normal, 50.0); + px(&p, cid)[0] + }; + assert!((blend(2.4) - 0.5f32.powf(1.0 / 2.4)).abs() < 2e-3, "{}", blend(2.4)); + assert!((blend(2.2) - 0.7354).abs() < 2e-3, "{}", blend(2.2)); + assert_ne!(crate::color::Pipe::of(&Project::default().settings).key(), { + let mut s = Project::default().settings; + s.working_gamma = 2.4; + crate::color::Pipe::of(&s).key() + }); +} diff --git a/crates/script/src/model.rs b/crates/script/src/model.rs index e0f17fef..4445fbbf 100644 --- a/crates/script/src/model.rs +++ b/crates/script/src/model.rs @@ -82,6 +82,7 @@ fn project(s: &Session) -> J { "linearBlending": p.settings.blend_linear, "linearizeWorkingSpace": p.settings.linearize, "workingSpace": p.settings.working_space.map(|w| format!("{w:?}")).unwrap_or_default(), + "workingGamma": if p.settings.working_gamma_24() { 2.4 } else { 2.2 }, "timeDisplayFrames": p.settings.time_display == effectcraft_project::TimeDisplayStyle::Frames, "renderQueue": p.render_queue.len(), "revision": s.revision, diff --git a/crates/script/src/prelude.js b/crates/script/src/prelude.js index 2b8085d3..f50a968c 100644 --- a/crates/script/src/prelude.js +++ b/crates/script/src/prelude.js @@ -1696,6 +1696,8 @@ Project.prototype = { set linearizeWorkingSpace(v) { __call("file.projectSettings", { linearize: !!v }); }, get workingSpace() { return this.__info().workingSpace; }, set workingSpace(v) { __call("file.projectSettings", { workingSpace: String(v) || "none" }); }, + get workingGamma() { return this.__info().workingGamma; }, + set workingGamma(v) { __call("file.projectSettings", { workingGamma: __num(v) }); }, get timeDisplayType() { return this.__info().timeDisplayFrames ? TimeDisplayType.FRAMES : TimeDisplayType.TIMECODE; }, set timeDisplayType(v) { __call("file.projectSettings", { timeDisplay: v === TimeDisplayType.FRAMES ? "frames" : "timecode" }); }, get expressionEngine() { return "javascript-1.0"; }, diff --git a/crates/ui-egui/src/panels/forms.rs b/crates/ui-egui/src/panels/forms.rs index c7f330b0..8569e287 100644 --- a/crates/ui-egui/src/panels/forms.rs +++ b/crates/ui-egui/src/panels/forms.rs @@ -546,6 +546,8 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool { ], st.working_space.map_or(0, |c| 1 + effectcraft_engine::project::ColorSpace::WORKING.iter().position(|x| *x == c).unwrap_or(0)), ), + // With Working Space None (After Effects' Working Gamma, 2.2 or 2.4). + Field::choice("workingGamma", "Working gamma", &[("2.2", json!(2.2)), ("2.4", json!(2.4))], usize::from(st.working_gamma_24())), Field::bool("linearize", "Linearize working space", st.linearize), Field::bool("blendLinear", "Blend colors using 1.0 gamma", st.blend_linear), Field::choice( From 5e2c7560d639f7f50986fd15e984dadd22f1b137 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 20:15:24 +0000 Subject: [PATCH 073/107] Import: only dropped files default to stills; scripts and agents naming a frame still get its run (#431) The UI behaves as in After Effects (the Import dialog's Sequence checkbox starts off, dropped files are stills, dropped folders make sequences), but file.import without sequence or drag keeps importing a numbered still's run, so existing scripts and agents keep working. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f --- crates/engine/src/commands/file.rs | 2 +- crates/engine/src/sequence.rs | 12 +++++++----- crates/engine/src/tests_sequence.rs | 6 +++++- docs/agents.md | 8 ++++---- docs/footage.md | 6 ++++-- 5 files changed, 21 insertions(+), 13 deletions(-) diff --git a/crates/engine/src/commands/file.rs b/crates/engine/src/commands/file.rs index f2347593..55b2c2b6 100644 --- a/crates/engine/src/commands/file.rs +++ b/crates/engine/src/commands/file.rs @@ -688,7 +688,7 @@ pub fn specs() -> Vec { "File...", ["File", "Import"], Some("Cmd+I"), - "{paths: [string] (files or folders), sequence?: bool (the Sequence checkbox: true imports numbered stills of one run as one image sequence, one picked file bringing its whole run and several picked files that range; by default picked files import as stills and folders' numbered stills as sequences; false imports a folder's files individually), alphabetical?: bool (Force Alphabetical Order: every image of that type in the folder, by name), frameRate?: fps (image sequences; default Settings ▸ Import ▸ Sequence Footage), importAs?: footage|composition|compositionLayerSizes (Photoshop, PDF, Illustrator and EPS files), layer?: name|index (footage of one Photoshop layer), page?: number from 1 (PDF / Illustrator page), drag?: bool (dropped files: Settings ▸ Import ▸ Default Drag Import As), addToComp?: bool (also add them to the active comp, at time?, index?, position? as in layer.addItem), background?: bool (probe the files in a background job, jobs.list / jobs.wait; returns {job})}", + "{paths: [string] (files or folders), sequence?: bool (the Sequence checkbox: true imports numbered stills of one run as one image sequence, one picked file bringing its whole run and several picked files that range; default: a numbered still brings its run, except dropped files (drag), which import as stills as in After Effects; folders' runs import as sequences; false imports stills and a folder's files individually), alphabetical?: bool (Force Alphabetical Order: every image of that type in the folder, by name), frameRate?: fps (image sequences; default Settings ▸ Import ▸ Sequence Footage), importAs?: footage|composition|compositionLayerSizes (Photoshop, PDF, Illustrator and EPS files), layer?: name|index (footage of one Photoshop layer), page?: number from 1 (PDF / Illustrator page), drag?: bool (dropped files: Settings ▸ Import ▸ Default Drag Import As; files import as stills unless sequence), addToComp?: bool (also add them to the active comp, at time?, index?, position? as in layer.addItem), background?: bool (probe the files in a background job, jobs.list / jobs.wait; returns {job})}", always, import_cmd ), diff --git a/crates/engine/src/sequence.rs b/crates/engine/src/sequence.rs index 79142955..c98aba1d 100644 --- a/crates/engine/src/sequence.rs +++ b/crates/engine/src/sequence.rs @@ -42,15 +42,17 @@ impl SequenceOptions { SequenceOptions { sequence, folders: sequence, alphabetical: flag("alphabetical").unwrap_or(false) } } - /// `file.import`'s options, as in After Effects: picked or dropped files import as stills - /// unless `sequence: true` (the Import dialog's " Sequence" checkbox, which is off - /// unless ticked), while a folder's numbered stills import as sequences unless - /// `sequence: false` (Alt-drag of a folder). + /// `file.import`'s options. Dropped files (`drag: true`) import as stills, as in After + /// Effects, while a folder's numbered stills import as sequences unless `sequence: false` + /// (Alt-drag of a folder). The Import dialog passes its " Sequence" checkbox (off + /// unless ticked) as `sequence`. Without `sequence` or `drag` (scripts, agents) a numbered + /// still brings its run, as it always has. pub fn for_import(p: &serde_json::Value) -> SequenceOptions { let flag = |k: &str| p.get(k).and_then(serde_json::Value::as_bool); let (sequence, alphabetical) = (flag("sequence"), flag("alphabetical").unwrap_or(false)); + let dropped = flag("drag").unwrap_or(false); // Force Alphabetical Order is part of the Sequence option. - SequenceOptions { sequence: sequence.unwrap_or(alphabetical), folders: sequence.unwrap_or(true), alphabetical } + SequenceOptions { sequence: sequence.unwrap_or(alphabetical || !dropped), folders: sequence.unwrap_or(true), alphabetical } } } diff --git a/crates/engine/src/tests_sequence.rs b/crates/engine/src/tests_sequence.rs index d59a9298..a89485f9 100644 --- a/crates/engine/src/tests_sequence.rs +++ b/crates/engine/src/tests_sequence.rs @@ -97,11 +97,15 @@ fn numbered_stills_import_as_stills_by_default() { let r = s.execute("file.import", json!({"paths": [at(&dir, "shot_0002.png")], "drag": true})).unwrap(); let (name, f) = footage(&s, &r["items"][0]); assert_eq!((name.as_str(), f.kind), ("shot_0002.png", FootageKind::Still)); - let r = s.execute("file.import", json!({"paths": [at(&dir, "shot_0001.png"), at(&dir, "shot_0004.png")]})).unwrap(); + let r = s.execute("file.import", json!({"paths": [at(&dir, "shot_0001.png"), at(&dir, "shot_0004.png")], "drag": true})).unwrap(); assert_eq!(r["items"].as_array().unwrap().len(), 2, "{r}"); let r = s.execute("file.import", json!({"paths": [dir.clone()], "drag": true})).unwrap(); let (name, f) = footage(&s, &r["items"][0]); assert_eq!((name.as_str(), f.kind), ("shot_[0001-0005].png", FootageKind::Sequence)); + // Scripts and agents naming one frame without `sequence` still get its run. + let r = s.execute("file.import", json!({"paths": [at(&dir, "shot_0003.png")]})).unwrap(); + let (_, f) = footage(&s, &r["items"][0]); + assert_eq!(f.kind, FootageKind::Sequence); } #[test] diff --git a/docs/agents.md b/docs/agents.md index 62fcd216..c631bc05 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -251,10 +251,10 @@ build real projects through these interfaces; they are worked examples of everyt then `keys.velocity {"outSpeed":0,"outInfluence":33.33}`. * **Gradients**: `set_property {"path":"contents/gfill/colors","value":["#0080ff","#ffff00"]}` or `{"colors":[[0,"#0080ff"],[1,[1,1,0]]],"opacities":[[0,1],[1,0.5]]}`. -* **Image sequences**: `file.import {"paths":["/r/shot_0001.png"], "sequence": true}` imports the - file's whole numbered run as one item (several picked frames: that range; a folder, even without - `sequence`: each run in it; without `sequence` picked files import as stills, as in After - Effects; `"alphabetical": true`: Force Alphabetical Order, the files +* **Image sequences**: `file.import {"paths":["/r/shot_0001.png"]}` imports the file's whole + numbered run as one item (several picked frames: that range; a folder: each run in it; + `"sequence": false`: one still; dropped files, `"drag": true`, import as stills unless + `"sequence": true`, as in After Effects; `"alphabetical": true`: Force Alphabetical Order, the files play one after another) at `frameRate` or 30 fps (Settings ▸ Import). Missing frames show colour bars, as in After Effects. `file.interpretFootage {"items":[id], "frameRate":12, "startTimecode":1001}` conforms them and sets Override Start (the item and layers that ran to its end get the new diff --git a/docs/footage.md b/docs/footage.md index d25dcc34..458c4c99 100644 --- a/docs/footage.md +++ b/docs/footage.md @@ -52,9 +52,11 @@ Effects does, so every other frame stays at its number. Render or copy the missi folder and choose File ▸ Reload Footage to fill the gaps. Agents: `file.import {"paths": ["/shots/shot_0001.png"], "sequence": true, "frameRate": 24}`. -Without `sequence`, picked files import as stills and a folder's numbered runs as sequences; +Without `sequence`, a numbered file brings its whole run and a folder's numbered runs import as +sequences, except dropped files (`"drag": true`), which import as stills as in After Effects. `"sequence": true` is the Sequence option (one file brings its whole run), `"sequence": false` -imports a folder's files one by one, and `"alphabetical": true` is Force Alphabetical Order. +imports stills and a folder's files one by one, and `"alphabetical": true` is Force Alphabetical +Order. ## Interpret Footage From 8c74fce5fa7d442e2267c2c409f60afbbaf7f767 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 20:29:58 +0000 Subject: [PATCH 074/107] GPU: a declined precomp falls back for that layer only; the decline warning is once per comp and reason (#641 follow-up) A nested comp too deep or too large for a GPU texture now takes the CPU path for its layer, as before #641, instead of failing the whole frame; only a device failure does. The warning no longer fires every frame (which pushed every other warning out of the System Report's log), and names the no-readback case and regions of interest. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f --- crates/gpu/src/lib.rs | 19 +++++++++++++++---- crates/gpu/src/walk.rs | 7 ++++++- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 4d3f6c77..723da308 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -102,6 +102,8 @@ pub struct Gpu { ctx: Arc, /// Backend::Auto's per-comp CPU / GPU timings. auto: Arc, + /// (comp, reason) pairs already warned about when the GPU declined a frame. + declined: Arc>>, } /// A viewer frame left on the GPU: premultiplied RGBA8 (`wgpu::TextureFormat::Rgba8Unorm`) with @@ -133,7 +135,7 @@ impl Gpu { } pub fn from_context(ctx: GpuContext) -> Gpu { - Gpu { ctx: Arc::new(ctx), auto: Default::default() } + Gpu { ctx: Arc::new(ctx), auto: Default::default(), declined: Default::default() } } /// A device of its own without blocking, with deferred readbacks (a browser worker, see @@ -291,8 +293,9 @@ impl Gpu { fn decline_reason(&self) -> String { match self.ctx.check_health() { Err(e) => format!("device failure / out of memory: {e}"), + Ok(()) if !self.ctx.can_readback() => "no GPU readback is available here".into(), Ok(()) => format!( - "the GPU compositor did not handle the frame (unsupported content, a frame over the {} px texture limit, or an allocation failed)", + "the GPU compositor did not handle the frame (a region of interest, unsupported content, a frame over the {} px texture limit, or an allocation failed)", self.ctx.max_dim ), } @@ -313,8 +316,16 @@ impl Accelerator for Gpu { let img = self.render(r, comp, t); if img.is_none() { // The caller renders the frame on the CPU; say so and why, or an export that falls - // back frame after frame (a nested comp the GPU declines) is only mysteriously slow. - log::warn!(target: "effectcraft_gpu", "GPU declined the frame at {:.3} s, the CPU renders it: {}", t.seconds(), self.decline_reason()); + // back frame after frame is only mysteriously slow. Once per comp and reason as a + // warning (the log keeps only the last few hundred, for the System Report), then at + // debug level. + let reason = self.decline_reason(); + let first = self.declined.lock().map(|mut seen| seen.len() < 256 && seen.insert((comp.0, reason.clone()))).unwrap_or(false); + if first { + log::warn!(target: "effectcraft_gpu", "GPU declined the frame at {:.3} s, the CPU renders it: {reason}", t.seconds()); + } else { + log::debug!(target: "effectcraft_gpu", "GPU declined the frame at {:.3} s: {reason}", t.seconds()); + } } img } diff --git a/crates/gpu/src/walk.rs b/crates/gpu/src/walk.rs index e2b2910f..83cb8550 100644 --- a/crates/gpu/src/walk.rs +++ b/crates/gpu/src/walk.rs @@ -258,7 +258,12 @@ fn draw_precomp(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, layer: &Layer, canvas: return None; } let (sub, item, lt, bs) = r.precomp_source(ctx, layer)?; - let Some(mut src) = render(e, &sub, item, lt) else { return Some(None) }; + // A nested comp the GPU can't take (nested too deep, over the texture limit, content it + // declines) goes back to the CPU path for this layer only; only a device failure fails the + // frame. + let Some(mut src) = render(e, &sub, item, lt) else { + return if e.g.check_health().is_ok() { None } else { Some(None) }; + }; let pipe = r.pipe(); // `masked_source` quantises a non-solid source to the project's depth. if let Some(l) = pipe.levels { From faffad3a6e56b00b73559524e54c86d1e83be9d4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 20:40:50 +0000 Subject: [PATCH 075/107] Media: a multi-layer EXR's picture and its EXtractoR / Cryptomatte channels share one decode (#474) A multi-layer OpenEXR file without an unnamed RGB layer (Blender's compositor File Output) was decoded twice per frame: `layered_image` decoded every channel to find the picture, then `MediaPool::aux` decoded the same bytes again into the aux-channel cache for EXtractoR and Cryptomatte. The first EXtractoR layer doubled the frame time. The footage path now reads the picture from the aux-channel cache (`cached_layered_image`), so the decode it makes is the one EXtractoR and Cryptomatte reuse. (Step 1 of the issue; reading only the picture's channels and keeping half channels as f16 are still to do.) Test: after a frame of a layered file is decoded, its aux channels come from the cache without reading the file again. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f --- crates/media/src/exr_channels.rs | 36 ++++++++++++++++++++++++++++++-- crates/media/src/pool.rs | 2 +- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/crates/media/src/exr_channels.rs b/crates/media/src/exr_channels.rs index 732cd3c4..6e90a6dd 100644 --- a/crates/media/src/exr_channels.rs +++ b/crates/media/src/exr_channels.rs @@ -161,9 +161,20 @@ fn picture_layer<'a>(aux: &AuxChannels, layers: &[&'a str]) -> Option<&'a str> { /// that is not a data pass (never Cryptomatte, depth or normals, #412), else the first channel /// as grey. Linear, straight RGBA as the file stores it. `None` when it is not a readable EXR. pub fn layered_image(bytes: &[u8]) -> Option { - let aux = read_exr_channels(bytes)?; + picture_of(&read_exr_channels(bytes)?) +} + +/// [`layered_image`] of the EXR at `path` from the channel cache, so the picture and EXtractoR / +/// Cryptomatte share one decode of the file (#474). +pub(crate) fn cached_layered_image(path: &str, bytes: Arc<[u8]>) -> Option { + let aux = cached(path, || Some(bytes))?; + picture_of(&aux) +} + +/// The picture of decoded channels (see [`layered_image`]). +fn picture_of(aux: &AuxChannels) -> Option { let layers = aux.layers(); - let colour = picture_layer(&aux, &layers)?; + let colour = picture_layer(aux, &layers)?; let names = aux.layer_rgba(colour); // A layer without R, G and B channels shows its first channel as grey. let first = aux.names().into_iter().find(|n| split_channel(n).0 == colour).unwrap_or_default(); @@ -254,6 +265,27 @@ mod tests { /// #295: a multi-layer EXR whose colour is only in named layers (`diffuse.R`, as Blender and /// Nuke write them) failed to import ("does not contain non-deep rgb channels"); it now /// shows its colour layer and keeps every channel for EXtractoR. + /// #474: the picture and EXtractoR / Cryptomatte share one decode of a multi-layer file: + /// after the frame is decoded, the aux channels come from the cache without reading the file. + #[test] + fn picture_and_aux_channels_share_one_decode() { + use exr::prelude::*; + let (w, h) = (2usize, 2usize); + let ch = |n: &str, v: f32| AnyChannel::new(n, FlatSamples::F32(vec![v; w * h])); + let channels = AnyChannels::sort(vec![ch("Image.R", 0.5), ch("Image.G", 0.5), ch("Image.B", 0.5), ch("GlossDir.R", 0.25)].into()); + let image = Image::from_layer(Layer::new((w, h), LayerAttributes::default(), Encoding::FAST_LOSSLESS, channels)); + let mut bytes = std::io::Cursor::new(Vec::new()); + image.write().to_buffered(&mut bytes).unwrap(); + let b: Arc<[u8]> = bytes.into_inner().into(); + let path = "/474-shared-decode.exr"; + let pool = crate::MediaPool::new(); + pool.add_bytes(path, b.clone()); + let f = crate::probe_bytes(path, b).unwrap(); + pool.frame_at(&f, effectcraft_time::Tick::ZERO).unwrap(); + let aux = cached(path, || panic!("the frame's decode is reused, the file is not read again")).unwrap(); + assert_eq!(aux.get("GlossDir.R").and_then(|p| p.first()).copied(), Some(0.25)); + } + #[test] fn layered_exr_without_an_unnamed_rgb_layer_imports() { use exr::prelude::*; diff --git a/crates/media/src/pool.rs b/crates/media/src/pool.rs index 9db2f225..ce980c56 100644 --- a/crates/media/src/pool.rs +++ b/crates/media/src/pool.rs @@ -741,7 +741,7 @@ impl Inner { let img = match image::load_from_memory(&bytes) { Ok(img) => img, // A multi-layer OpenEXR file without an unnamed RGB layer: its colour layer. - Err(e) => crate::exr_channels::layered_image(&bytes).ok_or_else(|| MediaError::Decode(format!("{path}: {e}")))?, + Err(e) => crate::exr_channels::cached_layered_image(path, bytes).ok_or_else(|| MediaError::Decode(format!("{path}: {e}")))?, }; Ok(dynamic_to_image(&img, op)) } From 1c9b5f1828cd9fe9a6faf2b4870a87291c29e952 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 20:42:34 +0000 Subject: [PATCH 076/107] Graph Editor: the speed graph of a curved motion path is smooth, not jagged (#456) Spatial properties move along their path by arc length: a table of 96 chords per segment, read back by linear interpolation of the curve parameter. Within each cell that mapping is linear, so the speed (the Graph Editor's speed graph, a central difference of the position) jumped at every cell boundary and was off by up to ~2 % on a curved path: a sawtooth over a constant-speed move, and a slight wobble in the motion itself. The table now holds Gauss-Legendre arc lengths, and `at_length` refines the cell's linear guess with Newton steps on the true arc length (|B'(u)| as the derivative), so position and speed follow the path exactly. Test: linear keys along a strongly curved path give a constant speed (within 0.1 %) at every sample; the old mapping was off by 2 %. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f --- crates/keyframe/src/lib.rs | 62 +++++++++++++++++++++++++++++++++----- 1 file changed, 55 insertions(+), 7 deletions(-) diff --git a/crates/keyframe/src/lib.rs b/crates/keyframe/src/lib.rs index 80537149..3ef50882 100644 --- a/crates/keyframe/src/lib.rs +++ b/crates/keyframe/src/lib.rs @@ -233,26 +233,44 @@ struct SpatialSeg { const ARC_N: usize = 96; +/// 5-point Gauss-Legendre nodes and weights on -1..1. +const GAUSS: [(f64, f64); 5] = [ + (0.0, 0.568_888_888_888_888_9), + (-0.538_469_310_105_683_1, 0.478_628_670_499_366_5), + (0.538_469_310_105_683_1, 0.478_628_670_499_366_5), + (-0.906_179_845_938_664, 0.236_926_885_056_189_1), + (0.906_179_845_938_664, 0.236_926_885_056_189_1), +]; + impl SpatialSeg { fn new(p: [[f64; 3]; 4]) -> SpatialSeg { let mut table = Vec::with_capacity(ARC_N + 1); table.push(0.0); - let mut prev = Self::point(&p, 0.0); let mut acc = 0.0; for i in 1..=ARC_N { - let q = Self::point(&p, i as f64 / ARC_N as f64); - acc += ((q[0] - prev[0]).powi(2) + (q[1] - prev[1]).powi(2) + (q[2] - prev[2]).powi(2)).sqrt(); + acc += Self::arc(&p, (i - 1) as f64 / ARC_N as f64, i as f64 / ARC_N as f64); table.push(acc); - prev = q; } SpatialSeg { p, table } } fn point(p: &[[f64; 3]; 4], u: f64) -> [f64; 3] { [bez(p[0][0], p[1][0], p[2][0], p[3][0], u), bez(p[0][1], p[1][1], p[2][1], p[3][1], u), bez(p[0][2], p[1][2], p[2][2], p[3][2], u)] } + /// |dB/du|. + fn tangent_len(p: &[[f64; 3]; 4], u: f64) -> f64 { + (0..3).map(|k| bez_d(p[0][k], p[1][k], p[2][k], p[3][k], u).powi(2)).sum::().sqrt() + } + /// Arc length between `a` and `b` (Gauss-Legendre; exact enough on one table cell). + fn arc(p: &[[f64; 3]; 4], a: f64, b: f64) -> f64 { + let (m, h) = ((a + b) * 0.5, (b - a) * 0.5); + GAUSS.iter().map(|(x, w)| w * Self::tangent_len(p, m + h * x)).sum::() * h + } fn length(&self) -> f64 { *self.table.last().unwrap_or(&0.0) } + /// The point at arc length `s`. The table cell's linear guess is refined with Newton steps + /// on the true arc length, so the position (and the speed graph, its derivative) is smooth + /// along curved paths instead of piecewise linear per cell (#456). fn at_length(&self, s: f64) -> [f64; 3] { let len = self.length(); if len <= 0.0 { @@ -260,9 +278,21 @@ impl SpatialSeg { } let s = s.clamp(0.0, len); let i = self.table.partition_point(|&v| v < s).clamp(1, ARC_N); - let (a, b) = (self.table[i - 1], self.table[i]); - let f = if b > a { (s - a) / (b - a) } else { 0.0 }; - Self::point(&self.p, (i as f64 - 1.0 + f) / ARC_N as f64) + let (Some(&a), Some(&b)) = (self.table.get(i - 1), self.table.get(i)) else { return self.p[0] }; + let (u0, u1) = ((i - 1) as f64 / ARC_N as f64, i as f64 / ARC_N as f64); + let mut u = u0 + if b > a { (s - a) / (b - a) } else { 0.0 } * (u1 - u0); + for _ in 0..4 { + let d = Self::tangent_len(&self.p, u); + if d <= 1e-12 { + break; + } + let e = a + Self::arc(&self.p, u0, u) - s; + u = (u - e / d).clamp(u0, u1); + if e.abs() <= 1e-12 * len.max(1.0) { + break; + } + } + Self::point(&self.p, u) } } @@ -764,6 +794,24 @@ mod tests { assert!((p[0] - 150.0).abs() < 0.5 && (p[1] - 200.0).abs() < 0.5, "{p:?}"); } + /// #456: along a curved spatial path with linear (constant-speed) keys the speed graph is + /// flat, not a sawtooth from a piecewise-linear arc-length table. + #[test] + fn spatial_curved_path_has_smooth_constant_speed() { + let mut a = Keyframe::new(s(0.0), Value::Vec2([0.0, 0.0])); + let mut b = Keyframe::new(s(2.0), Value::Vec2([400.0, 0.0])); + a.spatial_auto = false; + b.spatial_auto = false; + a.spatial_out = [50.0, -300.0, 0.0]; + b.spatial_in = [80.0, 250.0, 0.0]; + let keys = vec![a, b]; + let len = spatial_segment_length(&keys, 0); + let speeds: Vec = (1..400).map(|i| speed(&keys, s(i as f64 * 0.005), true)).collect(); + for v in &speeds { + assert!((v - len / 2.0).abs() < 1e-3 * len, "speed {v} vs {}", len / 2.0); + } + } + #[test] fn spatial_auto_bezier_passes_through_keys() { let keys = vec![ From ecb2cf1d3202cea55535a363845f76b6baf75a2c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 20:51:24 +0000 Subject: [PATCH 077/107] Blend modes: classic modes fade with opacity and HDR modes keep over-range values, as in After Effects (#494) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measured against After Effects at 32 bpc: - Classic Color Dodge, Classic Color Burn and Classic Difference at partial opacity: AE fades the source toward the mode's neutral colour (0 for Dodge and Difference, 1 for Burn) by the opacity and applies the classic formula at full strength (b / (1 − k·o), |b − k·o|, 1 − (1 − b) / (1 − k·(1 − o))). We mixed the full-strength result by opacity: up to 45/255 off on ordinary 0–1 footage. - Overlay, Exclusion, Luminosity, Color Burn and Classic Color Burn keep their plain formula for values above 1 (HDR passes). They were missing from `supports_hdr`, so their inputs were clamped to 0–1 (Exclusion was up to 208/255 off); Classic Color Burn also clamped its result at 0, where AE keeps negative values. The CPU blend (color::blend_pixel) and the GPU kernel (common.wgsl) change together. Tests: the reporter's formulas for each case (blend.rs); the CPU/GPU parity test of every blend mode at every bit depth (over-range values, 70 % opacity) passes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f --- crates/color/src/blend.rs | 73 +++++++++++++++++++++++++----- crates/gpu/src/shaders/common.wgsl | 16 +++++-- 2 files changed, 75 insertions(+), 14 deletions(-) diff --git a/crates/color/src/blend.rs b/crates/color/src/blend.rs index 2305f12a..0fecf4e9 100644 --- a/crates/color/src/blend.rs +++ b/crates/color/src/blend.rs @@ -159,7 +159,8 @@ impl BlendMode { /// Modes whose formulas are defined for over-range (HDR, 32 bpc) values. The others clamp /// their inputs to 0..1 before blending, as After Effects documents for 32 bpc projects - /// (in 8/16 bpc everything is already in range). + /// (in 8/16 bpc everything is already in range). Overlay, Exclusion, Luminosity and the + /// Color Burns keep their plain formula above 1 in After Effects (#494). pub fn supports_hdr(self) -> bool { matches!( self, @@ -168,6 +169,8 @@ impl BlendMode { | BlendMode::DancingDissolve | BlendMode::Darken | BlendMode::Multiply + | BlendMode::ColorBurn + | BlendMode::ClassicColorBurn | BlendMode::LinearBurn | BlendMode::DarkerColor | BlendMode::Add @@ -177,6 +180,9 @@ impl BlendMode { | BlendMode::LighterColor | BlendMode::Difference | BlendMode::ClassicDifference + | BlendMode::Overlay + | BlendMode::Exclusion + | BlendMode::Luminosity | BlendMode::Subtract | BlendMode::Divide | BlendMode::StencilAlpha @@ -188,6 +194,17 @@ impl BlendMode { ) } + /// The source colour a classic mode leaves the backdrop unchanged with. After Effects fades + /// the source toward it by the layer's opacity before applying the classic formula at full + /// strength, rather than mixing the full-strength result by opacity (#494). + fn classic_neutral(self) -> Option { + match self { + BlendMode::ClassicColorDodge | BlendMode::ClassicDifference => Some(0.0), + BlendMode::ClassicColorBurn => Some(1.0), + _ => None, + } + } + pub fn next(self) -> BlendMode { let i = BlendMode::ALL.iter().position(|m| *m == self).unwrap_or(0); BlendMode::ALL[(i + 1) % BlendMode::ALL.len()] @@ -242,11 +259,8 @@ fn separable(mode: BlendMode, cb: f32, cs: f32) -> f32 { BlendMode::Multiply => cb * cs, BlendMode::ColorBurn => burn(cb, cs), BlendMode::ClassicColorBurn => { - if cs <= 0.0 { - 0.0 - } else { - (1.0 - (1.0 - cb) / cs).max(0.0) - } + // (negative results are kept, as in After Effects) + if cs <= 0.0 { 0.0 } else { 1.0 - (1.0 - cb) / cs } } BlendMode::LinearBurn => (cb + cs - 1.0).max(0.0), BlendMode::Add | BlendMode::LinearDodge => cb + cs, @@ -401,11 +415,19 @@ pub fn blend_pixel(mode: BlendMode, dst: [f32; 4], src: [f32; 4], noise: f32) -> cs = cs.map(|v| v.clamp(0.0, 1.0)); cb = cb.map(|v| v.clamp(0.0, 1.0)); } - let b = non_separable(mode, cb, cs); let ao = sa + da - sa * da; let mut o = [0.0f32; 4]; - for i in 0..3 { - o[i] = (1.0 - da) * src[i] + (1.0 - sa) * dst[i] + sa * da * b[i]; + if let Some(neutral) = mode.classic_neutral() { + // Fade the source toward the neutral colour by its coverage, then apply the formula. + let b = non_separable(mode, cb, cs.map(|v| neutral + sa * (v - neutral))); + for i in 0..3 { + o[i] = (1.0 - da) * src[i] + da * b[i]; + } + } else { + let b = non_separable(mode, cb, cs); + for i in 0..3 { + o[i] = (1.0 - da) * src[i] + (1.0 - sa) * dst[i] + sa * da * b[i]; + } } o[3] = ao; o @@ -469,12 +491,41 @@ mod tests { #[test] fn hdr_modes() { - // Add and Screen keep over-range values (32 bpc); Overlay clamps its inputs. + // Add and Screen keep over-range values (32 bpc); Soft Light clamps its inputs. let d = [2.0, 2.0, 2.0, 1.0]; let s = [0.5, 0.5, 0.5, 1.0]; assert!((blend_pixel(BlendMode::Add, d, s, 0.5)[0] - 2.5).abs() < 1e-6); assert!((blend_pixel(BlendMode::Screen, d, s, 0.5)[0] - 1.5).abs() < 1e-6); - assert!(blend_pixel(BlendMode::Overlay, d, s, 0.5)[0] <= 1.0); + assert!(blend_pixel(BlendMode::SoftLight, d, s, 0.5)[0] <= 1.0); + } + + /// #494: After Effects' 32 bpc formulas, measured by the reporter. b = bottom, o = top, + /// k = opacity (premultiplied into the source, as the compositor passes it). + #[test] + fn after_effects_32bpc_formulas() { + let at = |m, b: f32, o: f32, k: f32| blend_pixel(m, opaque(b), [o * k, o * k, o * k, k], 0.5)[0]; + let close = |a: f32, e: f32| (a - e).abs() < 1e-5; + // Classic modes fade the source toward their neutral colour first. + for (b, o) in [(0.4, 0.5), (0.8, 0.9), (0.1, 0.3)] { + let k = 0.15; + assert!(close(at(BlendMode::ClassicColorDodge, b, o, k), (b / (1.0 - k * o)).min(1.0))); + assert!(close(at(BlendMode::ClassicDifference, b, o, k), (b - k * o).abs())); + assert!(close(at(BlendMode::ClassicColorBurn, b, o, k), 1.0 - (1.0 - b) / (1.0 - k * (1.0 - o)))); + } + // At full opacity the classic formulas are unchanged. + assert!(close(at(BlendMode::ClassicDifference, 0.6, 0.3, 1.0), 0.3)); + // Over-range top layers keep the plain formulas. + let (b, o) = (0.7, 3.0); + assert!(close(at(BlendMode::Exclusion, b, o, 1.0), b + o - 2.0 * b * o)); + let s = 2.0 * b - 1.0; + assert!(close(at(BlendMode::Overlay, b, o, 1.0), o + s - o * s)); + assert!(close(at(BlendMode::ColorBurn, 0.0, 1.35, 0.15), 0.15 * (1.0 - 1.0 / 1.35))); + assert!(close(at(BlendMode::ClassicColorBurn, 0.0, 0.1, 1.0), -9.0), "negative results are kept"); + // Luminosity: SetLum + ClipColor on the unclamped top layer. + let l = blend_pixel(BlendMode::Luminosity, [0.2, 0.4, 0.6, 1.0], [2.0, 2.0, 2.0, 1.0], 0.5); + let e = set_lum([0.2, 0.4, 0.6], 2.0); + assert!((0..3).all(|i| close(l[i], e[i])), "{l:?} {e:?}"); + assert!((l[0] - 1.0).abs() > 0.1, "not the white of a top layer clamped to 1: {l:?}"); } #[test] diff --git a/crates/gpu/src/shaders/common.wgsl b/crates/gpu/src/shaders/common.wgsl index 00ac946d..2c65c9d4 100644 --- a/crates/gpu/src/shaders/common.wgsl +++ b/crates/gpu/src/shaders/common.wgsl @@ -235,7 +235,7 @@ fn separable(mode: u32, cb: f32, cs: f32) -> f32 { if (cs <= 0.0) { return 0.0; } - return max(1.0 - (1.0 - cb) / cs, 0.0); + return 1.0 - (1.0 - cb) / cs; } case 7u: { return max(cb + cs - 1.0, 0.0); } case 9u, 14u: { return cb + cs; } @@ -343,7 +343,7 @@ fn non_separable(mode: u32, cb: vec3, cs: vec3) -> vec3 { fn supports_hdr(mode: u32) -> bool { switch mode { - case 0u, 1u, 2u, 3u, 4u, 7u, 8u, 9u, 10u, 11u, 14u, 15u, 23u, 24u, 26u, 27u, 32u, 33u, 34u, 35u, 36u, 37u: { return true; } + case 0u, 1u, 2u, 3u, 4u, 5u, 6u, 7u, 8u, 9u, 10u, 11u, 14u, 15u, 16u, 23u, 24u, 25u, 26u, 27u, 31u, 32u, 33u, 34u, 35u, 36u, 37u: { return true; } default: { return false; } } } @@ -388,8 +388,18 @@ fn blend_pixel(mode: u32, dst: vec4, src: vec4, noise: f32) -> vec4(0.0), vec3(1.0)); cb = clamp(cb, vec3(0.0), vec3(1.0)); } - let b = non_separable(mode, cb, cs); let ao = sa + da - sa * da; + // Classic Color Dodge / Difference (neutral 0) and Classic Color Burn (neutral 1): the + // source fades toward the neutral colour by its coverage, then the formula applies. + if (mode == 13u || mode == 24u || mode == 6u) { + var neutral = 0.0; + if (mode == 6u) { + neutral = 1.0; + } + let bc = non_separable(mode, cb, neutral + sa * (cs - neutral)); + return vec4((1.0 - da) * src.xyz + da * bc, ao); + } + let b = non_separable(mode, cb, cs); return vec4((1.0 - da) * src.xyz + (1.0 - sa) * dst.xyz + sa * da * b, ao); } From 75cc264ccffc6edcb5cef903117c032e9e45a4ff Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Sat, 10 Oct 2026 16:54:16 -0400 Subject: [PATCH 078/107] Lint: clippy's collapsible_match passes on Rust 1.95 cargo +1.95 clippy --workspace --all-targets --release -- -D warnings stopped on seven `if` blocks that clippy wants folded into their match arm (psd, text, effects x2, pdf x2, render). Each becomes a match guard. The behaviour is unchanged: when a guard is false the value falls through to arms that can't match it and then to `_ => {}`. The one exception is the PDF PostScript calculator, where a `{` past the nesting limit would fall through to the operator arm; it keeps its own no-op arm, so it is still skipped rather than pushed as an operator. --- crates/effects/src/audio_fx.rs | 6 ++---- crates/effects/src/generate3.rs | 6 ++---- crates/pdf/src/color.rs | 8 +++----- crates/pdf/src/page.rs | 8 +++----- crates/psd/src/lib.rs | 6 ++---- crates/render/src/masks.rs | 6 ++---- crates/text/src/path_text.rs | 6 ++---- 7 files changed, 16 insertions(+), 30 deletions(-) diff --git a/crates/effects/src/audio_fx.rs b/crates/effects/src/audio_fx.rs index a5e1fc4e..cd9153d6 100644 --- a/crates/effects/src/audio_fx.rs +++ b/crates/effects/src/audio_fx.rs @@ -245,10 +245,8 @@ pub fn process(id: &str, params: &Params, buf: &mut [f32], rate: u32, start: f64 } let sr = rate as f64; match id { - "ec.audio.backwards" => { - if params.b("swapChannels") { - buf.as_chunks_mut::<2>().0.iter_mut().for_each(|f| f.swap(0, 1)); - } + "ec.audio.backwards" if params.b("swapChannels") => { + buf.as_chunks_mut::<2>().0.iter_mut().for_each(|f| f.swap(0, 1)); } "ec.audio.basstreble" => { let bass = params.f("bass") / 100.0 * 12.0; diff --git a/crates/effects/src/generate3.rs b/crates/effects/src/generate3.rs index 926846a9..81797d9c 100644 --- a/crates/effects/src/generate3.rs +++ b/crates/effects/src/generate3.rs @@ -503,10 +503,8 @@ fn scan_runs(polys: &[(Vec<[f64; 2]>, bool, bool)], fill: u32, ew: f64, d: [f64; let entering = k % 2 == 0; let l = ew / sin; match mode { - 0 => { - if entering && k + 1 < xs.len() { - out.push((t, xs[k + 1].0)); - } + 0 if entering && k + 1 < xs.len() => { + out.push((t, xs[k + 1].0)); } 1 => out.push((t - l * 0.5, t + l * 0.5)), 2 => out.push(if entering { (t, t + l) } else { (t - l, t) }), diff --git a/crates/pdf/src/color.rs b/crates/pdf/src/color.rs index 019121e0..6943476b 100644 --- a/crates/pdf/src/color.rs +++ b/crates/pdf/src/color.rs @@ -221,11 +221,9 @@ fn ps_parse(lx: &mut crate::object::Lexer, depth: usize) -> Vec { match o { Obj::Num(n) => out.push(PsOp::Num(n)), Obj::Bool(b) => out.push(PsOp::Num(if b { 1.0 } else { 0.0 })), - Obj::Op(op) if op == "{" => { - if depth < 32 { - out.push(PsOp::Proc(ps_parse(lx, depth + 1))); - } - } + Obj::Op(op) if op == "{" && depth < 32 => out.push(PsOp::Proc(ps_parse(lx, depth + 1))), + // Nested too deep: skip the procedure's opening brace (never push it as an operator). + Obj::Op(op) if op == "{" => {} Obj::Op(op) if op == "}" => return out, Obj::Op(op) => out.push(PsOp::Op(op)), _ => {} diff --git a/crates/pdf/src/page.rs b/crates/pdf/src/page.rs index 94f62933..13e98b3c 100644 --- a/crates/pdf/src/page.rs +++ b/crates/pdf/src/page.rs @@ -313,11 +313,9 @@ impl<'a> Interp<'a> { self.path.curve_to(Point::new(n(0), n(1)), p, p); self.cur = Some(p); } - "h" => { - if self.cur.is_some() { - self.path.close_path(); - self.cur = self.start; - } + "h" if self.cur.is_some() => { + self.path.close_path(); + self.cur = self.start; } "re" => { let (x, y, w, h) = (n(0), n(1), n(2), n(3)); diff --git a/crates/psd/src/lib.rs b/crates/psd/src/lib.rs index 422c3153..1c0fd3d4 100644 --- a/crates/psd/src/lib.rs +++ b/crates/psd/src/lib.rs @@ -656,10 +656,8 @@ impl Psd { let gamma = r.f32()? as f64; l.adjustment = Some(Adjustment::Exposure { exposure, offset, gamma }); } - b"curv" | b"blnc" | b"post" | b"thrs" | b"selc" | b"mixr" | b"phfl" | b"vibA" | b"blwh" | b"grdm" | b"clrL" => { - if l.adjustment.is_none() { - l.adjustment = Some(Adjustment::Other(String::from_utf8_lossy(key).into_owned())); - } + b"curv" | b"blnc" | b"post" | b"thrs" | b"selc" | b"mixr" | b"phfl" | b"vibA" | b"blwh" | b"grdm" | b"clrL" if l.adjustment.is_none() => { + l.adjustment = Some(Adjustment::Other(String::from_utf8_lossy(key).into_owned())); } b"SoLd" | b"SoLE" => { let _id = r.tag()?; diff --git a/crates/render/src/masks.rs b/crates/render/src/masks.rs index 980e6475..f36c6d9e 100644 --- a/crates/render/src/masks.rs +++ b/crates/render/src/masks.rs @@ -114,10 +114,8 @@ pub fn shapes(ctx: &EvalCtx, layer: &Layer) -> Vec = Vec::new(); let mut closed = false; kurbo::flatten(path.iter(), 0.25, |el| match el { - effectcraft_path::PathEl::MoveTo(p) | effectcraft_path::PathEl::LineTo(p) => { - if pts.last() != Some(&[p.x, p.y]) { - pts.push([p.x, p.y]); - } + effectcraft_path::PathEl::MoveTo(p) | effectcraft_path::PathEl::LineTo(p) if pts.last() != Some(&[p.x, p.y]) => { + pts.push([p.x, p.y]); } effectcraft_path::PathEl::ClosePath => closed = true, _ => {} diff --git a/crates/text/src/path_text.rs b/crates/text/src/path_text.rs index 5c2b4338..85cab071 100644 --- a/crates/text/src/path_text.rs +++ b/crates/text/src/path_text.rs @@ -32,10 +32,8 @@ impl PathMeasure { pts.push(p); } } - PathEl::LineTo(p) => { - if pts.last().is_none_or(|q| q.distance(p) > 1e-9) { - pts.push(p); - } + PathEl::LineTo(p) if pts.last().is_none_or(|q| q.distance(p) > 1e-9) => { + pts.push(p); } PathEl::ClosePath => { closed = true; From d3b26f2a659b85c16ea01f949723a49f091a1487 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 21:00:37 +0000 Subject: [PATCH 079/107] Appearance: Sync with System keeps following macOS appearance changes after startup (#642) apply_visuals pinned egui's theme preference to Light or Dark on every apply. egui syncs that to the native window (ViewportCommand::SetTheme), winit then gives the NSWindow an explicit Aqua/DarkAqua appearance, and a window with its own appearance no longer receives the system's ThemeChanged events, so Sync with System froze on the first frame's appearance. Our visuals now go into both of egui's styles (light and dark), and the preference is System while the appearance mode is Sync with System, so the window keeps following the OS; fixed Light / Dark modes still pin it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f --- crates/ui-egui/src/lib.rs | 30 ++++++++++++++++++++++++++++++ crates/ui-egui/src/theme.rs | 21 ++++++++++++++++----- 2 files changed, 46 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 92eba0fb..00af44aa 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -427,6 +427,7 @@ impl EffectcraftApp { self.ui.theme = theme::ThemeKind::from_name(p.appearance.resolved_theme(system)).unwrap_or_default(); self.tokens = Tokens::from_prefs(p, self.ui.theme); theme::apply_visuals(ctx, &self.tokens); + ctx.set_theme(theme::theme_preference(&p.appearance.appearance_mode, self.ui.theme.is_light())); let tips = p.general.show_tool_tips; ctx.all_styles_mut(|s| s.interaction.tooltip_delay = if tips { 0.5 } else { f32::INFINITY }); // Settings ▸ Appearance ▸ UI Scale (#284). egui's own Ctrl+= / Ctrl+- / Ctrl+0 zoom stays @@ -1891,6 +1892,35 @@ mod appearance_tests { assert_eq!(app.ui.theme, ThemeKind::Dark); } + /// Sync with System leaves egui's theme preference at System, so egui never gives the native + /// window an appearance of its own (on macOS that stops system appearance changes reaching + /// the app, #642); egui's light and dark styles both carry the resolved theme. Fixed modes + /// pin the window to their family. + #[test] + fn auto_mode_leaves_the_native_window_following_the_system() { + let ctx = egui::Context::default(); + let mut app = app(); + app.hooks.system_theme = Some(Box::new(|_| Some(egui::Theme::Light))); + app.set_pref("appearance.appearanceMode", json!("auto")).unwrap(); + app.apply_prefs(&ctx); + assert_eq!(app.ui.theme, ThemeKind::Light); + assert_eq!(ctx.options(|o| o.theme_preference), egui::ThemePreference::System); + for theme in [egui::Theme::Light, egui::Theme::Dark] { + assert_eq!(ctx.style_of(theme).visuals.panel_fill, app.tokens.panel_bg, "{theme:?}"); + } + // egui asks the window to follow the system, never to take Light or Dark. + let mut out = ctx.run_ui(egui::RawInput::default(), |_| {}); + out.textures_delta.clear(); + let cmds = out.viewport_output.values().flat_map(|v| v.commands.iter()).collect::>(); + assert!(!cmds.iter().any(|c| matches!(c, egui::ViewportCommand::SetTheme(t) if t != &egui::SystemTheme::SystemDefault)), "{cmds:?}"); + for (mode, pref) in [("light", egui::ThemePreference::Light), ("dark", egui::ThemePreference::Dark)] { + app.set_pref("appearance.appearanceMode", json!(mode)).unwrap(); + app.apply_prefs(&ctx); + assert_eq!(ctx.options(|o| o.theme_preference), pref, "{mode}"); + assert_eq!(ctx.global_style().visuals.panel_fill, app.tokens.panel_bg, "{mode}"); + } + } + /// Picking a theme (`view.theme.*`, `ui.set {theme}`) fixes the mode to its family. #[test] fn picking_a_theme_fixes_its_mode() { diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 928dca9b..febd1991 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -405,11 +405,20 @@ fn is_chinese(language: &str) -> bool { matches!(language, "zh-hans" | "zh-hant") } +/// egui's theme preference for an appearance mode showing a `light` (or dark) theme. Sync with +/// System leaves it at System: a concrete preference makes egui set the native window's +/// appearance, and on macOS a window with its own appearance no longer hears when the system's +/// changes (#642). A fixed Light or Dark mode pins the window to match. +pub fn theme_preference(appearance_mode: &str, light: bool) -> egui::ThemePreference { + match appearance_mode { + "auto" => egui::ThemePreference::System, + _ if light => egui::ThemePreference::Light, + _ => egui::ThemePreference::Dark, + } +} + pub fn apply_visuals(ctx: &egui::Context, t: &Tokens) { let light = t.kind.is_light(); - // egui keeps one style per light/dark theme and by default picks it by the system appearance; - // pin it to ours so a fixed Dark or Light mode isn't swapped for egui's defaults. - ctx.set_theme(if light { egui::ThemePreference::Light } else { egui::ThemePreference::Dark }); let mut v = if light { Visuals::light() } else { Visuals::dark() }; v.panel_fill = t.panel_bg; v.window_fill = t.panel_bg; @@ -444,8 +453,10 @@ pub fn apply_visuals(ctx: &egui::Context, t: &Tokens) { v.widgets.active.fg_stroke = Stroke::new(1.0, t.tab_text_active); v.widgets.open.bg_fill = t.hover; v.widgets.open.weak_bg_fill = t.hover; - ctx.set_visuals(v); - ctx.global_style_mut(|s| { + // egui keeps one style per light/dark theme and picks it by its theme preference (see + // [`theme_preference`]); both get ours, so whichever it picks, egui's defaults never show. + ctx.all_styles_mut(|s| { + s.visuals = v.clone(); s.spacing.item_spacing = egui::vec2(6.0, 4.0); s.spacing.button_padding = egui::vec2(8.0, 3.0); s.spacing.interact_size.y = 22.0; From 1bcfe6912d84547eabe3a6ed2df7472f9292f813 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 21:11:53 +0000 Subject: [PATCH 080/107] Timeline: colour properties get an eyedropper after their swatch, as in After Effects (#633) A shape's Fill and Stroke Color (and every other colour property) in the Timeline only had a swatch that opens the colour picker; After Effects puts an eyedropper next to it to sample a colour from the Composition panel. The Timeline now shows that eyedropper (`timeline.prop..eyedropper`), reusing Effect Controls' viewer pick: click it, then click in the Composition panel, and the property is set to the sampled colour. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f --- crates/ui-egui/src/panels/effect_controls.rs | 5 ++- crates/ui-egui/src/panels/timeline.rs | 11 +++++ crates/ui-egui/tests/ui_timeline_shapes.rs | 46 ++++++++++++++++++++ 3 files changed, 60 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index 6612187e..fb388845 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -186,8 +186,9 @@ fn key_navigator( } } -/// Toggle a viewer pick (crosshair / eyedropper) for a parameter. -fn toggle_pick(app: &mut EffectcraftApp, kind: &str, layer: &Layer, prop: &Property) { +/// Toggle a viewer pick (crosshair / eyedropper) for a parameter (also the Timeline's colour +/// eyedroppers). +pub(crate) fn toggle_pick(app: &mut EffectcraftApp, kind: &str, layer: &Layer, prop: &Property) { let pick = FxPick { kind: kind.into(), layer: layer.id.0, prop: prop.uid, name: prop.name.clone() }; if app.ui.fx_pick.as_ref() == Some(&pick) { app.ui.fx_pick = None; diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 086e46dc..b28c0d23 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -3591,6 +3591,17 @@ fn value_editor( if resp.clicked() { widgets::open_popup(ui, pop); } + // The eyedropper after the swatch samples the Composition panel (shape Fill and + // Stroke colours, text and effect colours), as in After Effects' Timeline (#633). + let er = Rect::from_center_size(pos2(r.max.x + 12.0, at.y), vec2(16.0, 16.0)); + let active = app.ui.fx_pick.as_ref().is_some_and(|k| k.prop == uid && k.kind == "color"); + if super::fx_widgets::eyedropper_button(ui, er, active, egui::Id::new(("tl-eye", uid)), &t) + .on_hover_text(crate::i18n::tr("Eyedropper: click, then click in the Composition panel")) + .clicked() + { + super::effect_controls::toggle_pick(app, "color", layer, prop); + } + app.auto.add(&format!("timeline.prop.{uid}.eyedropper"), er, &prop.name); let mut rgb = [c[0] as f32, c[1] as f32, c[2] as f32]; if crate::widgets::color_popup(ui, pop, r.left_bottom(), &mut rgb) { set(actions, json!([rgb[0], rgb[1], rgb[2], 1.0])); diff --git a/crates/ui-egui/tests/ui_timeline_shapes.rs b/crates/ui-egui/tests/ui_timeline_shapes.rs index 811b55c7..dd9232d7 100644 --- a/crates/ui-egui/tests/ui_timeline_shapes.rs +++ b/crates/ui-egui/tests/ui_timeline_shapes.rs @@ -197,3 +197,49 @@ fn toolbar_stroke_edits_the_selected_shape_layer() { assert_eq!(width, Some(4.0), "a stroke was added with the toolbar's width"); assert_eq!(h.state().session.state.shape_tool.stroke_width, 4.0); } + +/// Issue #633: a colour property in the Timeline (a shape's Fill Color) has an eyedropper after +/// its swatch, as in After Effects: click it, then click in the Composition panel to sample. +#[test] +fn timeline_colour_eyedropper_samples_the_comp() { + let mut s = session(); + s.execute("layer.newSolid", json!({"name": "Back", "color": "#20c040"})).unwrap(); + let l = s.execute("layer.newShape", json!({"kind": "rect", "fill": [1.0, 0.0, 0.0], "size": [100.0, 60.0]})).unwrap()["layer"].as_u64().unwrap(); + let layer = s.active_comp().unwrap().layer(LayerId(l)).unwrap().clone(); + let mut fill = None; + layer.props.walk("", &mut |_, p| { + if fill.is_none() && matches!(p.value, effectcraft_engine::keyframe::Value::Color(_)) { + fill = Some(p.uid); + } + }); + let fill = fill.expect("a fill colour"); + fn groups(g: &effectcraft_engine::project::PropGroup, out: &mut Vec) { + for c in g.groups() { + out.push(c.uid); + groups(c, out); + } + } + let mut open = vec![]; + groups(&layer.props, &mut open); + let mut h = harness(s); + h.state_mut().ui.timeline.open_groups.extend(open); + open_layer(&mut h, l); + h.run_steps(2); + click(&mut h, &format!("timeline.prop.{fill}.eyedropper")); + assert_eq!(h.state().ui.fx_pick.as_ref().map(|p| (p.prop, p.kind.as_str())), Some((fill, "color")), "the eyedropper waits for a viewer click"); + let pos = effectcraft_ui_egui::panels::viewer::comp_to_screen(&h.ctx, [6.0, 6.0]).unwrap(); + click_at(&mut h, pos); + assert!(h.state().ui.fx_pick.is_none(), "the pick is used up"); + let layer = h.state().session.active_comp().unwrap().layer(LayerId(l)).unwrap().clone(); + let mut got = None; + layer.props.walk("", &mut |_, p| { + if p.uid == fill { + got = Some(p.value.clone()); + } + }); + let Some(effectcraft_engine::keyframe::Value::Color(c)) = got else { panic!("{got:?}") }; + let want = [0x20 as f64 / 255.0, 0xc0 as f64 / 255.0, 0x40 as f64 / 255.0]; + for k in 0..3 { + assert!((c[k] - want[k]).abs() < 0.02, "{c:?} vs {want:?}"); + } +} From 6240ed646eec2ed1f12e3d693c3340cf9010ad5f Mon Sep 17 00:00:00 2001 From: Katt <238968166+KattKnight@users.noreply.github.com> Date: Sun, 11 Oct 2026 01:14:31 +0530 Subject: [PATCH 081/107] Keyframes: auto-Bezier spatial tangents follow After Effects Measured against After Effects 26.3 with the original examples/ae-spatial-tangents.jsx (three paths, ten keys, even and uneven timing): an auto-Bezier key's out tangent is (next - previous) / 6 and the in tangent its mirror, whatever the key times; an end key uses its one neighbour, (neighbour - key) / 6. EffectCraft gave end keys none and scaled middle tangents by segment length, so paths with auto-Bezier keys ran up to 28 px from AE's. All ten tangents now match; path samples are within 0.05 px. Tests that relied on end keys having no tangent now expect AE's: Spatial Bezier keeps auto keys' handles (linear keys still get a third), the motion-path handle is drawn at a sixth, and a straight loopOut path is compared within 1e-9. docs/fidelity records the method and scores. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/tests_timeline.rs | 20 ++++++----- crates/expr/src/tests.rs | 6 +++- crates/keyframe/src/lib.rs | 51 +++++++++++++++++++---------- crates/ui-egui/tests/ui_viewer.rs | 13 ++++---- docs/fidelity/README.md | 20 ++++++++++- docs/fidelity/ae-2026-acceptance.md | 2 +- examples/ae-spatial-tangents.jsx | 27 +++++++++++++++ 7 files changed, 105 insertions(+), 34 deletions(-) create mode 100644 examples/ae-spatial-tangents.jsx diff --git a/crates/engine/src/tests_timeline.rs b/crates/engine/src/tests_timeline.rs index a741f5ad..37f66c77 100644 --- a/crates/engine/src/tests_timeline.rs +++ b/crates/engine/src/tests_timeline.rs @@ -163,20 +163,24 @@ fn velocity_dialog_values_per_dimension_and_continuous() { undo_redo_roundtrip(&mut s, after, |s| assert_eq!(prop(s, l, "transform/scale").keys[1].in_interp, Interp::Linear)); } -/// Spatial Bezier on a straight two-key path gives both keys handles a third of the way along it -/// (they had none), so the path can be pulled into a curve; the motion stays the same (#290). +/// Spatial Bezier keeps the handles a key has and gives a straight linear path handles a third of +/// the way along it (they had none), so the path can be pulled into a curve; the motion stays the +/// same (#290). Auto-Bezier keys keep their tangents, a sixth of the way to the neighbour here. #[test] fn spatial_bezier_gives_a_straight_path_handles() { let (mut s, l) = setup(); animate(&mut s, l, "transform/position", &[(0.0, json!([0, 0])), (2.0, json!([300, 0]))]); let mid = prop(&s, l, "transform/position").value_at(effectcraft_time::Tick::from_seconds_f64(0.7)).as_vec3(); select_prop_keys(&mut s, l, "transform/position"); - s.execute("keys.interpolation", json!({"spatial": "bezier"})).unwrap(); - let p = prop(&s, l, "transform/position"); - assert!(p.keys.iter().all(|k| !k.spatial_auto)); - assert_eq!((p.keys[0].spatial_out, p.keys[1].spatial_in), ([100.0, 0.0, 0.0], [-100.0, 0.0, 0.0])); - let now = p.value_at(effectcraft_time::Tick::from_seconds_f64(0.7)).as_vec3(); - assert!((now[0] - mid[0]).abs() < 0.01 && now[1].abs() < 1e-9, "{mid:?} → {now:?}"); + for (from, handle) in [("autoBezier", 50.0), ("linear", 100.0)] { + s.execute("keys.interpolation", json!({"spatial": from})).unwrap(); + s.execute("keys.interpolation", json!({"spatial": "bezier"})).unwrap(); + let p = prop(&s, l, "transform/position"); + assert!(p.keys.iter().all(|k| !k.spatial_auto)); + assert_eq!((p.keys[0].spatial_out, p.keys[1].spatial_in), ([handle, 0.0, 0.0], [-handle, 0.0, 0.0]), "from {from}"); + let now = p.value_at(effectcraft_time::Tick::from_seconds_f64(0.7)).as_vec3(); + assert!((now[0] - mid[0]).abs() < 0.01 && now[1].abs() < 1e-9, "from {from}: {mid:?} → {now:?}"); + } } #[test] diff --git a/crates/expr/src/tests.rs b/crates/expr/src/tests.rs index 3d5acb2d..d2ada7d5 100644 --- a/crates/expr/src/tests.rs +++ b/crates/expr/src/tests.rs @@ -588,7 +588,11 @@ fn loop_on_two_dimensional_position() { let mut f = fx(); f.layer_mut(f.a).props.prop_mut(POS).unwrap().keys = vec![Keyframe::new(secs(0.0), Value::Vec3([0.0, 0.0, 0.0])), Keyframe::new(secs(1.0), Value::Vec3([100.0, 50.0, 0.0]))]; - assert_eq!(f.pos("loopOut('offset')", 1.5), [150.0, 75.0, 0.0]); + // The auto-Bezier keys have tangents along the line, so the path is sampled by arc length. + let p = f.pos("loopOut('offset')", 1.5); + for (got, want) in p.into_iter().zip([150.0, 75.0, 0.0]) { + assert_close!(got, want, 1e-9); + } } #[test] diff --git a/crates/keyframe/src/lib.rs b/crates/keyframe/src/lib.rs index 80537149..f1f34ea1 100644 --- a/crates/keyframe/src/lib.rs +++ b/crates/keyframe/src/lib.rs @@ -270,25 +270,18 @@ fn v3(v: &Value) -> [f64; 3] { v.as_vec3() } -/// Auto-Bezier spatial tangents (Catmull-Rom style) for key `i`. +/// Auto-Bezier spatial tangents for key `i`, as After Effects computes them (Catmull-Rom): the out +/// tangent is a sixth of the way from the previous key to the next, the in tangent its mirror, and +/// key times play no part. An end key uses its one neighbour, so the path leaves the first key +/// and enters the last one toward the neighbouring key instead of with no tangent. fn auto_spatial(keys: &[Keyframe], i: usize) -> ([f64; 3], [f64; 3]) { - if i == 0 || i + 1 >= keys.len() { + if i >= keys.len() || keys.len() < 2 { return ([0.0; 3], [0.0; 3]); } - let a = v3(&keys[i - 1].value); - let b = v3(&keys[i].value); - let c = v3(&keys[i + 1].value); - let d = [c[0] - a[0], c[1] - a[1], c[2] - a[2]]; - let dl = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt(); - if dl < 1e-12 { - return ([0.0; 3], [0.0; 3]); - } - let lin = ((b[0] - a[0]).powi(2) + (b[1] - a[1]).powi(2) + (b[2] - a[2]).powi(2)).sqrt(); - let lout = ((c[0] - b[0]).powi(2) + (c[1] - b[1]).powi(2) + (c[2] - b[2]).powi(2)).sqrt(); - let n = [d[0] / dl, d[1] / dl, d[2] / dl]; - let tin = [-n[0] * lin / 3.0, -n[1] * lin / 3.0, -n[2] * lin / 3.0]; - let tout = [n[0] * lout / 3.0, n[1] * lout / 3.0, n[2] * lout / 3.0]; - (tin, tout) + let a = v3(&keys[i.saturating_sub(1)].value); + let c = v3(&keys[(i + 1).min(keys.len() - 1)].value); + let tout = [(c[0] - a[0]) / 6.0, (c[1] - a[1]) / 6.0, (c[2] - a[2]) / 6.0]; + ([-tout[0], -tout[1], -tout[2]], tout) } /// Effective spatial tangents of key `i` (auto or stored). @@ -298,7 +291,7 @@ pub fn spatial_tangents(keys: &[Keyframe], i: usize) -> ([f64; 3], [f64; 3]) { } /// Spatial tangents of key `i` as motion-path handles: [`spatial_tangents`], with a side that -/// has none but a neighbour (the ends of an auto-Bezier path, a straight two-key path) pointing +/// has none but a neighbour (a key whose stored tangents are zero, as on a straight path) pointing /// where the path leaves the key, toward the segment's next control point, a third of the way to /// the neighbouring key. After Effects shows Bezier handles there to drag the path into a curve. pub fn spatial_handles(keys: &[Keyframe], i: usize) -> ([f64; 3], [f64; 3]) { @@ -778,6 +771,30 @@ mod tests { assert!((m[0] - m[1]).abs() > 1.0, "{m:?}"); } + #[test] + fn spatial_auto_bezier_tangents_follow_after_effects() { + // Measured on After Effects 26.3 with original paths (docs/fidelity/README.md): each + // auto-Bezier key's out tangent is (next - previous) / 6 and its in tangent the mirror, + // whatever the key times. End keys use their one neighbour: (neighbour - key) / 6. + let p = [[40.0, 300.0, 0.0], [260.0, 80.0, 20.0], [700.0, 420.0, -40.0], [1100.0, 150.0, 0.0]]; + let mut keys: Vec = [0.0, 0.2, 1.5, 1.8].iter().zip(p).map(|(&t, v)| Keyframe::new(s(t), Value::Vec3(v))).collect(); + let sixth = |a: [f64; 3], b: [f64; 3]| [(b[0] - a[0]) / 6.0, (b[1] - a[1]) / 6.0, (b[2] - a[2]) / 6.0]; + let neg = |v: [f64; 3]| [-v[0], -v[1], -v[2]]; + let close = |a: [f64; 3], b: [f64; 3]| a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-9); + let want = [sixth(p[0], p[1]), sixth(p[0], p[2]), sixth(p[1], p[3]), sixth(p[2], p[3])]; + for (i, out) in want.into_iter().enumerate() { + let (tin, tout) = spatial_tangents(&keys, i); + assert!(close(tout, out) && close(tin, neg(out)), "key {i}: {tin:?} {tout:?}"); + } + // Moving a key in time leaves every tangent as it was. + keys[1].time = s(1.2); + assert!(close(spatial_tangents(&keys, 1).1, sixth(p[0], p[2]))); + // A key between two coincident neighbours, and a lone key, have none. + let still = [[5.0, 5.0], [9.0, 1.0], [5.0, 5.0]].iter().enumerate().map(|(i, &v)| Keyframe::new(s(i as f64), Value::Vec2(v))).collect::>(); + assert_eq!(spatial_tangents(&still, 1), ([0.0; 3], [0.0; 3])); + assert_eq!(spatial_tangents(&still[..1], 0), ([0.0; 3], [0.0; 3])); + } + #[test] fn set_key_replaces_same_time() { let mut keys = vec![]; diff --git a/crates/ui-egui/tests/ui_viewer.rs b/crates/ui-egui/tests/ui_viewer.rs index bada77a7..651dc6c7 100644 --- a/crates/ui-egui/tests/ui_viewer.rs +++ b/crates/ui-egui/tests/ui_viewer.rs @@ -770,8 +770,8 @@ fn clicking_off_a_mask_deselects_its_points() { assert_eq!(h.state().session.state.selected_layers, vec![LayerId(plate)], "the layer stays selected"); } -/// A straight two-key motion path shows Bezier handles at both keys (it had none), and dragging -/// one pulls the path into a curve (#290). +/// A straight two-key motion path shows Bezier handles at both keys, and dragging one pulls the +/// path into a curve (#290). #[test] fn a_straight_motion_path_has_handles_to_curve_it() { let mut h = harness(); @@ -785,15 +785,16 @@ fn a_straight_motion_path_has_handles_to_curve_it() { h.run_steps(3); let (out0, in1) = (format!("viewer.motionPath.{}.0.out", box_id.0), format!("viewer.motionPath.{}.1.in", box_id.0)); assert!(h.state().auto.find(&in1).is_some(), "the second key has a handle"); - // The first key's handle lies a third of the way along the path: drag it down 150 px. + // The first key's auto-Bezier handle lies a sixth of the way along the path, as After Effects + // puts it: drag it down 150 px. let from = rect(&h, &out0).center(); - assert!(from.distance(screen(&h, [200.0, 100.0])) < 2.0, "{from:?}"); - let to = screen(&h, [200.0, 250.0]); + assert!(from.distance(screen(&h, [150.0, 100.0])) < 2.0, "{from:?}"); + let to = screen(&h, [150.0, 250.0]); drag(&mut h, from, to); let p = h.state().session.active_comp().unwrap().layer(box_id).unwrap().props.prop("transform/position").unwrap().clone(); assert!(!p.keys[0].spatial_auto); let t = p.keys[0].spatial_out; - assert!((t[0] - 100.0).abs() < 2.0 && (t[1] - 150.0).abs() < 2.0, "{t:?}"); + assert!((t[0] - 50.0).abs() < 2.0 && (t[1] - 150.0).abs() < 2.0, "{t:?}"); let mid = p.value_at(effectcraft_engine::time::Tick::from_seconds_f64(1.0)).as_vec3(); assert!(mid[1] > 130.0, "the path curves: {mid:?}"); } diff --git a/docs/fidelity/README.md b/docs/fidelity/README.md index ae88186b..025997d4 100644 --- a/docs/fidelity/README.md +++ b/docs/fidelity/README.md @@ -50,12 +50,30 @@ time curve. Its closed-form inverse and a parameter-based convergence check now mathematical regression test at times within `1e-12` of the midpoint; that test is additional to the 168 live AE samples. +## Auto-Bezier spatial tangents + +Measured on 10 October 2026 with After Effects 2026 (26.3) by running the original +`examples/ae-spatial-tangents.jsx` in both hosts (`AfterFX.exe -r` and `effectcraft-cli script`): +three original Position paths, ten keys, with even and uneven key timing. The script pins spatial +interpolation to auto-Bezier, since an AE preference can default new keys to linear. + +AE gives each auto-Bezier key an out tangent of (next key − previous key) / 6 and the mirrored in +tangent, whatever the key times; an end key uses its one neighbour, (neighbour − key) / 6. That is a +Catmull-Rom spline. EffectCraft gave end keys no tangent and scaled middle keys' tangents by the +lengths of their segments, so all ten tangents differed. Sampled Position along an auto-Bezier path +with Easy Ease was up to 25.1 px off, and a roving path up to 27.7 px. + +After the fix, all ten tangents equal AE's to the printed 0.001 px, and the two path cases are within +0.05 px over 61 samples each; what remains is the arc-length table's sub-pixel speed. The regression +test `spatial_auto_bezier_tangents_follow_after_effects` checks the rule on other original keys, +including a timing change. AE's output stays local; only the script and these scores are tracked. + ## Still unmeasured Rendered frame comparisons now have a deterministic `effectcraft_effects::fidelity::compare_frames` helper. Corpus runners can use it with locally generated host/reference frames and fail on either a mean or maximum channel error without committing proprietary renders. -Mixed interpolation sides, non-unit durations, automatic/continuous Bezier, spatial speeds and paths, +Mixed interpolation sides, non-unit durations, temporal automatic/continuous Bezier, spatial speeds, time remapping, expressions, actual rendered motion and other effects need separate corpus cases. The initial probe makes no claim about those features. diff --git a/docs/fidelity/ae-2026-acceptance.md b/docs/fidelity/ae-2026-acceptance.md index 042d1177..018d1ddf 100644 --- a/docs/fidelity/ae-2026-acceptance.md +++ b/docs/fidelity/ae-2026-acceptance.md @@ -12,7 +12,7 @@ platform, fonts, color settings, source files, and project parameters. | --- | --- | --- | | Work projects | Representative projects recreated or imported, edited, saved, reopened and delivered without lost state | Native AE project import and third-party AE plug-in execution remain gaps | | Workflow | Pointer and keyboard cases for all panels, tools, shortcuts, docking, undo, dialogs and selections | Current pass covers 34 panel layouts and selected interaction regressions; every control is not verified | -| Animation | AE-sampled values for scalar, spatial, multidimensional and expression-driven properties, timing and interpolation | Eight scalar Rotation/ease cases, 168 samples verified against AE 26.3x87; other cases remain open | +| Animation | AE-sampled values for scalar, spatial, multidimensional and expression-driven properties, timing and interpolation | Eight scalar Rotation/ease cases, 168 samples verified against AE 26.3x87; auto-Bezier spatial tangents (ten keys, three paths) match AE 26.3; other cases remain open | | Render fidelity | Original test scenes compared at 8/16/32 bpc, specified color spaces and alpha conventions; edges, impulses, gradients and temporal sequences | Most AE-rendered comparisons remain open; CPU/GPU agreement alone is insufficient | | Media and delivery | Real footage import, seek, frame rates, audio sync, relinking, image sequences, codec/container metadata and exports checked in downstream tools | Actual work fixtures still required | | Performance | Matched AE/EffectCraft input-to-present and export timings at 1080p/4K, warm/cold caches, memory/VRAM and long sessions | No claim of beating AE or measured blur response yet | diff --git a/examples/ae-spatial-tangents.jsx b/examples/ae-spatial-tangents.jsx new file mode 100644 index 00000000..a9a9218b --- /dev/null +++ b/examples/ae-spatial-tangents.jsx @@ -0,0 +1,27 @@ +// Original behavior probe: the spatial tangents a host gives auto-Bezier Position keys. +// Runs unchanged in After Effects (File > Scripts > Run Script File, or AfterFX.exe -r) and in +// EffectCraft (effectcraft-cli script examples/ae-spatial-tangents.jsx --empty). Builds three +// original comps with one solid each; run it in a scratch project. EffectCraft prints one JSON line +// per key; After Effects writes them to ae-spatial-tangents.jsonl next to this script. +// No Adobe assets, presets, application internals or implementation code are read. +var EC = String(app.version).indexOf("EffectCraft") >= 0; +var OUT = null; +if (!EC) { + OUT = new File(File($.fileName).parent.fsName + "/ae-spatial-tangents.jsonl"); + OUT.encoding = "UTF-8"; + OUT.open("w"); +} +function emit(s) { if (EC) { writeLn(s); } else { OUT.writeln(s); } } +function r(v) { var o = []; for (var i = 0; i < v.length; i++) o.push(Math.round(v[i] * 1000) / 1000); return "[" + o.join(",") + "]"; } +function dump(name, pts, times) { + var c = app.project.items.addComp(name, 1920, 1080, 1, 2, 30); + var p = c.layers.addSolid([1, 1, 1], "S", 100, 100, 1).property("Transform").property("Position"); + for (var i = 0; i < pts.length; i++) p.setValueAtTime(times[i], pts[i]); + // Pin what a preference could decide: AE can default new keys to linear spatial interpolation. + for (var k = 1; k <= p.numKeys; k++) p.setSpatialAutoBezierAtKey(k, true); + for (var k2 = 1; k2 <= p.numKeys; k2++) emit('{"case":"' + name + '","key":' + k2 + ',"in":' + r(p.keyInSpatialTangent(k2)) + ',"out":' + r(p.keyOutSpatialTangent(k2)) + "}"); +} +dump("T1 symmetric", [[200, 200], [960, 900], [1700, 200]], [0, 1, 2]); +dump("T2 uneven", [[100, 540], [400, 700], [1800, 540]], [0, 0.5, 2]); +dump("T3 four keys", [[100, 100], [500, 900], [900, 100], [1800, 600]], [0, 0.6, 1.2, 2]); +if (OUT) OUT.close(); From e2a47495eb8ad4c5601ceba3280a1b8c3b2b267f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 21:17:18 +0000 Subject: [PATCH 082/107] GPU: an effect kernel the shader compiler rejects no longer turns the GPU compositor off (#613) GpuContext::new built every compute pipeline up front and failed as a whole when one didn't build. On DirectX 12, FXC (wgpu's default D3D shader compiler) aborts on the `pointwise` effect kernel, so the whole app fell back to CPU compositing. The compositor's own kernels are still required, but the effect kernels (`pointwise`, the blur/glow/shadow passes and every fx_* kernel) are now optional: one that fails to build is logged and listed (GpuContext::unavailable_kernels), and a GPU effect chain that dispatches a missing kernel reports it (Enc::take_missing) so those effects render on the CPU while everything else stays on the GPU. This does not make FXC accept `pointwise` (that needs a Windows/FXC box to bisect); it limits the damage to the effects using it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f --- crates/gpu/src/context.rs | 95 +++++++++++++++++++++++++++------------ crates/gpu/src/effects.rs | 9 ++++ crates/gpu/src/tests.rs | 32 +++++++++++++ 3 files changed, 108 insertions(+), 28 deletions(-) diff --git a/crates/gpu/src/context.rs b/crates/gpu/src/context.rs index e994dd1b..c5065eae 100644 --- a/crates/gpu/src/context.rs +++ b/crates/gpu/src/context.rs @@ -18,14 +18,6 @@ const ENTRIES: &[&str] = &[ "quantize", "convert", "half", - "box_h", - "box_v", - "directional", - "glow_bright", - "glow_combine", - "shadow_make", - "shadow_combine", - "pointwise", "adjust_mix", "bokeh_boost", "bokeh_prefix", @@ -33,6 +25,11 @@ const ENTRIES: &[&str] = &[ "fill", ]; +/// Effect entry points in `kernels.wgsl`. Like the `fx_*` kernels, one that fails to build +/// (a driver's shader compiler rejecting it, #613) leaves its effects to the CPU instead of +/// turning the whole compositor off. +const EFFECT_ENTRIES: &[&str] = &["box_h", "box_v", "directional", "glow_bright", "glow_combine", "shadow_make", "shadow_combine", "pointwise"]; + /// Entry points that also bind group 1 (four read-only storage buffers; see /// [`Enc::dispatch_ext`]). const EXT_ENTRIES: &[&str] = &["classic3d"]; @@ -244,6 +241,8 @@ pub struct GpuContext { bgl: wgpu::BindGroupLayout, bgl_ext: wgpu::BindGroupLayout, pipelines: HashMap<&'static str, wgpu::ComputePipeline>, + /// Effect kernels that failed to build, and why (their effects render on the CPU). + unavailable: Vec<(&'static str, String)>, display_bgl: wgpu::BindGroupLayout, display: wgpu::ComputePipeline, /// The display texture's next mip level from the one above (`display.wgsl` `mip`). @@ -450,7 +449,29 @@ impl GpuContext { immediate_size: 0, }) })?; - let pipelines = ENTRIES + let build = |e: &'static str, layout: &wgpu::PipelineLayout| { + init_resource(&device, e, || { + device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor { + label: Some(e), + layout: Some(layout), + module: &module, + entry_point: Some(e), + compilation_options: Default::default(), + cache: None, + }) + }) + }; + // The compositor's own kernels are required; an effect kernel that doesn't build is + // left out, and its effects render on the CPU (see `Enc::take_missing`). + let mut pipelines = ENTRIES + .iter() + .chain(crate::adv3d::SKY_KERNELS) + .map(|e| (e, &layout)) + .chain(EXT_ENTRIES.iter().map(|e| (e, &layout_ext))) + .map(|(e, layout)| Ok((*e, build(e, layout)?))) + .collect::, String>>()?; + let mut unavailable = vec![]; + for e in EFFECT_ENTRIES .iter() .chain(crate::fx_color::KERNELS) .chain(crate::fx_distort::KERNELS) @@ -472,23 +493,17 @@ impl GpuContext { .chain(crate::fx_time::KERNELS) .chain(crate::fx_pixel2::KERNELS) .chain(crate::fx_gen2::KERNELS) - .chain(crate::adv3d::SKY_KERNELS) - .map(|e| (e, &layout)) - .chain(EXT_ENTRIES.iter().map(|e| (e, &layout_ext))) - .map(|(e, layout)| { - let p = init_resource(&device, e, || { - device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor { - label: Some(e), - layout: Some(layout), - module: &module, - entry_point: Some(e), - compilation_options: Default::default(), - cache: None, - }) - })?; - Ok((*e, p)) - }) - .collect::, String>>()?; + { + match build(e, &layout) { + Ok(p) => { + pipelines.insert(*e, p); + } + Err(why) => { + log::warn!("gpu {name}: kernel {e} unavailable, its effects render on the CPU: {why}"); + unavailable.push((*e, why)); + } + } + } let dmodule = init_resource(&device, "display shader module", || { device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("effectcraft display"), @@ -547,6 +562,7 @@ impl GpuContext { bgl, bgl_ext, pipelines, + unavailable, display_bgl, display, display_mip, @@ -721,6 +737,18 @@ impl GpuContext { /// Readbacks (GPU → CPU) are possible: waited for natively, or deferred (a browser /// worker, [`crate::deferred`]). Impossible on the browser's main thread. + /// Effect kernels the device couldn't build, with why: their effects render on the CPU. + pub fn unavailable_kernels(&self) -> &[(&'static str, String)] { + &self.unavailable + } + + /// Leave kernel `entry` out, as if it had failed to build (tests of the CPU fallback). + #[cfg(test)] + pub(crate) fn drop_kernel(&mut self, entry: &'static str) { + self.pipelines.remove(entry); + self.unavailable.push((entry, "dropped by a test".into())); + } + pub fn can_readback(&self) -> bool { self.readbacks.check().is_ok() && (self.can_wait() || self.deferred.is_some()) } @@ -859,6 +887,8 @@ pub(crate) struct Enc<'g> { /// The open command encoder and its id in the texture pool. enc: Option<(wgpu::CommandEncoder, u64)>, pending: usize, + /// A dispatch named a kernel the device doesn't have (see [`Enc::take_missing`]). + missing: bool, } impl Drop for Enc<'_> { @@ -874,7 +904,13 @@ impl Drop for Enc<'_> { impl<'g> Enc<'g> { pub fn new(g: &'g GpuContext) -> Enc<'g> { - Enc { g, enc: None, pending: 0 } + Enc { g, enc: None, pending: 0, missing: false } + } + + /// Whether a dispatch since the last call named a kernel the device doesn't have (one + /// that failed to build): its output is wrong, and the effect must render on the CPU. + pub(crate) fn take_missing(&mut self) -> bool { + std::mem::take(&mut self.missing) } pub(crate) fn encoder(&mut self) -> &mut wgpu::CommandEncoder { @@ -954,7 +990,10 @@ impl<'g> Enc<'g> { ) { let g = self.g; let Some(pipe) = g.pipelines.get(entry) else { - log::error!("gpu: no kernel {entry}"); + if !g.unavailable.iter().any(|(e, _)| *e == entry) { + log::error!("gpu: no kernel {entry}"); + } + self.missing = true; return; }; let ub = g.device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: None, contents: &p.bytes(), usage: wgpu::BufferUsages::UNIFORM }); diff --git a/crates/gpu/src/effects.rs b/crates/gpu/src/effects.rs index 00cc4fd9..426931d7 100644 --- a/crates/gpu/src/effects.rs +++ b/crates/gpu/src/effects.rs @@ -66,8 +66,12 @@ pub(crate) fn run_chain(e: &mut Enc, chain: &[FxStep], buf: &Buf, levels: Option } let misses = deferred.as_ref().map(|d| d.misses()); let mut b = GBuf { img: e.g.upload_image(&buf.img)?, offset: buf.offset, scale: buf.scale }; + e.take_missing(); for step in chain { b = apply(e, step.spec.id, &step.ctx, b)?; + if e.take_missing() { + return None; + } if let Some(l) = levels { b.img = ops::quantize(e, &b.img, l); } @@ -119,8 +123,13 @@ impl effectcraft_render::FxTarget for GpuFx<'_, '_> { fn gpu(&mut self, steps: &[FxStep]) -> bool { let Some(b) = &self.b else { return false }; let mut cur = GBuf { img: b.img.clone(), offset: b.offset, scale: b.scale }; + self.e.take_missing(); for step in steps { let Some(next) = apply(self.e, step.spec.id, &step.ctx, cur) else { return false }; + // A kernel the device couldn't build: the effects render on the CPU (#613). + if self.e.take_missing() { + return false; + } cur = next; if let Some(l) = self.levels { cur.img = ops::quantize(self.e, &cur.img, l); diff --git a/crates/gpu/src/tests.rs b/crates/gpu/src/tests.rs index 39be29a9..3629d8ea 100644 --- a/crates/gpu/src/tests.rs +++ b/crates/gpu/src/tests.rs @@ -745,3 +745,35 @@ fn within_memory_returns_the_work_when_there_is_room() { let img = g.within_memory(|| g.ctx.image(64, 32)).expect("room for a 64×32 texture"); assert_eq!((img.width, img.height), (64, 32)); } + +/// #613: an effect kernel the device's shader compiler rejects (FXC on DirectX 12 aborting on +/// `pointwise`) no longer turns the whole GPU compositor off: the context is built without it, +/// lists it, and the effects that need it render on the CPU, matching the CPU render. +#[test] +fn effects_whose_kernels_failed_render_on_the_cpu() { + if gpu().is_none() { + return; + } + let Ok(mut ctx) = pollster::block_on(crate::context::GpuContext::request()) else { return }; + ctx.drop_kernel("pointwise"); + ctx.drop_kernel("box_h"); + let names: Vec<&str> = ctx.unavailable_kernels().iter().map(|(e, _)| *e).collect(); + assert_eq!(names, ["pointwise", "box_h"]); + let g = Gpu::from_context(ctx); + for depth in [BitDepth::Bpc8, BitDepth::Bpc32] { + let mut s = Scene::new(depth); + let mut l = s.footage(80, 50); + s.effect(&mut l, "ec.color.exposure", &[("master/exposure", n(0.7))]); + s.effect(&mut l, "ec.color.tint", &[("black", c(0.1, 0.0, 0.3)), ("amount", n(80.0))]); + s.effect(&mut l, "ec.blur.gaussian", &[("blurriness", n(5.0))]); + s.effect(&mut l, "ec.channel.invert", &[("blend", n(50.0))]); + s.push(l); + let cpu = Renderer::new(&s.p, &Pattern, RenderOpts { backend: Backend::Cpu, ..opts() }).comp_frame_cpu(s.cid, Tick::ZERO); + let mut r = Renderer::new(&s.p, &Pattern, RenderOpts { backend: Backend::Gpu, ..opts() }); + r.accel = Some(&g); + let img = g.render(&r, s.cid, Tick::ZERO).expect("the GPU renders the frame"); + let mut d = diff(&cpu, &img, tolerance(depth)); + d.quantized = depth != BitDepth::Bpc32; + check(&format!("missing kernels {depth:?}"), Some(d), 0.0); + } +} From 56fd5d52b13857254f6f6130a871920ea3d2e2da Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 21:22:38 +0000 Subject: [PATCH 083/107] Dock: a panel dropped on a stacked panel becomes a tab in its frame, as in After Effects (#302) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A stack (the Default workspace's right column) held one panel per frame. Dropping a panel onto the middle or the tab strip of a stacked panel inserted a new stacked frame below it, and dropping on its top or bottom edge split the whole column. A stacked frame now holds tabs (`StackEntry { panels, active, open, height }`): - Center / tab strip drops add the panel as a tab of the anchor's frame and show it. - Top / Bottom drops add a frame of its own above / below the anchor's frame only. (Left / Right still split the column: a frame can't hold a side-by-side split.) - Clicking a frame's shown tab (or its header bar) expands / collapses it; another tab shows that panel. Closing a tab keeps the frame; its last tab removes it. - Saved workspaces from before (`{"panel": …}` per frame) still load. Tests: dock unit tests (Center / Top / Bottom on a stack, insert_tab, close, old saved entries); a kittest dragging Effect Controls onto Properties makes a two-tab frame whose tabs switch the shown panel. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f --- crates/ui-egui/src/dock.rs | 159 ++++++++++++++++++++----- crates/ui-egui/tests/ui_panel_focus.rs | 2 +- crates/ui-egui/tests/ui_tabs.rs | 24 +++- 3 files changed, 153 insertions(+), 32 deletions(-) diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 39be3f59..06b99982 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -168,15 +168,66 @@ pub enum SplitSize { FixedB(f32), } -/// One panel in a [`DockNode::Stack`]. -#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +/// One frame in a [`DockNode::Stack`]: a header with one tab per panel (a panel dropped onto a +/// stacked panel joins its frame as a tab, as in After Effects). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(from = "StackEntryRepr")] pub struct StackEntry { - pub panel: PanelKind, + pub panels: Vec, + /// The shown tab. + pub active: usize, pub open: bool, /// Content height when open (set by dragging the gaps); `None` = share the remaining height. pub height: Option, } +/// A saved [`StackEntry`]: workspaces saved before stacked frames held tabs name one `panel`. +#[derive(Deserialize)] +struct StackEntryRepr { + #[serde(default)] + panel: Option, + #[serde(default)] + panels: Vec, + #[serde(default)] + active: usize, + open: bool, + #[serde(default)] + height: Option, +} + +impl From for StackEntry { + fn from(r: StackEntryRepr) -> StackEntry { + let mut panels = r.panels; + if let Some(p) = r.panel + && !panels.contains(&p) + { + panels.insert(0, p); + } + let active = r.active.min(panels.len().saturating_sub(1)); + StackEntry { panels, active, open: r.open, height: r.height } + } +} + +impl StackEntry { + /// A frame showing just `panel`. + pub fn new(panel: PanelKind, open: bool, height: Option) -> StackEntry { + StackEntry { panels: vec![panel], active: 0, open, height } + } + pub fn contains(&self, p: PanelKind) -> bool { + self.panels.contains(&p) + } + /// The frame's shown panel. + pub fn shown(&self) -> Option { + self.panels.get(self.active).or(self.panels.first()).copied() + } + /// Show `p` (one of its tabs). + fn show(&mut self, p: PanelKind) { + if let Some(i) = self.panels.iter().position(|x| *x == p) { + self.active = i; + } + } +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub enum DockNode { /// `vertical`: children stacked top/bottom; else side by side. @@ -202,7 +253,7 @@ fn tabs(p: &[PanelKind], active: usize) -> DockNode { DockNode::Tabs { panels: p.to_vec(), active } } fn stack(e: &[(PanelKind, bool, Option)]) -> DockNode { - DockNode::Stack { entries: e.iter().map(|&(panel, open, height)| StackEntry { panel, open, height }).collect() } + DockNode::Stack { entries: e.iter().map(|&(panel, open, height)| StackEntry::new(panel, open, height)).collect() } } fn hsplit(size: SplitSize, a: DockNode, b: DockNode) -> DockNode { DockNode::Split { vertical: false, size, a: Box::new(a), b: Box::new(b) } @@ -401,7 +452,7 @@ impl DockNode { b.panels(out); } DockNode::Tabs { panels, .. } => out.extend(panels.iter().copied()), - DockNode::Stack { entries } => out.extend(entries.iter().map(|e| e.panel)), + DockNode::Stack { entries } => out.extend(entries.iter().flat_map(|e| e.panels.iter().copied())), } } pub fn contains(&self, p: PanelKind) -> bool { @@ -421,8 +472,9 @@ impl DockNode { false } } - DockNode::Stack { entries } => match entries.iter_mut().find(|e| e.panel == p) { + DockNode::Stack { entries } => match entries.iter_mut().find(|e| e.contains(p)) { Some(e) => { + e.show(p); e.open = true; true } @@ -430,13 +482,15 @@ impl DockNode { }, } } - /// Expand or collapse a stacked panel. Returns false if `p` is not in a stack. + /// Expand or collapse a stacked panel's frame (showing `p`). Returns false if `p` is not in + /// a stack. pub fn toggle_stacked(&mut self, p: PanelKind) -> bool { match self { DockNode::Split { a, b, .. } => a.toggle_stacked(p) || b.toggle_stacked(p), DockNode::Tabs { .. } => false, - DockNode::Stack { entries } => match entries.iter_mut().find(|e| e.panel == p) { + DockNode::Stack { entries } => match entries.iter_mut().find(|e| e.contains(p)) { Some(e) => { + e.show(p); e.open = !e.open; true } @@ -448,7 +502,7 @@ impl DockNode { match self { DockNode::Split { a, b, .. } => a.is_visible(p) || b.is_visible(p), DockNode::Tabs { panels, active } => panels.get(*active) == Some(&p), - DockNode::Stack { entries } => entries.iter().any(|e| e.panel == p && e.open), + DockNode::Stack { entries } => entries.iter().any(|e| e.open && e.shown() == Some(p)), } } /// Close a panel (remove its tab; empty groups collapse their split). @@ -471,7 +525,12 @@ impl DockNode { panels.retain(|x| *x != p); *active = shown.and_then(|shown| panels.iter().position(|x| *x == shown)).unwrap_or((*active).min(panels.len().saturating_sub(1))); } else if let DockNode::Stack { entries } = self { - entries.retain(|e| e.panel != p); + for e in entries.iter_mut() { + let shown = e.shown(); + e.panels.retain(|x| *x != p); + e.active = shown.and_then(|shown| e.panels.iter().position(|x| *x == shown)).unwrap_or(e.active.min(e.panels.len().saturating_sub(1))); + } + entries.retain(|e| !e.panels.is_empty()); } } /// Add a panel as a tab next to `near` (or into the first group). @@ -492,9 +551,9 @@ impl DockNode { false } } - DockNode::Stack { entries } => match entries.iter().position(|e| e.panel == near) { + DockNode::Stack { entries } => match entries.iter().position(|e| e.contains(near)) { Some(i) => { - entries.insert(i + 1, StackEntry { panel: p, open: true, height: None }); + entries.insert(i + 1, StackEntry::new(p, true, None)); true } None => false, @@ -510,7 +569,7 @@ impl DockNode { true } DockNode::Stack { entries } if !entries.is_empty() => { - entries.push(StackEntry { panel: p, open: true, height: None }); + entries.push(StackEntry::new(p, true, None)); true } _ => false, @@ -586,8 +645,10 @@ pub struct Floating { impl DockNode { /// Insert panel `p` relative to the group that holds `anchor`: as a tab next to it - /// (Center) or in a new group split off to one side. `p` is removed from its old place - /// first. Returns false (tree unchanged) if `anchor` isn't docked or is `p` itself alone. + /// (Center) or in a new group split off to one side. In a stack, the group is `anchor`'s + /// frame: Center adds a tab to that frame and Top / Bottom add a frame above or below it + /// (#302); Left / Right split the column. `p` is removed from its old place first. Returns + /// false (tree unchanged) if `anchor` isn't docked or is `p` itself alone. pub fn dock_panel(&mut self, p: PanelKind, anchor: PanelKind, zone: Zone) -> bool { if p == anchor || !self.contains(anchor) { return false; @@ -598,7 +659,7 @@ impl DockNode { let here = match n { DockNode::Split { a, b, .. } => return rec(a, p, anchor, zone) || rec(b, p, anchor, zone), DockNode::Tabs { panels, .. } => panels.contains(&anchor), - DockNode::Stack { entries } => entries.iter().any(|e| e.panel == anchor), + DockNode::Stack { entries } => entries.iter().any(|e| e.contains(anchor)), }; if !here { return false; @@ -610,8 +671,16 @@ impl DockNode { *active = i; } (Zone::Center, DockNode::Stack { entries }) => { - let i = entries.iter().position(|e| e.panel == anchor).map_or(entries.len(), |i| i + 1); - entries.insert(i, StackEntry { panel: p, open: true, height: None }); + let Some(e) = entries.iter_mut().find(|e| e.contains(anchor)) else { return false }; + let i = e.panels.iter().position(|x| *x == anchor).map_or(e.panels.len(), |i| i + 1); + e.panels.insert(i, p); + e.active = i; + e.open = true; + } + (Zone::Top | Zone::Bottom, DockNode::Stack { entries }) => { + let Some(i) = entries.iter().position(|e| e.contains(anchor)) else { return false }; + let at = if zone == Zone::Top { i } else { i + 1 }; + entries.insert(at, StackEntry::new(p, true, None)); } (z, node) => { let new = tabs(&[p], 0); @@ -648,9 +717,13 @@ impl DockNode { *active = i; true } - DockNode::Stack { entries } if entries.iter().any(|e| e.panel == anchor) => { - let i = before.and_then(|b| entries.iter().position(|e| e.panel == b)).unwrap_or(entries.len()); - entries.insert(i, StackEntry { panel: p, open: true, height: None }); + DockNode::Stack { entries } => { + // A tab of the anchor's frame. + let Some(e) = entries.iter_mut().find(|e| e.contains(anchor)) else { return false }; + let i = before.and_then(|b| e.panels.iter().position(|x| *x == b)).unwrap_or(e.panels.len()); + e.panels.insert(i, p); + e.active = i; + e.open = true; true } _ => false, @@ -670,7 +743,7 @@ impl DockNode { match n { DockNode::Split { a, b, .. } => rec(a, p, &format!("{path}a")).or_else(|| rec(b, p, &format!("{path}b"))), DockNode::Tabs { panels, .. } => panels.contains(&p).then(|| path.to_string()), - DockNode::Stack { entries } => entries.iter().position(|e| e.panel == p).map(|i| format!("{path}s{i}")), + DockNode::Stack { entries } => entries.iter().position(|e| e.contains(p)).map(|i| format!("{path}s{i}")), } } rec(self, p, "") @@ -886,7 +959,7 @@ pub fn layout(ui: &mut egui::Ui, node: &mut DockNode, rect: Rect, t: &Tokens, pa drag = Some((above, below, d)); } } - out.push(Group { path: group, rect: r, content, panels: vec![e.panel], active: 0, stacked: Some(e.open) }); + out.push(Group { path: group, rect: r, content, panels: e.panels.clone(), active: e.active, stacked: Some(e.open) }); y = r.max.y + g; } if let Some((above, below, d)) = drag { @@ -1184,7 +1257,9 @@ pub fn draw_group_chrome(ui: &mut egui::Ui, g: &Group, t: &Tokens, reg: &mut cra } } if resp.clicked() { - if g.stacked.is_some() { + // A stacked frame's shown tab expands / collapses it; another tab shows that + // panel (expanding the frame). + if g.stacked.is_some() && (k == ai || entries.len() == 1) { out.actions.push(DockAction::ToggleStacked(*p)); } else { out.actions.push(activate(e)); @@ -1197,9 +1272,9 @@ pub fn draw_group_chrome(ui: &mut egui::Ui, g: &Group, t: &Tokens, reg: &mut cra x += w + 8.0; } // A stacked panel's whole header bar expands/collapses it, not just its name (as in After - // Effects). A stack group holds one panel, so the rest of the strip is unambiguous. + // Effects): the frame with its shown tab. if g.stacked.is_some() - && let Some(e) = entries.first() + && let Some(e) = entries.get(ai).or(entries.first()) { let rest = Rect::from_min_max(pos2((x - 8.0).max(strip.min.x), strip.min.y), strip.max); if rest.width() > 0.0 { @@ -1269,7 +1344,7 @@ mod tests { } fn entry(panel: PanelKind, open: bool, height: Option) -> StackEntry { - StackEntry { panel, open, height } + StackEntry::new(panel, open, height) } fn close_to(a: &[f32], b: &[f32]) -> bool { @@ -1471,16 +1546,42 @@ mod tests { assert!(!d.dock_panel(Audio, Audio, Zone::Left)); assert!(!d.dock_panel(Audio, Tracker, Zone::Left)); assert_eq!(d, before); - // Into a stack. + // Into a stack: a tab of the anchor's frame (#302). let mut s = workspace("Default"); + let props = s.path_of(Properties).unwrap(); assert!(s.dock_panel(Tracker, Properties, Zone::Center)); - assert!(s.path_of(Tracker).unwrap().contains('s')); + assert_eq!(s.path_of(Tracker), Some(props.clone())); + assert!(s.is_visible(Tracker) && !s.is_visible(Properties)); + assert!(s.activate(Properties) && s.is_visible(Properties)); + // Top / Bottom: a frame of its own above / below the anchor's, not a split column. + assert!(s.dock_panel(Info, Properties, Zone::Bottom)); + let below = s.path_of(Info).unwrap(); + assert_ne!(below, props); + assert_eq!(s.path_of(Properties), Some(props.clone())); + assert!(s.dock_panel(Info, Properties, Zone::Top)); + assert_eq!(s.path_of(Info), Some(props.clone()), "above Properties: its frame's index"); + // Closing a tab keeps the frame; closing the frame's last tab removes it. + s.close(Tracker); + assert!(s.contains(Properties) && !s.contains(Tracker)); + // A tab dragged onto a stacked frame's tab strip joins that frame. + let mut t = workspace("Default"); + assert!(t.insert_tab(Info, Preview, None)); + assert_eq!(t.path_of(Info), t.path_of(Preview)); // Moving the only panel of a group collapses its split. let mut m = hsplit(SplitSize::Ratio(0.5), tabs(&[Project], 0), tabs(&[Composition], 0)); assert!(m.dock_panel(Project, Composition, Zone::Center)); assert_eq!(m, tabs(&[Composition, Project], 1)); } + /// #302: workspaces saved when each stacked frame held one `panel` still load. + #[test] + fn old_stack_entries_load() { + let e: StackEntry = serde_json::from_str(r#"{"panel":"Preview","open":true,"height":46.0}"#).unwrap(); + assert_eq!(e, StackEntry::new(PanelKind::Preview, true, Some(46.0))); + let s = serde_json::to_string(&workspace("Default")).unwrap(); + assert_eq!(serde_json::from_str::(&s).unwrap(), workspace("Default")); + } + #[test] fn floating_layout_round_trips() { use PanelKind::*; diff --git a/crates/ui-egui/tests/ui_panel_focus.rs b/crates/ui-egui/tests/ui_panel_focus.rs index f87c3ddd..5d3302b1 100644 --- a/crates/ui-egui/tests/ui_panel_focus.rs +++ b/crates/ui-egui/tests/ui_panel_focus.rs @@ -47,7 +47,7 @@ fn group_of(n: &DockNode, p: PanelKind) -> Option> { match n { DockNode::Split { a, b, .. } => group_of(a, p).or_else(|| group_of(b, p)), DockNode::Tabs { panels, .. } => panels.contains(&p).then(|| panels.clone()), - DockNode::Stack { entries } => entries.iter().any(|e| e.panel == p).then(|| entries.iter().map(|e| e.panel).collect()), + DockNode::Stack { entries } => entries.iter().find(|e| e.contains(p)).map(|e| e.panels.clone()), } } diff --git a/crates/ui-egui/tests/ui_tabs.rs b/crates/ui-egui/tests/ui_tabs.rs index 8b52a72a..4d6777b6 100644 --- a/crates/ui-egui/tests/ui_tabs.rs +++ b/crates/ui-egui/tests/ui_tabs.rs @@ -64,7 +64,7 @@ fn group_of(n: &DockNode, p: PanelKind) -> Option> { match n { DockNode::Split { a, b, .. } => group_of(a, p).or_else(|| group_of(b, p)), DockNode::Tabs { panels, .. } => panels.contains(&p).then(|| panels.clone()), - DockNode::Stack { entries } => entries.iter().any(|e| e.panel == p).then(|| entries.iter().map(|e| e.panel).collect()), + DockNode::Stack { entries } => entries.iter().find(|e| e.contains(p)).map(|e| e.panels.clone()), } } @@ -209,7 +209,7 @@ fn stacked_panel_gaps_resize_the_panels() { match n { DockNode::Split { a, b, .. } => height(a, p).or_else(|| height(b, p)), DockNode::Tabs { .. } => None, - DockNode::Stack { entries } => entries.iter().find(|e| e.panel == p).map(|e| e.height), + DockNode::Stack { entries } => entries.iter().find(|e| e.contains(p)).map(|e| e.height), } } let (preview, props) = (rect(&h, "panel.Preview"), rect(&h, "panel.Properties")); @@ -345,3 +345,23 @@ fn a_stacked_panels_whole_header_bar_toggles_it() { click_n(&mut h, menu.center(), 1); assert!(open(&h), "the panel menu button doesn't toggle the panel"); } + +/// #302: a panel dropped onto a stacked panel becomes a tab in that panel's frame (it was +/// inserted as a stacked panel of its own below it); its tabs switch the frame's panel. +#[test] +fn a_tab_dropped_on_a_stacked_panel_joins_its_frame() { + let (mut h, _, _) = harness(); + let dock = |h: &Harness<'_, EffectcraftApp>| h.state().ui.dock.clone(); + let fx = rect(&h, "panel.tab.EffectControls"); + let props = rect(&h, "panel.Properties"); + drag(&mut h, fx.center(), props.center()); + assert_eq!(group_of(&dock(&h), PanelKind::Properties), Some(vec![PanelKind::Properties, PanelKind::EffectControls])); + assert!(dock(&h).is_visible(PanelKind::EffectControls) && !dock(&h).is_visible(PanelKind::Properties)); + // The frame's other tab shows Properties again, the frame stays open. + let tab = rect(&h, "panel.tab.Properties"); + click_n(&mut h, tab.center(), 1); + assert!(dock(&h).is_visible(PanelKind::Properties) && !dock(&h).is_visible(PanelKind::EffectControls)); + // The layout (saved workspaces) round-trips. + let s = serde_json::to_string(&dock(&h)).unwrap(); + assert_eq!(serde_json::from_str::(&s).unwrap(), dock(&h)); +} From 03598229baab9e56ac62cae0b9d7301db9a47d46 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 21:23:23 +0000 Subject: [PATCH 084/107] Viewer: 3D layer outlines and the axis gizmo clip at the camera instead of drawing long lines (#572) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The viewer projected a 3D layer's box, its axis gizmo and its Extended Viewer outline through the camera with a plain perspective divide. A point at or behind the camera (w ≤ 0) flips through infinity, so a layer turned or moved so that part of it reaches past the camera drew long lines across the viewer, and its handles and hit area flipped too. - `layer_quad` (handles, hit tests, marquee) is None for a layer reaching past the near plane, so nothing is placed or picked on a flipped quad. - `layer_outline` clips the box to the part in front of the near plane (Sutherland-Hodgman in homogeneous coordinates); the selection outline and the Extended Viewer use it. - The 3D axis gizmo's arrows are clipped the same way (`project_segment`). Tests: a box crossing the near plane keeps only its front part, one wholly behind draws nothing; gizmo segments are cut at the plane and never flip. Note: the reporter's exact setup (ellipse, X/Y Rotation, Orientation) wasn't reproduced in a headless probe with the default camera, where the layer stays in front of it; this fixes the projection fault that draws such lines. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f --- crates/ui-egui/src/panels/viewer.rs | 125 ++++++++++++++++++++++++++-- 1 file changed, 119 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index 36df6174..e4ef5f37 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -258,17 +258,87 @@ pub(crate) fn fx2c(ctx: &EvalCtx, layer: &Layer) -> Mat3 { from_effect_space(ctx, layer, l2c(ctx, layer).0) } -/// Layer → comp matrix and its bounds quad (in comp pixels). +/// The least homogeneous depth (`w`: the distance in front of a perspective camera in pixels, 1 +/// for 2D layers and orthographic views) a viewer outline is drawn at: points at or behind the +/// camera would flip through infinity and draw long lines across the viewer (#572). +const NEAR_W: f64 = 0.5; + +/// `p` through the homography `m` before the perspective divide: (x, y, w). +fn homogeneous(m: &Mat3, p: [f64; 2]) -> [f64; 3] { + let r = |i: usize| m.0.get(i).map_or(0.0, |row| row[0] * p[0] + row[1] * p[1] + row[2]); + [r(0), r(1), r(2)] +} + +/// The closed polygon `poly` (layer pixels) through `m`, clipped to the part in front of the +/// camera's near plane (comp pixels; empty when none of it is). +fn project_clipped(m: &Mat3, poly: &[[f64; 2]]) -> Vec<[f64; 2]> { + let h: Vec<[f64; 3]> = poly.iter().map(|p| homogeneous(m, *p)).collect(); + let mut out = vec![]; + for (i, a) in h.iter().enumerate() { + let Some(b) = h.get((i + 1) % h.len()) else { continue }; + let (ina, inb) = (a[2] >= NEAR_W, b[2] >= NEAR_W); + if ina { + out.push(*a); + } + if ina != inb { + let t = (NEAR_W - a[2]) / (b[2] - a[2]); + out.push([a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, NEAR_W]); + } + } + out.iter().map(|q| [q[0] / q[2], q[1] / q[2]]).filter(|q| q[0].is_finite() && q[1].is_finite()).collect() +} + +/// The segment a → b (world space) through the projection `pc`, clipped at the near plane. +fn project_segment(pc: &effectcraft_engine::geom::Mat4, a: effectcraft_engine::geom::Vec3, b: effectcraft_engine::geom::Vec3) -> Option<[[f64; 2]; 2]> { + let h = |p: effectcraft_engine::geom::Vec3| { + let r = |i: usize| pc.0.get(i).map_or(0.0, |row| row[0] * p.x + row[1] * p.y + row[2] * p.z + row[3]); + [r(0), r(1), r(3)] + }; + let (mut ha, mut hb) = (h(a), h(b)); + if ha[2] < NEAR_W && hb[2] < NEAR_W { + return None; + } + let cut = |p: [f64; 3], q: [f64; 3]| { + let t = (NEAR_W - p[2]) / (q[2] - p[2]); + [p[0] + (q[0] - p[0]) * t, p[1] + (q[1] - p[1]) * t, NEAR_W] + }; + if ha[2] < NEAR_W { + ha = cut(ha, hb); + } else if hb[2] < NEAR_W { + hb = cut(hb, ha); + } + let d = |q: [f64; 3]| [q[0] / q[2], q[1] / q[2]]; + let s = [d(ha), d(hb)]; + s.iter().flatten().all(|v| v.is_finite()).then_some(s) +} + +/// The content box corners (layer pixels) of `layer`. +fn box_corners(b: [f64; 4]) -> [[f64; 2]; 4] { + [[b[0], b[1]], [b[2], b[1]], [b[2], b[3]], [b[0], b[3]]] +} + +/// Layer → comp matrix and its bounds quad (in comp pixels). None for a 3D layer reaching to or +/// behind the camera (its quad would flip; [`layer_outline`] draws the part in front). pub(crate) fn layer_quad(ctx: &EvalCtx, layer: &Layer) -> Option<(Mat3, [[f64; 2]; 4], [f64; 4])> { let b = effectcraft_engine::render::content_bounds(ctx, layer)?; let (m, _) = l2c(ctx, layer); - let pts = [[b[0], b[1]], [b[2], b[1]], [b[2], b[3]], [b[0], b[3]]].map(|p| { + let corners = box_corners(b); + if corners.iter().any(|p| homogeneous(&m, *p)[2] < NEAR_W) { + return None; + } + let pts = corners.map(|p| { let q = m.apply(gv2(p[0], p[1])); [q.x, q.y] }); Some((m, pts, b)) } +/// The outline of a layer's content box in comp pixels, clipped at the camera's near plane. +pub(crate) fn layer_outline(ctx: &EvalCtx, layer: &Layer) -> Vec<[f64; 2]> { + let Some(b) = effectcraft_engine::render::content_bounds(ctx, layer) else { return vec![] }; + project_clipped(&l2c(ctx, layer).0, &box_corners(b)) +} + /// Which axes a bounding-box handle scales: corners (0–3) both; the top and bottom edges (4, 6) /// y; the right and left edges (5, 7) x. fn handle_axes(handle: usize) -> [bool; 2] { @@ -836,6 +906,12 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } let Some((m, q, _)) = layer_quad(&ectx, l) else { + // A 3D layer reaching past the camera: the part of its box in front of it. + let outline = layer_outline(&ectx, l); + if outline.len() > 2 { + painter.add(egui::Shape::closed_line(outline.iter().map(|p| map.to_screen(*p)).collect(), Stroke::new(1.0, col))); + continue; + } // Cameras, lights, empty layers: just the anchor. if let Some(tr) = l.transform() { let pos = ectx.v3(l, tr, "position", [0.0; 3]); @@ -868,15 +944,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let w = ectx.world_matrix(l); let pc = cam_state(&ectx).projection(cw as f64, ch as f64) * w; let a3 = effectcraft_engine::geom::vec3(a[0], a[1], a[2]); - let o = pc.apply(a3); let len = 60.0 / zoom as f64; for (axis, col) in [ (effectcraft_engine::geom::vec3(len, 0.0, 0.0), Color32::from_rgb(0xe0, 0x50, 0x50)), (effectcraft_engine::geom::vec3(0.0, len, 0.0), Color32::from_rgb(0x60, 0xd0, 0x60)), (effectcraft_engine::geom::vec3(0.0, 0.0, len), Color32::from_rgb(0x50, 0x8c, 0xf0)), ] { - let e = pc.apply(a3 + axis); - painter.arrow(map.to_screen([o.x, o.y]), map.to_screen([e.x, e.y]) - map.to_screen([o.x, o.y]), Stroke::new(2.0, col)); + // (clipped at the camera's near plane, #572) + if let Some([o, e]) = project_segment(&pc, a3, a3 + axis) { + painter.arrow(map.to_screen(o), map.to_screen(e) - map.to_screen(o), Stroke::new(2.0, col)); + } } } } @@ -888,7 +965,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let has_3d = comp.layers.iter().any(|l| l.is_3d() && l.is_active_at(time)); if has_3d && app.session.prefs.three_d.extended_viewer { for l in comp.layers.iter().filter(|l| l.is_3d() && l.is_active_at(time) && l.has_video()) { - if let Some((_, q, _)) = layer_quad(&ectx, l) { + let q = layer_outline(&ectx, l); + if q.len() > 2 { let sq: Vec = q.iter().map(|p| map.to_screen(*p)).collect(); let outside = sq.iter().any(|p| !comp_rect.contains(*p)); if outside { @@ -2228,3 +2306,38 @@ fn show_frame(app: &mut EffectcraftApp, ctx: &egui::Context, key: crate::frames: } } } + +#[cfg(test)] +mod tests { + use super::*; + use effectcraft_engine::geom::{Mat4, vec3}; + + /// A perspective-like homography: w = 100 + y (the plane crosses the camera at y = −100). + fn tilted() -> Mat3 { + Mat3([[100.0, 0.0, 0.0], [0.0, 100.0, 0.0], [0.0, 1.0, 100.0]]) + } + + /// #572: a box reaching behind the camera is clipped at the near plane instead of flipping + /// through infinity into long lines. + #[test] + fn outlines_clip_at_the_near_plane() { + let m = tilted(); + let front = project_clipped(&m, &box_corners([-50.0, -50.0, 50.0, 50.0])); + assert_eq!(front.len(), 4); + let crossing = project_clipped(&m, &box_corners([-50.0, -150.0, 50.0, 50.0])); + assert_eq!(crossing.len(), 4, "{crossing:?}"); + // Every point is in front: none flipped to the other side (y < 0 here means behind). + assert!(crossing.iter().all(|p| p[0].is_finite() && p[1].abs() < 1e4 && p[0].abs() < 1e4), "{crossing:?}"); + assert!(project_clipped(&m, &box_corners([-50.0, -400.0, 50.0, -200.0])).is_empty(), "wholly behind"); + } + + #[test] + fn gizmo_segments_clip_at_the_near_plane() { + // w = 100 + z. + let pc = Mat4([[100.0, 0.0, 0.0, 0.0], [0.0, 100.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 1.0, 100.0]]); + let s = project_segment(&pc, vec3(10.0, 0.0, 0.0), vec3(10.0, 0.0, -300.0)).unwrap(); + assert_eq!(s[0], [10.0, 0.0]); + assert!(s[1][0] > 10.0 && s[1][0].is_finite(), "toward the camera it grows, never flips: {s:?}"); + assert!(project_segment(&pc, vec3(0.0, 0.0, -200.0), vec3(0.0, 0.0, -300.0)).is_none()); + } +} From 657f34d40b6f4eec2e0fc29bf5f3cbca656d3fe3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 21:23:36 +0000 Subject: [PATCH 085/107] Layers: with several layers selected, value edits, stopwatches and viewer Scale / Rotate change them all (#355, #491) With several layers selected, After Effects applies a property edit to the same property of every selected layer; we changed only the layer whose row was edited (the Properties panel showed only the first selected layer), so a stopwatch switched on one layer and later moves of the others had nothing to keyframe (#355), and scaling or rotating a selection changed one layer (#491). - Engine: `prop.set`, `prop.toggleAnimation` and `prop.toggleKey` take `selected: true`: the change also goes to the same property (by match path, `transform/scale`, `effects/#n/`) of every other selected, unlocked layer whose value has the same kind, in one undo step (a scrub keeps folding into it through its `merge` key). With `offset: true` (a scrub) each layer changes by the same amount from its own value; a typed value sets them all. Position with Separate Dimensions is handled per layer. - The Timeline, Properties and Effect Controls panels pass `selected` (and `offset` while a value is dragged). The Properties panel heads "(N) Selected Objects" (zh-CN / zh-TW / ja / uk rows). - Viewer: a bounding-box handle drag scales every selected layer by the same ratio about its own anchor point; the Rotation tool turns them all by the same angle (one undo step). Tests: engine test (stopwatch, scrub offset, typed value, keyframe button, one undo step, unselected layers untouched); kittests: the Properties panel with three layers selected (header, stopwatch on all three undone in one step, a scrub moving all three by the same amount) and a handle drag scaling two selected layers. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f --- crates/engine/src/commands/prop.rs | 147 ++++++++++++++++++- crates/engine/src/tests_timeline.rs | 48 ++++++ crates/ui-egui/src/i18n/ui.rs | 4 + crates/ui-egui/src/panels/effect_controls.rs | 11 +- crates/ui-egui/src/panels/properties.rs | 22 ++- crates/ui-egui/src/panels/timeline.rs | 12 +- crates/ui-egui/src/panels/viewer.rs | 42 +++++- crates/ui-egui/tests/ui_keyframes.rs | 50 +++++++ crates/ui-egui/tests/ui_viewer.rs | 26 ++++ 9 files changed, 342 insertions(+), 20 deletions(-) diff --git a/crates/engine/src/commands/prop.rs b/crates/engine/src/commands/prop.rs index cb8c3ad2..46804c87 100644 --- a/crates/engine/src/commands/prop.rs +++ b/crates/engine/src/commands/prop.rs @@ -165,6 +165,145 @@ fn property_reply(p: &Value, layer: &Layer, pr: &Property, time: Tick, value: Va } } +// ---------------------------------------------------------------- several selected layers + +/// With `selected: true` (the Timeline, Properties and Effect Controls panels pass it): the same +/// property (by match path, `transform/position`, `effects/#n/`) of every other +/// selected, unlocked layer of the comp whose value is of the same kind, as (layer, uid). Empty +/// when `selected` is not set or the edited layer isn't selected. After Effects applies a value +/// edit, the stopwatch and the keyframe button to all selected layers (#355, #491). +fn selected_twins(s: &Session, p: &Value, cmd: &str) -> Result> { + if b_p(p, "selected") != Some(true) { + return Ok(vec![]); + } + let (cid, lid, uid) = prop_ref(s, p, cmd)?; + let Some(comp) = s.project.comp(cid) else { return Ok(vec![]) }; + let Some(layer) = comp.layer(lid) else { return Ok(vec![]) }; + let (Some(path), Some(pr)) = (layer.props.match_path_of(uid), layer.props.find(uid)) else { return Ok(vec![]) }; + if !s.state.selected_layers.contains(&lid) { + return Ok(vec![]); + } + let kind = (pr.value.kind_name(), pr.value.components().len()); + Ok(s.state + .selected_layers + .iter() + .filter(|o| **o != lid) + .filter_map(|o| comp.layer(*o)) + .filter(|l| !l.switches.locked) + .filter_map(|l| l.props.prop(&path).filter(|q| (q.value.kind_name(), q.value.components().len()) == kind).map(|q| (l.id, q.uid))) + .collect()) +} + +/// `p` aimed at property `uid` of layer `lid` instead. +fn twin_params(p: &Value, lid: LayerId, uid: Uid) -> Value { + let mut q = p.clone(); + if let Some(o) = q.as_object_mut() { + o.remove("path"); + o.remove("selected"); + o.remove("offset"); + } + q["layer"] = json!(lid.0); + q["prop"] = json!(uid); + q +} + +/// The value of property `uid` of `lid` at the CTI as numbers (Position with Separate +/// Dimensions: its X / Y / Z Position). +fn current_components(s: &Session, cid: ItemId, lid: LayerId, uid: Uid) -> Option> { + let l = s.project.comp(cid)?.layer(lid)?; + let lt = l.layer_time(s.time_of(cid)); + if let Some(tr) = l.transform() + && tr.get("position").is_some_and(|q| q.uid == uid) + && tr.get("positionX").is_some() + { + return Some(["positionX", "positionY", "positionZ"].iter().filter_map(|m| tr.get(m)).map(|q| q.value_at(lt).as_f64()).collect()); + } + Some(l.props.find(uid)?.value_at(lt).components()) +} + +/// A JSON number or array of numbers. +fn json_numbers(v: &Value) -> Option> { + match v { + Value::Number(n) => Some(vec![n.as_f64()?]), + Value::Array(a) => a.iter().map(Value::as_f64).collect(), + _ => None, + } +} + +/// `prop.set`; with `selected`, also on the other selected layers: the same value, or with +/// `offset` (a scrub) the same change from each layer's own value. One undo step (with `merge`, +/// one that a continuing scrub keeps folding into). +fn set_selected(s: &mut Session, p: &Value) -> Result { + let twins = selected_twins(s, p, "prop.set")?; + if twins.is_empty() { + return set(s, p); + } + let (cid, lid, uid) = prop_ref(s, p, "prop.set")?; + let new = p.get("value").and_then(json_numbers); + let delta = match (b_p(p, "offset"), &new, current_components(s, cid, lid, uid)) { + (Some(true), Some(n), Some(c)) => Some(n.iter().zip(c.iter().chain(std::iter::repeat(&0.0))).map(|(a, b)| a - b).collect::>()), + _ => None, + }; + let run = |s: &mut Session| -> Result { + let out = set(s, p)?; + for (olid, ouid) in twins { + let mut q = twin_params(p, olid, ouid); + if let Some(d) = &delta + && let Some(c) = current_components(s, cid, olid, ouid) + { + let v: Vec = c.iter().enumerate().map(|(i, x)| x + d.get(i).copied().unwrap_or(0.0)).collect(); + q["value"] = if p.get("value").is_some_and(Value::is_number) { json!(v.first().copied().unwrap_or(0.0)) } else { json!(v) }; + } + // (a layer that can't take it, e.g. a clamped or static property, keeps its value) + let _ = set(s, &q); + } + Ok(out) + }; + if merge_p(p).is_some() { run(s) } else { super::app_more::grouped(s, "Change Property", run) } +} + +/// `prop.toggleAnimation`; with `selected`, the other selected layers' same property follows. +fn toggle_anim_selected(s: &mut Session, p: &Value) -> Result { + let twins = selected_twins(s, p, "prop.toggleAnimation")?; + if twins.is_empty() { + return toggle_anim(s, p); + } + let run = |s: &mut Session| -> Result { + let on = toggle_anim(s, p)?; + for (olid, ouid) in twins { + let mut q = twin_params(p, olid, ouid); + q["value"] = on.clone(); + let _ = toggle_anim(s, &q); + } + Ok(on) + }; + // (a stopwatch drag over several rows keeps folding into the step its `merge` names) + if merge_p(p).is_some() { run(s) } else { super::app_more::grouped(s, "Toggle Animation", run) } +} + +/// `prop.toggleKey`; with `selected`, the other selected layers' same property gets (or loses) +/// its keyframe at the current time too. +fn toggle_key_selected(s: &mut Session, p: &Value) -> Result { + let twins = selected_twins(s, p, "prop.toggleKey")?; + if twins.is_empty() { + return toggle_key(s, p); + } + let cid = prop_ref(s, p, "prop.toggleKey")?.0; + super::app_more::grouped(s, "Add/Remove Keyframe", |s| { + let added = toggle_key(s, p)?; + let want = added.as_bool().unwrap_or(true); + for (olid, ouid) in twins { + let _ = with_prop(s, "Add/Remove Keyframe", None, cid, olid, ouid, |pr, lt| { + if key_at(&pr.keys, lt).is_some() != want { + toggle_key_at(pr, lt); + } + Ok(()) + }); + } + Ok(added) + }) +} + fn set(s: &mut Session, p: &Value) -> Result { let (cid, lid, uid) = prop_ref(s, p, "prop.set")?; // Position with Separate Dimensions: write the X/Y/Z Position properties instead. @@ -1001,13 +1140,13 @@ pub fn specs() -> Vec { "Set Property Value", [], None, - "{layer?, path|prop, value, time? (layer s by default), timeBase?: layer|comp, merge?}", + "{layer?, path|prop, value, time? (layer s by default), timeBase?: layer|comp, merge?, selected? (also the other selected layers), offset? (them by the same change)}", has_layers, - set + set_selected ), - cmd!("prop.toggleAnimation", "Toggle Stopwatch", [], None, "{layer?, path|prop, value?, merge?}", has_layers, toggle_anim), + cmd!("prop.toggleAnimation", "Toggle Stopwatch", [], None, "{layer?, path|prop, value?, merge?, selected?}", has_layers, toggle_anim_selected), cmd!("prop.addKey", "Add Keyframe", [], None, "{layer?, path|prop, time? (layer s by default), timeBase?: layer|comp, value?}", has_layers, add_key), - cmd!("prop.toggleKey", "Add or Remove Keyframe at Current Time", [], None, "{layer?, path|prop}", has_layers, toggle_key), + cmd!("prop.toggleKey", "Add or Remove Keyframe at Current Time", [], None, "{layer?, path|prop, selected?}", has_layers, toggle_key_selected), cmd!( "keys.toggleTransform", "Add or Remove Transform Keyframe", diff --git a/crates/engine/src/tests_timeline.rs b/crates/engine/src/tests_timeline.rs index a741f5ad..746a8a0b 100644 --- a/crates/engine/src/tests_timeline.rs +++ b/crates/engine/src/tests_timeline.rs @@ -831,3 +831,51 @@ fn toggle_transform_key_covers_selected_layers_separated_position_and_3d_rotatio } assert!(s.execute("keys.toggleTransform", json!({"prop": "skew"})).is_err()); } + +/// #355, #491: with several layers selected, the stopwatch, the keyframe button and value edits +/// (`selected: true`, as the panels send them) apply to every selected layer in one undo step; a +/// scrub (`offset`) moves each layer by the same amount, a typed value sets them all. +#[test] +fn property_edits_apply_to_every_selected_layer() { + let (mut s, a) = setup(); + let b = s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 50, "height": 50})).unwrap()["layer"].as_u64().unwrap(); + let c = s.execute("layer.newSolid", json!({"color": "#00ff00", "width": 50, "height": 50})).unwrap()["layer"].as_u64().unwrap(); + s.execute("prop.set", json!({"layer": b, "path": "transform/position", "value": [10, 20]})).unwrap(); + s.execute("layer.select", json!({"layers": [a, b]})).unwrap(); + let pos = |s: &Session, l: u64| prop(s, l, "transform/position").value_at(effectcraft_time::Tick::ZERO).components(); + let uid = prop(&s, a, "transform/position").uid; + + // Stopwatch on all selected layers, one undo step; the unselected layer is untouched. + let undo = s.history.undo.len(); + s.execute("prop.toggleAnimation", json!({"layer": a, "prop": uid, "selected": true})).unwrap(); + assert!(prop(&s, a, "transform/position").is_animated() && prop(&s, b, "transform/position").is_animated()); + assert!(!prop(&s, c, "transform/position").is_animated()); + assert_eq!(s.history.undo.len(), undo + 1); + s.execute("edit.undo", json!({})).unwrap(); + assert!(!prop(&s, a, "transform/position").is_animated() && !prop(&s, b, "transform/position").is_animated()); + + // A scrub moves both by the same change (merged into one step while it continues). + let (pa, pb) = (pos(&s, a), pos(&s, b)); + for k in 1..=3 { + let v = [pa[0] + 5.0 * k as f64, pa[1]]; + s.execute("prop.set", json!({"layer": a, "prop": uid, "value": v, "selected": true, "offset": true, "merge": "scrub-1"})).unwrap(); + } + assert_eq!(pos(&s, a)[..2], [pa[0] + 15.0, pa[1]]); + assert_eq!(pos(&s, b)[..2], [pb[0] + 15.0, pb[1]]); + assert_eq!(s.history.undo.len(), undo + 1, "one undo step for the scrub"); + // A typed value sets them all. + s.execute("prop.set", json!({"layer": a, "path": "transform/opacity", "value": 40, "selected": true})).unwrap(); + assert_eq!(prop(&s, b, "transform/opacity").value.as_f64(), 40.0); + assert_eq!(prop(&s, c, "transform/opacity").value.as_f64(), 100.0); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(prop(&s, a, "transform/opacity").value.as_f64(), 100.0); + assert_eq!(prop(&s, b, "transform/opacity").value.as_f64(), 100.0); + + // The keyframe button adds a key on each selected layer. + s.execute("prop.toggleKey", json!({"layer": a, "path": "transform/scale", "selected": true})).unwrap(); + assert_eq!(prop(&s, a, "transform/scale").keys.len(), 1); + assert_eq!(prop(&s, b, "transform/scale").keys.len(), 1); + // Without `selected` only the given layer changes. + s.execute("prop.set", json!({"layer": a, "path": "transform/rotation", "value": 30})).unwrap(); + assert_eq!(prop(&s, b, "transform/rotation").value.as_f64(), 0.0); +} diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index ce7c2a99..f7c77905 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -405,6 +405,7 @@ const JAPANESE: &[(&str, &str)] = &[ ("Select a command to change its shortcut.", "ショートカットを変更するコマンドを選択してください。"), ("Select a layer to see its effects", "レイヤーを選択するとエフェクトが表示されます"), ("Select a layer to see its properties", "レイヤーを選択するとプロパティが表示されます"), + ("({}) Selected Objects", "({}) 個の選択されたオブジェクト"), ( "Select a layer with a mask or shape path (or the path property), or Puppet pins.", "マスクまたはシェイプパスのあるレイヤー(またはパスプロパティ)か、パペットピンを選択してください。", @@ -840,6 +841,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("Select a command to change its shortcut.", "选择一条命令以更改其快捷键。"), ("Select a layer to see its effects", "选择图层以查看其效果"), ("Select a layer to see its properties", "选择图层以查看其属性"), + ("({}) Selected Objects", "({}) 个选定对象"), ( "Select a layer with a mask or shape path (or the path property), or Puppet pins.", "请选择带蒙版或形状路径(或路径属性)的图层,或带有人偶定位点的图层。", @@ -1364,6 +1366,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("Select a command to change its shortcut.", "選取一條命令以更改其快速鍵。"), ("Select a layer to see its effects", "選取圖層以查看其效果"), ("Select a layer to see its properties", "選取圖層以查看其屬性"), + ("({}) Selected Objects", "({}) 個選取的物件"), ( "Select a layer with a mask or shape path (or the path property), or Puppet pins.", "請選取帶遮色片或形狀路徑(或路徑屬性)的圖層,或帶有人偶釘選點的圖層。", @@ -1882,6 +1885,7 @@ const UKRAINIAN: &[(&str, &str)] = &[ ("Select a command to change its shortcut.", "Виберіть команду, щоб змінити її клавіатурне скорочення."), ("Select a layer to see its effects", "Виділіть шар, щоб переглянути його ефекти"), ("Select a layer to see its properties", "Виділіть шар, щоб переглянути його властивості"), + ("({}) Selected Objects", "Вибрано об’єктів: ({})"), ( "Select a layer with a mask or shape path (or the path property), or Puppet pins.", "Виділіть шар із маскою або контуром фігури (або властивість контуру) чи опорні точки деформації.", diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index 6612187e..d5edc667 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -180,7 +180,7 @@ fn key_navigator( match what { "prev" => actions.push(("time.go".into(), json!({"to": "prevKey", "prop": uid}))), "next" => actions.push(("time.go".into(), json!({"to": "nextKey", "prop": uid}))), - _ => actions.push(("prop.toggleKey".into(), json!({"layer": layer.id.0, "prop": uid}))), + _ => actions.push(("prop.toggleKey".into(), json!({"layer": layer.id.0, "prop": uid, "selected": true}))), } } } @@ -241,7 +241,7 @@ fn prop_row( ); app.auto.add(&format!("effectControls.prop.{uid}.stopwatch"), swr, &prop.name); if resp.clicked() { - actions.push(("prop.toggleAnimation".into(), json!({"layer": layer.id.0, "prop": uid}))); + actions.push(("prop.toggleAnimation".into(), json!({"layer": layer.id.0, "prop": uid, "selected": true}))); } } if prop.is_animated() { @@ -273,8 +273,11 @@ fn prop_row( ui.interact(name_clip, egui::Id::new(("ec-prop-name", uid)), Sense::click()); app.auto.add(&format!("effectControls.prop.{uid}.name"), name_clip, &prop.name); let merge = format!("ec-{uid}"); - let set = - |actions: &mut Actions, v: serde_json::Value| actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": uid, "value": v, "merge": merge}))); + // With several layers selected the edit applies to the same effect property of each (#491). + let scrub = ui.ctx().dragged_id().is_some(); + let set = |actions: &mut Actions, v: serde_json::Value| { + actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": uid, "value": v, "merge": merge, "selected": true, "offset": scrub}))) + }; let twirl_open = |app: &mut EffectcraftApp, ui: &mut egui::Ui| { let open = app.ui.fx_slider_open.contains(&uid); let tw = Rect::from_center_size(pos2(swr.min.x - 9.0, cy), vec2(10.0, 10.0)); diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index dbeeb1a7..c10d29bb 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -72,6 +72,17 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let mut y = top; let mut actions: Actions = vec![]; + // Several layers selected: "(3) Selected Objects", as in After Effects. The rows show the + // first layer; the stopwatch, keyframe button and value edits apply to all of them (#355). + let selected = app.session.state.selected_layers.iter().filter(|id| comp.layer(**id).is_some()).count(); + if selected > 1 { + let n = selected.to_string(); + let label = crate::i18n::tr_args("({}) Selected Objects", &[&n]); + p.text(pos2(x0, y + ROW_H / 2.0), Align2::LEFT_CENTER, &label, Tokens::semibold(12.0), t.text); + app.auto.add("properties.selectedObjects", Rect::from_min_size(pos2(x0, y), vec2(w, ROW_H)), &label); + y += ROW_H + 4.0; + } + // ---------------------------------------------------------------- Layer Transform if let Some(tr) = layer.transform().cloned() { section_header(app, ui, &p, x0, w, y, "Layer Transform", "properties.transform"); @@ -218,7 +229,7 @@ fn transform_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, match what { "prev" => actions.push(("time.go".into(), json!({"to": "prevKey", "prop": uid}))), "next" => actions.push(("time.go".into(), json!({"to": "nextKey", "prop": uid}))), - _ => actions.push(("prop.toggleKey".into(), json!({"layer": lid, "prop": uid}))), + _ => actions.push(("prop.toggleKey".into(), json!({"layer": lid, "prop": uid, "selected": true}))), } } } @@ -228,13 +239,18 @@ fn transform_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, icons::paint(p, swr, Icon::Stopwatch, if resp.hovered() { t.text } else { t.text_dim }); app.auto.add(&format!("{base}.stopwatch"), swr, &pr.name); if resp.clicked() { - actions.push(("prop.toggleAnimation".into(), json!({"layer": lid, "prop": uid}))); + actions.push(("prop.toggleAnimation".into(), json!({"layer": lid, "prop": uid, "selected": true}))); } } p.text(pos2(r.min.x + 42.0, cy), Align2::LEFT_CENTER, &pr.name, Tokens::ui(12.0), t.text); let vx = r.min.x + (r.width() * 0.5).max(130.0); let merge = format!("props-{uid}"); - let set = |actions: &mut Actions, v: serde_json::Value| actions.push(("prop.set".into(), json!({"layer": lid, "prop": uid, "value": v, "merge": merge}))); + // With several layers selected the edit applies to all: a scrub by the same change, a typed + // value as it is. + let scrub = ui.ctx().dragged_id().is_some(); + let set = |actions: &mut Actions, v: serde_json::Value| { + actions.push(("prop.set".into(), json!({"layer": lid, "prop": uid, "value": v, "merge": merge, "selected": true, "offset": scrub}))) + }; let value = ectx.value(layer, pr); let is_scale = pr.name == "Scale"; let pct = is_scale || pr.name == "Opacity"; diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 086e46dc..fb074bcc 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -2403,7 +2403,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { ui_actions.push(UiAct::SetTime(layer.comp_time(k.time))); } if ui.interact(mid, egui::Id::new(("kmid", uid)), Sense::click()).clicked() { - actions.push(("prop.toggleKey".into(), json!({"layer": layer.id.0, "prop": uid}))); + actions.push(("prop.toggleKey".into(), json!({"layer": layer.id.0, "prop": uid, "selected": true}))); } app.auto.add(&format!("timeline.prop.{uid}.addKey"), mid, "Add or remove keyframe"); } @@ -2426,10 +2426,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { actions.push(("prop.setExpression".into(), json!({"layer": layer.id.0, "prop": uid}))); } } else if let Some((value, merge)) = switch_gesture(ui, &resp, r, "stopwatch", prop.is_animated()) { - actions.push(("prop.toggleAnimation".into(), json!({"layer": layer.id.0, "prop": uid, "value": value, "merge": merge}))); + actions + .push(("prop.toggleAnimation".into(), json!({"layer": layer.id.0, "prop": uid, "value": value, "merge": merge, "selected": true}))); } else if resp.clicked() && !resp.clicked_by(egui::PointerButton::Primary) { // Keyboard or accessibility activation. - actions.push(("prop.toggleAnimation".into(), json!({"layer": layer.id.0, "prop": uid}))); + actions.push(("prop.toggleAnimation".into(), json!({"layer": layer.id.0, "prop": uid, "selected": true}))); } } let name_x = indent + 12.0; @@ -3510,8 +3511,11 @@ fn value_editor( let uid = prop.uid; let mut dims = vec![]; let merge = format!("scrub-{uid}"); + // With several layers selected the edit applies to all of them (#491): a scrub by the same + // change, a typed value as it is. + let scrub = ui.ctx().dragged_id().is_some(); let set = |actions: &mut Vec<(String, serde_json::Value)>, v: serde_json::Value| { - actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": uid, "value": v, "merge": merge}))) + actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": uid, "value": v, "merge": merge, "selected": true, "offset": scrub}))) }; let is_3d = layer.is_3d(); let mut x = at.x; diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index e4ef5f37..2c5d47f3 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -84,12 +84,17 @@ enum Gesture { /// Layer pixels → comp as the drag began, and its inverse. l2c: Mat3, inv: Mat3, + /// The other selected layers and their scales as the drag began: they scale by the same + /// ratio about their own anchor points, as in After Effects (#491). + others: Vec<(LayerId, [f64; 3])>, }, Rotate { layer: LayerId, center: Pos2, start_angle: f64, start_rot: f64, + /// The other selected layers and their rotations: they turn by the same angle. + others: Vec<(LayerId, f64)>, }, Anchor { layer: LayerId, @@ -258,6 +263,16 @@ pub(crate) fn fx2c(ctx: &EvalCtx, layer: &Layer) -> Mat3 { from_effect_space(ctx, layer, l2c(ctx, layer).0) } +/// The selected, unlocked layers of `comp` other than `layer` (when `layer` is one of them): a +/// handle or Rotation tool drag on one changes them all. +fn selected_others<'a>(app: &EffectcraftApp, comp: &'a Comp, layer: LayerId) -> Vec<&'a Layer> { + let sel = &app.session.state.selected_layers; + if !sel.contains(&layer) { + return vec![]; + } + sel.iter().filter(|id| **id != layer).filter_map(|id| comp.layer(*id)).filter(|l| !l.switches.locked && l.transform().is_some()).collect() +} + /// The least homogeneous depth (`w`: the distance in front of a perspective camera in pixels, 1 /// for 2D layers and orthographic views) a viewer outline is drawn at: points at or behind the /// camera would flip through infinity and draw long lines across the viewer (#572). @@ -1232,6 +1247,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } Tool::MaskFeather => ov::begin_feather(app, &ectx, &paths, &map, press).map(Gesture::Overlay), Tool::Rotate => pick(app, &ectx, cpt, mods.shift).map(|l| { + let others = selected_others(app, &comp, l).into_iter().filter_map(|o| Some((o.id, ectx.f(o, o.transform()?, "rotation", 0.0)))).collect(); let layer = comp.layer(l).cloned(); let center = layer .as_ref() @@ -1239,7 +1255,13 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { .map(|p| map.to_screen([p[0], p[1]])) .unwrap_or(pos); let rot = layer.as_ref().and_then(|l| l.transform().map(|tr| ectx.f(l, tr, "rotation", 0.0))).unwrap_or(0.0); - Gesture::Rotate { layer: l, center, start_angle: ((pos.y - center.y) as f64).atan2((pos.x - center.x) as f64).to_degrees(), start_rot: rot } + Gesture::Rotate { + layer: l, + center, + start_angle: ((pos.y - center.y) as f64).atan2((pos.x - center.x) as f64).to_degrees(), + start_rot: rot, + others, + } }), Tool::PanBehind => pick(app, &ectx, cpt, false).and_then(|l| { let layer = comp.layer(l)?; @@ -1349,6 +1371,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { axes: handle_axes(hi), l2c, inv, + others: selected_others(app, &comp, lid) + .into_iter() + .filter_map(|o| Some((o.id, ectx.v3(o, o.transform()?, "scale", [100.0; 3])))) + .collect(), }) }) } else if let Some(l) = pick(app, &ectx, cpt, mods.shift) { @@ -1417,23 +1443,29 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let _ = app.session.execute("prop.set", json!({"layer": lid.0, "path": "transform/position", "value": v, "merge": merge})); } } - Gesture::Scale { layer, start_scale, start_local, handle, anchor, axes, l2c, inv } => { + Gesture::Scale { layer, start_scale, start_local, handle, anchor, axes, l2c, inv, others } => { let lp = inv.apply(gv2(cpt[0], cpt[1])); let uniform = mods.shift && axes == [true, true]; let f = drag_ratio(start_local, [lp.x - anchor[0], lp.y - anchor[1]], axes, uniform); // The handle snaps (to the comp's and other layers' edges, corners and centres), // so a layer scales exactly to them, e.g. to the comp's size. let f = snap_handle(f, handle, anchor, &l2c, axes, uniform, |p| vt::snap(app, &ctx, &ectx, &map, &[layer], &[p], mods)); - let v = [start_scale[0] * f[0], start_scale[1] * f[1], start_scale[2]]; - let _ = app.session.execute("prop.set", json!({"layer": layer.0, "path": "transform/scale", "value": v, "merge": merge})); + for (lid, s0) in std::iter::once((layer, start_scale)).chain(others) { + let v = [s0[0] * f[0], s0[1] * f[1], s0[2]]; + let _ = app.session.execute("prop.set", json!({"layer": lid.0, "path": "transform/scale", "value": v, "merge": merge})); + } } - Gesture::Rotate { layer, center, start_angle, start_rot } => { + Gesture::Rotate { layer, center, start_angle, start_rot, others } => { let ang = ((pos.y - center.y) as f64).atan2((pos.x - center.x) as f64).to_degrees(); let mut r = start_rot + (ang - start_angle); if mods.shift { r = (r / 45.0).round() * 45.0; } let _ = app.session.execute("prop.set", json!({"layer": layer.0, "path": "transform/rotation", "value": r, "merge": merge})); + for (lid, r0) in others { + let _ = + app.session.execute("prop.set", json!({"layer": lid.0, "path": "transform/rotation", "value": r0 + (r - start_rot), "merge": merge})); + } } Gesture::Anchor { layer, start_anchor, start_pos, start, inv, l2p, own, anchor_only } => { // Pan Behind snaps the anchor point (to its own layer's box, other layers' features, diff --git a/crates/ui-egui/tests/ui_keyframes.rs b/crates/ui-egui/tests/ui_keyframes.rs index 3ca85f8f..a7d1d1ae 100644 --- a/crates/ui-egui/tests/ui_keyframes.rs +++ b/crates/ui-egui/tests/ui_keyframes.rs @@ -348,3 +348,53 @@ fn graph_editor_buttons_that_do_not_fit_move_to_the_more_menu() { click(&mut h, pos2(item[0] + item[2] / 2.0, item[1] + item[3] / 2.0)); assert_eq!(h.state().ui.timeline.graph_transform_box, !before, "the menu item runs the button"); } + +/// #355, #491: with three layers selected the Properties panel heads "(3) Selected Objects"; its +/// stopwatch animates Position on all three (one undo step) and scrubbing X moves all three by +/// the same amount. +#[test] +fn properties_panel_edits_every_selected_layer() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Three", "width": 320, "height": 180, "frameRate": 30, "duration": 4})).unwrap(); + let mut ids = vec![]; + for (i, c) in ["#e04020", "#20e040", "#2040e0"].iter().enumerate() { + let l = s.execute("layer.newSolid", json!({"color": c, "width": 40, "height": 40})).unwrap()["layer"].as_u64().unwrap(); + s.execute("prop.set", json!({"layer": l, "path": "transform/position", "value": [60.0 + 80.0 * i as f64, 90.0]})).unwrap(); + ids.push(l); + } + s.execute("layer.select", json!({"layers": ids})).unwrap(); + let first = s.state.selected_layers[0]; + let uid = s.active_comp().unwrap().layer(first).unwrap().props.prop("transform/position").unwrap().uid; + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.state_mut().show_panel(effectcraft_ui_egui::dock::PanelKind::Properties); + h.run_steps(4); + assert_eq!(h.state().auto.find("properties.selectedObjects").map(|e| e.label.clone()), Some("(3) Selected Objects".into())); + let pos = |h: &Harness<'_, EffectcraftApp>, l: u64| { + h.state().session.active_comp().unwrap().layer(LayerId(l)).unwrap().props.prop("transform/position").unwrap().clone() + }; + let at = |h: &Harness<'_, EffectcraftApp>, id: &str| { + let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}")); + pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0) + }; + let sw = at(&h, &format!("properties.prop.{uid}.stopwatch")); + h.input_mut().events.push(Event::PointerMoved(sw)); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: sw, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: sw, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + h.run_steps(3); + assert!(ids.iter().all(|l| pos(&h, *l).is_animated()), "every selected layer's stopwatch"); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + h.run_steps(2); + assert!(ids.iter().all(|l| !pos(&h, *l).is_animated()), "one undo step"); + // Scrub X: each layer moves by the same amount. + let before: Vec = ids.iter().map(|l| pos(&h, *l).value.components()[0]).collect(); + let x = at(&h, &format!("properties.prop.{uid}.value.0")); + drag(&mut h, x, x + egui::vec2(30.0, 0.0), Default::default()); + let after: Vec = ids.iter().map(|l| pos(&h, *l).value.components()[0]).collect(); + let d = after[0] - before[0]; + assert!(d.abs() > 1.0, "the scrub moved X: {before:?} → {after:?}"); + for (a, b) in after.iter().zip(&before) { + assert!((a - b - d).abs() < 1e-6, "same change on every layer: {before:?} → {after:?}"); + } +} diff --git a/crates/ui-egui/tests/ui_viewer.rs b/crates/ui-egui/tests/ui_viewer.rs index bada77a7..120c4db0 100644 --- a/crates/ui-egui/tests/ui_viewer.rs +++ b/crates/ui-egui/tests/ui_viewer.rs @@ -1707,6 +1707,32 @@ fn handle_drags_scale_about_the_anchor_and_follow_the_pointer() { assert!(close(scale(&h), [162.5, 162.5]), "{:?}", scale(&h)); } +/// #491: with several layers selected, a handle drag scales all of them by the same ratio, each +/// about its own anchor point, in one undo step. +#[test] +fn handle_drags_scale_every_selected_layer() { + let mut h = harness(); + let layers = h.state().session.active_comp().unwrap().layers.iter().map(|l| l.id).collect::>(); + let (box_id, plate) = (layers[0], layers[1]); + let scale = |h: &Harness<'_, EffectcraftApp>, id: LayerId| { + let l = h.state().session.active_comp().unwrap().layer(id).unwrap().clone(); + l.props.prop("transform/scale").unwrap().value.as_vec3() + }; + h.state_mut().session.execute("prop.set", json!({"layer": plate.0, "path": "transform/scale", "value": [50, 50, 100]})).unwrap(); + h.state_mut().session.execute("layer.select", json!({"layers": [box_id.0, plate.0]})).unwrap(); + h.state_mut().session.execute("view.snapping", json!({"value": false})).unwrap(); + h.run_steps(2); + let undo0 = h.state().session.history.undo.len(); + // The box's bottom-right corner (360, 220) to (400, 260): twice the box's size. + let corner = rect(&h, &format!("viewer.handle.{}.2", box_id.0)).center(); + let to = screen(&h, [400.0, 260.0]); + drag_path(&mut h, &[corner, to], Default::default()); + let (b, p) = (scale(&h, box_id), scale(&h, plate)); + assert!((b[0] - 200.0).abs() < 1.0 && (b[1] - 200.0).abs() < 1.0, "{b:?}"); + assert!((p[0] - 100.0).abs() < 1.0 && (p[1] - 100.0).abs() < 1.0, "the other layer doubles too: {p:?}"); + assert_eq!(h.state().session.history.undo.len(), undo0 + 1, "one undo step"); +} + /// #252: with snapping on, a dragged handle snaps to the comp's corners and edges (and other /// layers'), so a layer scales exactly to the comp; Shift keeps the proportions and still lands /// on the comp's size. From 82788b7a68d6d8404512c3ada07cb56c39390b72 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 21:28:25 +0000 Subject: [PATCH 086/107] Multi-layer edits: only a real drag offsets the other layers; the Timeline stopwatch stays per property (#355, #491 follow-up) A typed value committed by clicking elsewhere could be applied as an offset, because dragged_id() is set on the press that commits it; a scrub now needs a decided drag. In the Timeline, clicking a stopwatch or the keyframe button acts on that layer's property only, as in After Effects (the Properties panel and keyboard shortcuts act on every selected layer). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f --- crates/ui-egui/src/panels/effect_controls.rs | 2 +- crates/ui-egui/src/panels/properties.rs | 2 +- crates/ui-egui/src/panels/timeline.rs | 9 ++++----- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index d5edc667..b4f40c1f 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -274,7 +274,7 @@ fn prop_row( app.auto.add(&format!("effectControls.prop.{uid}.name"), name_clip, &prop.name); let merge = format!("ec-{uid}"); // With several layers selected the edit applies to the same effect property of each (#491). - let scrub = ui.ctx().dragged_id().is_some(); + let scrub = ui.ctx().dragged_id().is_some() && ui.input(|i| i.pointer.is_decidedly_dragging()); let set = |actions: &mut Actions, v: serde_json::Value| { actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": uid, "value": v, "merge": merge, "selected": true, "offset": scrub}))) }; diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index c10d29bb..e565034e 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -247,7 +247,7 @@ fn transform_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, let merge = format!("props-{uid}"); // With several layers selected the edit applies to all: a scrub by the same change, a typed // value as it is. - let scrub = ui.ctx().dragged_id().is_some(); + let scrub = ui.ctx().dragged_id().is_some() && ui.input(|i| i.pointer.is_decidedly_dragging()); let set = |actions: &mut Actions, v: serde_json::Value| { actions.push(("prop.set".into(), json!({"layer": lid, "prop": uid, "value": v, "merge": merge, "selected": true, "offset": scrub}))) }; diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index fb074bcc..41402cce 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -2403,7 +2403,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { ui_actions.push(UiAct::SetTime(layer.comp_time(k.time))); } if ui.interact(mid, egui::Id::new(("kmid", uid)), Sense::click()).clicked() { - actions.push(("prop.toggleKey".into(), json!({"layer": layer.id.0, "prop": uid, "selected": true}))); + actions.push(("prop.toggleKey".into(), json!({"layer": layer.id.0, "prop": uid}))); } app.auto.add(&format!("timeline.prop.{uid}.addKey"), mid, "Add or remove keyframe"); } @@ -2426,11 +2426,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { actions.push(("prop.setExpression".into(), json!({"layer": layer.id.0, "prop": uid}))); } } else if let Some((value, merge)) = switch_gesture(ui, &resp, r, "stopwatch", prop.is_animated()) { - actions - .push(("prop.toggleAnimation".into(), json!({"layer": layer.id.0, "prop": uid, "value": value, "merge": merge, "selected": true}))); + actions.push(("prop.toggleAnimation".into(), json!({"layer": layer.id.0, "prop": uid, "value": value, "merge": merge}))); } else if resp.clicked() && !resp.clicked_by(egui::PointerButton::Primary) { // Keyboard or accessibility activation. - actions.push(("prop.toggleAnimation".into(), json!({"layer": layer.id.0, "prop": uid, "selected": true}))); + actions.push(("prop.toggleAnimation".into(), json!({"layer": layer.id.0, "prop": uid}))); } } let name_x = indent + 12.0; @@ -3513,7 +3512,7 @@ fn value_editor( let merge = format!("scrub-{uid}"); // With several layers selected the edit applies to all of them (#491): a scrub by the same // change, a typed value as it is. - let scrub = ui.ctx().dragged_id().is_some(); + let scrub = ui.ctx().dragged_id().is_some() && ui.input(|i| i.pointer.is_decidedly_dragging()); let set = |actions: &mut Vec<(String, serde_json::Value)>, v: serde_json::Value| { actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": uid, "value": v, "merge": merge, "selected": true, "offset": scrub}))) }; From ef96419bec78d541b8699def1fae219178fd7e86 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 21:29:46 +0000 Subject: [PATCH 087/107] Appearance: the Light theme repaints Effect Controls headers, Timeline wells and the expression field (#644) Effect Controls painted effect headers with fixed dark fills (#2a2a2a, #2f3a52 when active) under the theme's text colour, so on Light the effect names were dark on dark (1.1:1). The Timeline kept the selected layer's time-graph row, the A/V and switch wells, the time navigator, the work area bar and the expression field on fixed dark fills too. Those surfaces are now theme tokens (fx_header, fx_header_active, tl_graph_selected, switch_well, tl_nav_bar, tl_work_area_bar, expr_bg, expr_text) with the dark values unchanged and light ones for Light. The expression editor's syntax colours come from the theme as well: on the light field a darker set keeps every category, enabled/disabled expressions, matched brackets and line numbers at 4.5:1 or better (contrast test, with a negative control showing the dark set fails there). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f --- crates/ui-egui/src/panels/effect_controls.rs | 2 +- crates/ui-egui/src/panels/expr_editor.rs | 133 ++++++++++++++++--- crates/ui-egui/src/panels/timeline.rs | 29 ++-- crates/ui-egui/src/theme.rs | 36 +++++ 4 files changed, 161 insertions(+), 39 deletions(-) diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index fb388845..8df4447b 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -1102,7 +1102,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let open = !app.ui.fx_closed.contains(&g.uid); let selected = app.session.state.selected_props.iter().any(|(l, u)| *l == layer.id && *u == g.uid); let active = effect_active(g, layer.id, &app.session.state.selected_props); - bp.rect_filled(r, 0.0, if active { Color32::from_rgb(0x2f, 0x3a, 0x52) } else { Color32::from_rgb(0x2a, 0x2a, 0x2a) }); + bp.rect_filled(r, 0.0, if active { t.fx_header_active } else { t.fx_header }); let fxr = Rect::from_center_size(pos2(r.min.x + 14.0, r.center().y), vec2(16.0, 16.0)); if widgets::icon_toggle(ui, fxr, Icon::Fx, g.enabled, &t, egui::Id::new(("ec-fx", g.uid)), Sense::click()).clicked() { actions.push(("effect.toggle".into(), json!({"layer": layer.id.0, "effect": g.uid}))); diff --git a/crates/ui-egui/src/panels/expr_editor.rs b/crates/ui-egui/src/panels/expr_editor.rs index 1efd6876..ffb61738 100644 --- a/crates/ui-egui/src/panels/expr_editor.rs +++ b/crates/ui-egui/src/panels/expr_editor.rs @@ -9,13 +9,42 @@ use egui::{Color32, FontId, Rect, Stroke, pos2, vec2}; use crate::theme::Tokens; -/// Syntax colours. -const KEYWORD: Color32 = Color32::from_rgb(0xc6, 0x8a, 0xe6); -const NUMBER: Color32 = Color32::from_rgb(0xe0, 0xb4, 0x6c); -const STRING: Color32 = Color32::from_rgb(0x9c, 0xd0, 0x7a); -const COMMENT: Color32 = Color32::from_rgb(0x80, 0x88, 0x90); -const API: Color32 = Color32::from_rgb(0x6c, 0xb8, 0xf0); -const BRACKET_BG: Color32 = Color32::from_rgb(0x4a, 0x55, 0x6a); +/// Syntax colours, and the matched brackets' background. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Syntax { + pub keyword: Color32, + pub number: Color32, + pub string: Color32, + pub comment: Color32, + pub api: Color32, + pub bracket_bg: Color32, +} + +impl Syntax { + /// The colours for the theme: on the Light theme's light expression field, darker ones that + /// stay readable (#644). + pub fn for_tokens(t: &Tokens) -> Syntax { + if t.kind.is_light() { + Syntax { + keyword: Color32::from_rgb(0x7a, 0x2f, 0xa8), + number: Color32::from_rgb(0x8a, 0x4b, 0x00), + string: Color32::from_rgb(0x2c, 0x6a, 0x10), + comment: Color32::from_rgb(0x52, 0x5a, 0x62), + api: Color32::from_rgb(0x0a, 0x57, 0x94), + bracket_bg: Color32::from_rgb(0xd0, 0xdc, 0xf0), + } + } else { + Syntax { + keyword: Color32::from_rgb(0xc6, 0x8a, 0xe6), + number: Color32::from_rgb(0xe0, 0xb4, 0x6c), + string: Color32::from_rgb(0x9c, 0xd0, 0x7a), + comment: Color32::from_rgb(0x80, 0x88, 0x90), + api: Color32::from_rgb(0x6c, 0xb8, 0xf0), + bracket_bg: Color32::from_rgb(0x4a, 0x55, 0x6a), + } + } + } +} const KEYWORDS: &[&str] = &[ "var", @@ -184,7 +213,7 @@ pub fn completions(text: &str, cursor: usize) -> (String, Vec<&'static str>) { } /// Colour runs for JavaScript: (byte start, byte end, colour). -pub fn highlight(text: &str, base: Color32) -> Vec<(usize, usize, Color32)> { +pub fn highlight(text: &str, base: Color32, sx: &Syntax) -> Vec<(usize, usize, Color32)> { let b = text.as_bytes(); let mut out = vec![]; let mut i = 0; @@ -195,35 +224,35 @@ pub fn highlight(text: &str, base: Color32) -> Vec<(usize, usize, Color32)> { while i < b.len() && b[i] != b'\n' { i += 1; } - COMMENT + sx.comment } else if c == b'/' && b.get(i + 1) == Some(&b'*') { i += 2; while i < b.len() && !(b[i] == b'*' && b.get(i + 1) == Some(&b'/')) { i += 1; } i = (i + 2).min(b.len()); - COMMENT + sx.comment } else if c == b'"' || c == b'\'' || c == b'`' { i += 1; while i < b.len() && b[i] != c { i += if b[i] == b'\\' { 2 } else { 1 }; } i = (i + 1).min(b.len()); - STRING + sx.string } else if c.is_ascii_digit() { while i < b.len() && (b[i].is_ascii_alphanumeric() || b[i] == b'.') { i += 1; } - NUMBER + sx.number } else if c.is_ascii_alphabetic() || c == b'_' || c == b'$' { while i < b.len() && (b[i].is_ascii_alphanumeric() || b[i] == b'_' || b[i] == b'$') { i += 1; } let w = &text[start..i]; if KEYWORDS.contains(&w) { - KEYWORD + sx.keyword } else if COMPLETIONS.contains(&w) { - API + sx.api } else { base } @@ -246,9 +275,9 @@ fn byte_of(text: &str, ci: usize) -> usize { } /// The layout job of the editor text: syntax colours, the matched brackets' background, wrap. -fn layout(text: &str, font: FontId, base: Color32, p: &Scripting, brackets: Option<(usize, usize)>, wrap: f32) -> LayoutJob { +fn layout(text: &str, font: FontId, base: Color32, sx: &Syntax, p: &Scripting, brackets: Option<(usize, usize)>, wrap: f32) -> LayoutJob { let mut job = LayoutJob::default(); - let runs = if p.syntax_highlighting { highlight(text, base) } else { vec![(0, text.len(), base)] }; + let runs = if p.syntax_highlighting { highlight(text, base, sx) } else { vec![(0, text.len(), base)] }; let marks: Vec = brackets.filter(|_| p.bracket_matching).map(|(a, b)| vec![byte_of(text, a), byte_of(text, b)]).unwrap_or_default(); for (s, e, col) in runs { // Split runs at the marked brackets. @@ -263,7 +292,7 @@ fn layout(text: &str, font: FontId, base: Color32, p: &Scripting, brackets: Opti cuts.sort_unstable(); cuts.dedup(); for w in cuts.windows(2) { - let bg = if marks.contains(&w[0]) { BRACKET_BG } else { Color32::TRANSPARENT }; + let bg = if marks.contains(&w[0]) { sx.bracket_bg } else { Color32::TRANSPARENT }; job.append(&text[w[0]..w[1]], 0.0, TextFormat { font_id: font.clone(), color: col, background: bg, ..Default::default() }); } } @@ -281,8 +310,9 @@ pub fn editor(ui: &mut egui::Ui, id: egui::Id, buf: &mut String, rect: Rect, p: let cursor = egui::TextEdit::load_state(ui.ctx(), id).and_then(|s| s.cursor.char_range()).map(|r| r.primary.index.0); let brackets = cursor.and_then(|c| matching_bracket(buf, c)); let pc = p.clone(); + let sx = Syntax::for_tokens(t); let mut layouter = move |ui: &egui::Ui, text: &dyn egui::TextBuffer, wrap: f32| { - let job = layout(text.as_str(), font.clone(), color, &pc, brackets, wrap); + let job = layout(text.as_str(), font.clone(), color, &sx, &pc, brackets, wrap); ui.fonts_mut(|f| f.layout_job(job)) }; let mut child = ui.new_child(egui::UiBuilder::new().max_rect(er)); @@ -392,3 +422,70 @@ pub fn error_banner(app: &mut crate::EffectcraftApp, ui: &egui::Ui, area: Rect) } } } + +#[cfg(test)] +mod contrast_tests { + use super::*; + use crate::theme::ThemeKind; + + /// WCAG contrast ratio of two opaque sRGB colours. + fn contrast(a: Color32, b: Color32) -> f32 { + let lum = |c: Color32| { + let f = |v: u8| { + let v = v as f32 / 255.0; + if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) } + }; + 0.2126 * f(c.r()) + 0.7152 * f(c.g()) + 0.0722 * f(c.b()) + }; + let (x, y) = (lum(a), lum(b)); + (x.max(y) + 0.05) / (x.min(y) + 0.05) + } + + /// #644: on every theme, effect names stay readable on Effect Controls' headers, and the + /// expression field's text (every syntax colour, enabled and disabled expressions, matched + /// brackets, line numbers) on its background; the switch icons show on their wells. + #[test] + fn custom_painted_surfaces_keep_text_readable_on_every_theme() { + for kind in [ThemeKind::Dark, ThemeKind::Darker, ThemeKind::Light] { + let t = Tokens::for_kind(kind); + let sx = Syntax::for_tokens(&t); + for (what, bg) in [("effect header", t.fx_header), ("active effect header", t.fx_header_active)] { + assert!(contrast(t.text, bg) >= 4.5, "{kind:?}: effect name on {what}: {:.2}", contrast(t.text, bg)); + } + assert!(contrast(t.icon, t.switch_well) >= 3.0, "{kind:?}: switch icons on their wells"); + if !kind.is_light() { + continue; + } + let fg = [ + ("keyword", sx.keyword), + ("number", sx.number), + ("string", sx.string), + ("comment", sx.comment), + ("api", sx.api), + ("enabled expression", t.expr_text), + ("disabled expression", t.text_dim), + ("line numbers", t.text_faint), + ]; + for (what, c) in fg { + assert!(contrast(c, t.expr_bg) >= 4.5, "{kind:?}: {what} on the expression field: {:.2}", contrast(c, t.expr_bg)); + assert!(contrast(c, sx.bracket_bg) >= 4.0, "{kind:?}: {what} on a matched bracket: {:.2}", contrast(c, sx.bracket_bg)); + } + // The dark theme's syntax colours would not do on the light field. + let dark = Syntax::for_tokens(&Tokens::for_kind(ThemeKind::Dark)); + assert!([dark.keyword, dark.number, dark.string, dark.api].iter().all(|c| contrast(*c, t.expr_bg) < 4.5)); + } + } + + /// Syntax highlighting uses the theme's colours. + #[test] + fn highlight_uses_the_theme_palette() { + let t = Tokens::for_kind(ThemeKind::Light); + let sx = Syntax::for_tokens(&t); + let text = "var amount = 12; // note"; + let runs = highlight(text, t.expr_text, &sx); + let col = |s: &str| runs.iter().find(|(a, e, _)| text.get(*a..*e) == Some(s)).map(|r| r.2); + assert_eq!(col("var"), Some(sx.keyword)); + assert_eq!(col("12"), Some(sx.number)); + assert_eq!(col("// note"), Some(sx.comment)); + } +} diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index b28c0d23..a5305df9 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -58,11 +58,6 @@ fn classic_geometry_row(comp: &Comp, l: &Layer) -> bool { comp.renderer == effectcraft_engine::project::Renderer::Classic3D && l.is_3d() && matches!(l.source, LayerSource::Text | LayerSource::Shape) } -/// Time navigator's visible-span bar and the work area bar. -const NAV_BAR: Color32 = Color32::from_rgb(0x55, 0x55, 0x55); -const WORK_AREA_BAR: Color32 = Color32::from_rgb(0x5c, 0x5c, 0x5c); -/// Fill of the timeline's switch / A/V boxes (dark wells, as in After Effects). -const SWITCH_BOX: Color32 = Color32::from_rgb(0x19, 0x19, 0x19); /// Background of the selected layer's name cell. const SELECTED_NAME_BG: Color32 = Color32::from_rgb(0xa6, 0xa6, 0xa6); @@ -1335,7 +1330,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let vis1 = (tm.t(graph_x1) / comp.duration.seconds()) as f32; let nav_vis = Rect::from_min_max(pos2(nav.min.x + nav.width() * vis0.clamp(0.0, 1.0), nav.min.y), pos2(nav.min.x + nav.width() * vis1.clamp(0.0, 1.0), nav.max.y)); - p.rect_filled(nav_vis, 0.0, NAV_BAR); + p.rect_filled(nav_vis, 0.0, t.tl_nav_bar); for x in [nav_vis.min.x, nav_vis.max.x] { p.rect_filled(Rect::from_min_max(pos2(x - 3.0, nav.min.y - 1.0), pos2(x + 3.0, nav.max.y + 1.0)), 3.0, t.accent); } @@ -1375,7 +1370,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let wa_row = Rect::from_min_max(pos2(graph_x0, top + header_h), pos2(rect.max.x, top + header_h + colhdr_h)); let (wa0, wa1) = (comp.work_area.0.seconds(), comp.work_area.1.seconds()); let wa = Rect::from_min_max(pos2(tm.x(wa0), wa_row.min.y + 3.0), pos2(tm.x(wa1), wa_row.max.y - 6.0)); - p.with_clip_rect(wa_row).rect_filled(wa, 0.0, WORK_AREA_BAR); + p.with_clip_rect(wa_row).rect_filled(wa, 0.0, t.tl_work_area_bar); let shift = ui.input(|i| i.modifiers.shift); let bar = wa.intersect(wa_row); let bresp = ui.interact(bar, egui::Id::new("wa-bar"), Sense::click_and_drag()); @@ -1736,7 +1731,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { // (outline and time graph alike), not as alternating stripes. let _ = ri; lp.rect_filled(left, 0.0, if is_sel { t.row_selected } else { t.row_alt }); - gp.rect_filled(Rect::from_min_max(pos2(graph_x0, r.min.y), r.max), 0.0, if is_sel { Color32::from_rgb(0x2a, 0x2a, 0x2a) } else { t.tl_bg }); + gp.rect_filled(Rect::from_min_max(pos2(graph_x0, r.min.y), r.max), 0.0, if is_sel { t.tl_graph_selected } else { t.tl_bg }); lp.line_segment([pos2(rect.min.x, r.max.y - 0.5), pos2(graph_x0, r.max.y - 0.5)], Stroke::new(1.0, t.app_bg)); gp.line_segment([pos2(graph_x0, r.max.y - 0.5), pos2(r.max.x, r.max.y - 0.5)], Stroke::new(1.0, t.app_bg)); // A/V features. @@ -1752,7 +1747,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if !applicable || !vis.av { continue; } - lp.rect_filled(br.shrink(0.5), 1.0, SWITCH_BOX); + lp.rect_filled(br.shrink(0.5), 1.0, t.switch_well); layer_switch(ui, &t, r, br, icon, on, egui::Id::new(("av", layer.id.0, i)), layer.id.0, name, &mut actions); app.auto.add(&format!("timeline.layer.{}.{name}", layer.id.0), br, name); } @@ -1878,7 +1873,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { continue; } let br = Rect::from_center_size(pos2(cw.switches + SW * i as f32 + SW / 2.0, cy), vec2(16.0, 16.0)); - lp.rect_filled(br.shrink(1.0), 1.0, SWITCH_BOX); + lp.rect_filled(br.shrink(1.0), 1.0, t.switch_well); layer_switch(ui, &t, r, br, icon, on, egui::Id::new(("sw", layer.id.0, i)), layer.id.0, name, &mut actions); app.auto.add(&format!("timeline.layer.{}.switch.{name}", layer.id.0), br, name); } @@ -2233,7 +2228,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let en_r = Rect::from_center_size(pos2(cw.switches + 10.0, cy), vec2(16.0, 16.0)); let en = ui.interact(en_r, egui::Id::new(("expr-en", uid)), Sense::click()).on_hover_text(crate::i18n::tr("Enable Expression")); lp.rect_stroke(en_r.shrink(1.0), 2.0, Stroke::new(1.0, t.separator), StrokeKind::Inside); - let expr_col = Color32::from_rgb(0xe8, 0x7c, 0x5c); + let expr_col = t.expr_text; lp.text( en_r.center(), Align2::CENTER_CENTER, @@ -2318,7 +2313,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } // The editor in the time-graph area. ui.set_clip_rect(right_clip); - gp.rect_filled(Rect::from_min_max(pos2(graph_x0, r.min.y), r.max), 0.0, Color32::from_rgb(0x1a, 0x1a, 0x1a)); + gp.rect_filled(Rect::from_min_max(pos2(graph_x0, r.min.y), r.max), 0.0, t.expr_bg); let er = Rect::from_min_max(pos2(graph_x0 + 8.0, r.min.y + 3.0), pos2(rect.max.x - 14.0, r.max.y - 3.0)); let mut buf: String = ctx.data(|d| d.get_temp(buf_id)).unwrap_or_else(|| ex.text.clone()); if let Some(after) = ctx.data_mut(|d| d.remove_temp::(expr_refocus_id(*uid))) { @@ -2437,13 +2432,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let is_tr_rot = layer.transform().and_then(|t| t.get("rotation")).is_some_and(|r| r.uid == prop.uid); let base = if is_tr_rot && prop.name == "Rotation" && layer.is_3d() { "Z Rotation".to_string() } else { prop.name.clone() }; let pname = if prop.has_expression() { format!("{base} =") } else { base }; - lp.text( - pos2(name_x, cy), - Align2::LEFT_CENTER, - &pname, - Tokens::ui(12.0), - if prop.has_expression() { Color32::from_rgb(0xe8, 0x7c, 0x5c) } else { t.text }, - ); + lp.text(pos2(name_x, cy), Align2::LEFT_CENTER, &pname, Tokens::ui(12.0), if prop.has_expression() { t.expr_text } else { t.text }); let name_rect = Rect::from_min_max(pos2(name_x, r.min.y), pos2(cw.switches - 2.0, r.max.y)); let name_resp = ui.interact(name_rect, egui::Id::new(("pname", uid)), Sense::click_and_drag()); app.auto.add(&format!("timeline.prop.{uid}.name"), name_rect, &prop.name); @@ -2488,7 +2477,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { Align2::LEFT_CENTER, format!("= {}", e.text.lines().next().unwrap_or("")), Tokens::mono(11.0), - Color32::from_rgb(0xe8, 0x7c, 0x5c), + t.expr_text, ); } // Keyframes. diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index febd1991..953bf66d 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -81,6 +81,20 @@ pub struct Tokens { pub timecode: Color32, pub danger: Color32, pub warning: Color32, + /// Effect Controls effect headers, and the header of the effect being worked on (it or one + /// of its properties selected). + pub fx_header: Color32, + pub fx_header_active: Color32, + /// Timeline: a selected layer's row in the time graph, the boxes behind the A/V and layer + /// switches, the time navigator and the work area bar. + pub tl_graph_selected: Color32, + pub switch_well: Color32, + pub tl_nav_bar: Color32, + pub tl_work_area_bar: Color32, + /// The Timeline's expression field and the colour of enabled expressions (their text and + /// the names of properties that have one). + pub expr_bg: Color32, + pub expr_text: Color32, /// Label colours (`Label::ALL` order), from Settings ▸ Labels. pub labels: [Color32; 17], pub radius: f32, @@ -153,6 +167,14 @@ impl Tokens { timecode: Color32::from_rgb(0x3d, 0x8f, 0xf5), danger: Color32::from_rgb(0xe0, 0x4a, 0x3c), warning: Color32::from_rgb(0xe8, 0x9a, 0x2c), + fx_header: Color32::from_rgb(0x2a, 0x2a, 0x2a), + fx_header_active: Color32::from_rgb(0x2f, 0x3a, 0x52), + tl_graph_selected: Color32::from_rgb(0x2a, 0x2a, 0x2a), + switch_well: Color32::from_rgb(0x19, 0x19, 0x19), + tl_nav_bar: Color32::from_rgb(0x55, 0x55, 0x55), + tl_work_area_bar: Color32::from_rgb(0x5c, 0x5c, 0x5c), + expr_bg: Color32::from_rgb(0x1a, 0x1a, 0x1a), + expr_text: Color32::from_rgb(0xe8, 0x7c, 0x5c), labels: default_labels(), radius: 6.0, radius_sm: 3.0, @@ -202,6 +224,16 @@ impl Tokens { pasteboard: Color32::from_rgb(0xa8, 0xa8, 0xa8), // Keyframes have no outline: dark enough to stand out on the light time graph. keyframe: Color32::from_rgb(0x6c, 0x6c, 0x6c), + // Custom-painted surfaces in the Light palette, so dark text stays readable on + // them (#644). + fx_header: Color32::from_rgb(0xd8, 0xd8, 0xd8), + fx_header_active: Color32::from_rgb(0xb0, 0xcf, 0xec), + tl_graph_selected: Color32::from_rgb(0xc2, 0xc2, 0xc2), + switch_well: Color32::from_rgb(0xf2, 0xf2, 0xf2), + tl_nav_bar: Color32::from_rgb(0x9a, 0x9a, 0x9a), + tl_work_area_bar: Color32::from_rgb(0x94, 0x94, 0x94), + expr_bg: Color32::from_rgb(0xf2, 0xf2, 0xf2), + expr_text: Color32::from_rgb(0xa3, 0x34, 0x17), ..dark }, } @@ -268,6 +300,10 @@ impl Tokens { &mut self.tl_ruler_bg, &mut self.pasteboard, &mut self.work_area, + &mut self.fx_header, + &mut self.tl_graph_selected, + &mut self.switch_well, + &mut self.expr_bg, ] { adj(c); } From 9495810ff50352f27a70d0a2cf0f96f95ac93d00 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 21:48:52 +0000 Subject: [PATCH 088/107] Tests: the near-plane check tests for the flip, and loopOut compares within float tolerance (#572, #456 follow-up) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f --- crates/expr/src/tests.rs | 3 ++- crates/ui-egui/src/panels/viewer.rs | 6 ++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/crates/expr/src/tests.rs b/crates/expr/src/tests.rs index 3d5acb2d..133d32b5 100644 --- a/crates/expr/src/tests.rs +++ b/crates/expr/src/tests.rs @@ -588,7 +588,8 @@ fn loop_on_two_dimensional_position() { let mut f = fx(); f.layer_mut(f.a).props.prop_mut(POS).unwrap().keys = vec![Keyframe::new(secs(0.0), Value::Vec3([0.0, 0.0, 0.0])), Keyframe::new(secs(1.0), Value::Vec3([100.0, 50.0, 0.0]))]; - assert_eq!(f.pos("loopOut('offset')", 1.5), [150.0, 75.0, 0.0]); + let p = f.pos("loopOut('offset')", 1.5); + assert!(p.iter().zip([150.0, 75.0, 0.0]).all(|(a, b)| (a - b).abs() < 1e-9), "{p:?}"); } #[test] diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index 2c5d47f3..a532cdeb 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -2358,8 +2358,10 @@ mod tests { assert_eq!(front.len(), 4); let crossing = project_clipped(&m, &box_corners([-50.0, -150.0, 50.0, 50.0])); assert_eq!(crossing.len(), 4, "{crossing:?}"); - // Every point is in front: none flipped to the other side (y < 0 here means behind). - assert!(crossing.iter().all(|p| p[0].is_finite() && p[1].abs() < 1e4 && p[0].abs() < 1e4), "{crossing:?}"); + // Nothing flipped through infinity: a corner behind the camera (y = −150, w = −50) would + // land at y = +300 if it were projected as is; clipped at the near plane it runs off far + // above (negative y) instead, and the front corners stay where they were. + assert!(crossing.iter().all(|p| p[0].is_finite() && p[1].is_finite() && p[1] < 34.0), "{crossing:?}"); assert!(project_clipped(&m, &box_corners([-50.0, -400.0, 50.0, -200.0])).is_empty(), "wholly behind"); } From bd906d3ee9a3df6019e69d5d5b2bb0f6a8ab1551 Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Sun, 11 Oct 2026 04:52:24 +0700 Subject: [PATCH 089/107] fix(timeline): create composition when footage is dropped into an empty timeline (#649) --- crates/ui-egui/src/panels/timeline.rs | 28 +++++++++++++++++ crates/ui-egui/tests/ui_drag_drop.rs | 44 +++++++++++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 086e46dc..686420f2 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -1212,6 +1212,34 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let ctx = ui.ctx().clone(); let Some(cid) = app.session.active_comp_id() else { ui.painter().text(rect.center(), Align2::CENTER_CENTER, "(no composition)", Tokens::ui(12.0), t.text_faint); + // Dropping footage into an empty Timeline should create its first composition, + // just like dropping onto the Project panel's New Composition button (#649). + if let Some(crate::panels::DragPayload::Item(dragged)) = egui::DragAndDrop::payload::(&ctx).as_deref() + && ui.rect_contains_pointer(rect) + { + let items: Vec = crate::panels::project_drop_items(app, *dragged) + .into_iter() + .map(ItemId) + .filter(|id| { + app.session.project.item(*id).is_some_and(|item| matches!( + &item.kind, + effectcraft_engine::project::ItemKind::Footage(_) + | effectcraft_engine::project::ItemKind::Solid(_) + )) + }) + .collect(); + if !items.is_empty() { + ui.painter().rect_stroke(rect.shrink(2.0), 0.0, Stroke::new(2.0, t.accent), StrokeKind::Inside); + app.auto.add("timeline.newCompDrop", rect, "Drop footage to create a composition"); + if ctx.input(|i| i.pointer.any_released()) { + app.session.state.project_selection = items; + if let Err(e) = crate::menus::invoke(app, &ctx, "file.newCompFromSelection", json!({})) { + app.ui.status = e; + } + egui::DragAndDrop::clear_payload(&ctx); + } + } + } return; }; let Some(comp) = app.session.project.comp_arc(cid) else { return }; diff --git a/crates/ui-egui/tests/ui_drag_drop.rs b/crates/ui-egui/tests/ui_drag_drop.rs index d9e04ec3..a7254235 100644 --- a/crates/ui-egui/tests/ui_drag_drop.rs +++ b/crates/ui-egui/tests/ui_drag_drop.rs @@ -210,6 +210,50 @@ fn project_items_dropped_on_new_comp_make_a_composition() { assert_eq!(comps(&h), before + 1, "nothing made yet"); } +/// #649: a Project footage item dropped into an empty Timeline makes a composition +/// with that item's dimensions, frame rate, duration, and a source layer. +#[test] +fn project_footage_dropped_on_empty_timeline_creates_first_comp() { + let mut s = Session::default(); + let footage = s.execute( + "file.importPlaceholder", + json!({"name": "Landscape.png", "width": 640, "height": 360, "frameRate": 24, "duration": 5}), + ).unwrap()["item"].as_u64().unwrap(); + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.run_steps(3); + assert!(h.state().session.active_comp().is_none()); + let from = rect(&h, &format!("project.item.{footage}.name")).center(); + let to = rect(&h, "panel.Timeline").center(); + drag(&mut h, from, to, Modifiers::NONE); + let comp = h.state().session.active_comp().expect("created a composition"); + assert_eq!((comp.width, comp.height), (640, 360)); + assert_eq!(comp.frame_rate, effectcraft_engine::time::FrameRate::from_f64(24.0)); + assert_eq!(comp.duration.seconds(), 5.0); + assert_eq!(comp.layers.len(), 1); + assert_eq!(comp.layers[0].name, "Landscape.png"); + assert!(h.state().dialog.is_none()); +} + +/// Multiple source items use the existing New Comp from Selection dialog, rather than +/// quietly creating only the dragged item or changing the composition settings. +#[test] +fn multiple_selected_footage_dropped_on_empty_timeline_asks_how_to_create_comps() { + let mut s = Session::default(); + let first = s.execute("file.importPlaceholder", json!({"name": "First.png", "width": 160, "height": 90})).unwrap()["item"].as_u64().unwrap(); + let second = s.execute("file.importPlaceholder", json!({"name": "Second.png", "width": 300, "height": 200})).unwrap()["item"].as_u64().unwrap(); + s.state.project_selection = vec![ + effectcraft_engine::project::ItemId(first), + effectcraft_engine::project::ItemId(second), + ]; + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.run_steps(3); + let from = rect(&h, &format!("project.item.{first}.name")).center(); + drag(&mut h, from, rect(&h, "panel.Timeline").center(), Modifiers::NONE); + assert_eq!(h.state().dialog, Some(effectcraft_ui_egui::Dialog::Form)); + assert!(h.state().auto.find("form.field.single").is_some()); + assert!(h.state().session.active_comp().is_none(), "no composition until the user chooses"); +} + /// A file dropped on the window (egui hands it over as a dropped file). #[derive(Debug)] struct Dropped(std::path::PathBuf); From a9f4d92d5a333149bb36f2c8f3e0d71ab089c40a Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Sun, 11 Oct 2026 04:55:57 +0700 Subject: [PATCH 090/107] test(timeline): avoid overlapping harness borrows in multi-drop regression --- crates/ui-egui/tests/ui_drag_drop.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/tests/ui_drag_drop.rs b/crates/ui-egui/tests/ui_drag_drop.rs index a7254235..fc6a9a69 100644 --- a/crates/ui-egui/tests/ui_drag_drop.rs +++ b/crates/ui-egui/tests/ui_drag_drop.rs @@ -248,7 +248,8 @@ fn multiple_selected_footage_dropped_on_empty_timeline_asks_how_to_create_comps( let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); h.run_steps(3); let from = rect(&h, &format!("project.item.{first}.name")).center(); - drag(&mut h, from, rect(&h, "panel.Timeline").center(), Modifiers::NONE); + let to = rect(&h, "panel.Timeline").center(); + drag(&mut h, from, to, Modifiers::NONE); assert_eq!(h.state().dialog, Some(effectcraft_ui_egui::Dialog::Form)); assert!(h.state().auto.find("form.field.single").is_some()); assert!(h.state().session.active_comp().is_none(), "no composition until the user chooses"); From 2925d9605a9480a8e3db9c9c0a55aa1365c88f84 Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Sun, 11 Oct 2026 04:57:32 +0700 Subject: [PATCH 091/107] fix(timeline): ignore non-media data items dropped onto empty timeline --- crates/ui-egui/src/panels/timeline.rs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 686420f2..d5050e16 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -1221,11 +1221,13 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { .into_iter() .map(ItemId) .filter(|id| { - app.session.project.item(*id).is_some_and(|item| matches!( - &item.kind, - effectcraft_engine::project::ItemKind::Footage(_) - | effectcraft_engine::project::ItemKind::Solid(_) - )) + app.session.project.item(*id).is_some_and(|item| match &item.kind { + effectcraft_engine::project::ItemKind::Footage(footage) => { + footage.kind != effectcraft_engine::project::FootageKind::Data + } + effectcraft_engine::project::ItemKind::Solid(_) => true, + _ => false, + }) }) .collect(); if !items.is_empty() { From b2d8abdd1cffb1f5e3c80716ff79dcad8064f07c Mon Sep 17 00:00:00 2001 From: Johnstocker Date: Sat, 10 Oct 2026 23:33:49 +0700 Subject: [PATCH 092/107] fonts: extra font folders from EFFECTCRAFT_FONT_DIRS A tool can hand EffectCraft its own font library (a path list) without installing the fonts system-wide; the folders are scanned like the system ones. Co-Authored-By: Claude Opus 5.5 --- crates/text/src/fonts.rs | 15 +++++++++++++++ docs/agents.md | 2 ++ 2 files changed, 17 insertions(+) diff --git a/crates/text/src/fonts.rs b/crates/text/src/fonts.rs index 2a2c08e8..cb2b2ad2 100644 --- a/crates/text/src/fonts.rs +++ b/crates/text/src/fonts.rs @@ -94,10 +94,17 @@ impl DirectorySource { dirs.push(h.join(".fonts")); } } + dirs.extend(extra_dirs(std::env::var_os("EFFECTCRAFT_FONT_DIRS").as_deref())); Self { dirs } } } +/// Extra font folders from `EFFECTCRAFT_FONT_DIRS` (a path list, `:`-separated, `;` on Windows), so a +/// tool can hand EffectCraft its own font library without installing the fonts system-wide. +fn extra_dirs(var: Option<&std::ffi::OsStr>) -> Vec { + var.map(|v| std::env::split_paths(v).filter(|p| !p.as_os_str().is_empty()).collect()).unwrap_or_default() +} + fn walk(dir: &std::path::Path, depth: u32, out: &mut Vec) { let Ok(rd) = std::fs::read_dir(dir) else { return }; for e in rd.flatten() { @@ -824,6 +831,14 @@ fn system_fallback(c: char, style: &str) -> Option { mod tests { use super::*; + #[test] + fn extra_font_dirs_come_from_a_path_list() { + assert!(extra_dirs(None).is_empty()); + let v = std::env::join_paths(["/a/fonts", "/b/more"]).unwrap_or_default(); + assert_eq!(extra_dirs(Some(&v)), vec![PathBuf::from("/a/fonts"), PathBuf::from("/b/more")]); + assert!(extra_dirs(Some(std::ffi::OsStr::new(""))).is_empty()); + } + #[test] fn bundled_families_and_resolution() { let fams = families(); diff --git a/docs/agents.md b/docs/agents.md index c631bc05..6a3b9e85 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -495,6 +495,8 @@ styles, origin (`bundled`, `system`, `user`) and own-language name (`{"query":"g by name; `{"rescan":true}` first picks up fonts installed since launch). Pass a listed `family` and one of its `styles` to `layer.setText {"font", "style"}`; `text.fontFeatures` says which OpenType options a font draws with its own glyphs. +A tool with its own font library can hand it over without installing it: folders listed in +`EFFECTCRAFT_FONT_DIRS` (a path list, `:`-separated, `;` on Windows) are scanned like the system font folders. In expressions, `text.sourceText.style` / `getStyleAt(i, t)` read styles and the setters (`setFontSize(v, start?, count?)`, `setFillColor`, `setText`, `setJustification`…) return a From 9d689b1a61b09a6f3844ff574445b90372887bea Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 22:29:04 +0000 Subject: [PATCH 093/107] Community follow-ups: dedup font files from overlapping folders (#665); empty-Timeline drop also accepts compositions, rustfmt (#662) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f --- crates/text/src/fonts.rs | 1 + crates/ui-egui/src/panels/timeline.rs | 6 ++---- crates/ui-egui/tests/ui_drag_drop.rs | 13 +++++-------- 3 files changed, 8 insertions(+), 12 deletions(-) diff --git a/crates/text/src/fonts.rs b/crates/text/src/fonts.rs index cb2b2ad2..2f7311c5 100644 --- a/crates/text/src/fonts.rs +++ b/crates/text/src/fonts.rs @@ -126,6 +126,7 @@ impl FontSource for DirectorySource { walk(d, 0, &mut files); } files.sort(); + files.dedup(); let mut out = Vec::new(); for f in files { let Ok(mut file) = std::fs::File::open(&f) else { continue }; diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 7d7ebab4..61b2d22d 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -1217,10 +1217,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { .map(ItemId) .filter(|id| { app.session.project.item(*id).is_some_and(|item| match &item.kind { - effectcraft_engine::project::ItemKind::Footage(footage) => { - footage.kind != effectcraft_engine::project::FootageKind::Data - } - effectcraft_engine::project::ItemKind::Solid(_) => true, + effectcraft_engine::project::ItemKind::Footage(footage) => footage.kind != effectcraft_engine::project::FootageKind::Data, + effectcraft_engine::project::ItemKind::Solid(_) | effectcraft_engine::project::ItemKind::Comp(_) => true, _ => false, }) }) diff --git a/crates/ui-egui/tests/ui_drag_drop.rs b/crates/ui-egui/tests/ui_drag_drop.rs index fc6a9a69..ce09d7fd 100644 --- a/crates/ui-egui/tests/ui_drag_drop.rs +++ b/crates/ui-egui/tests/ui_drag_drop.rs @@ -215,10 +215,10 @@ fn project_items_dropped_on_new_comp_make_a_composition() { #[test] fn project_footage_dropped_on_empty_timeline_creates_first_comp() { let mut s = Session::default(); - let footage = s.execute( - "file.importPlaceholder", - json!({"name": "Landscape.png", "width": 640, "height": 360, "frameRate": 24, "duration": 5}), - ).unwrap()["item"].as_u64().unwrap(); + let footage = + s.execute("file.importPlaceholder", json!({"name": "Landscape.png", "width": 640, "height": 360, "frameRate": 24, "duration": 5})).unwrap()["item"] + .as_u64() + .unwrap(); let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); h.run_steps(3); assert!(h.state().session.active_comp().is_none()); @@ -241,10 +241,7 @@ fn multiple_selected_footage_dropped_on_empty_timeline_asks_how_to_create_comps( let mut s = Session::default(); let first = s.execute("file.importPlaceholder", json!({"name": "First.png", "width": 160, "height": 90})).unwrap()["item"].as_u64().unwrap(); let second = s.execute("file.importPlaceholder", json!({"name": "Second.png", "width": 300, "height": 200})).unwrap()["item"].as_u64().unwrap(); - s.state.project_selection = vec![ - effectcraft_engine::project::ItemId(first), - effectcraft_engine::project::ItemId(second), - ]; + s.state.project_selection = vec![effectcraft_engine::project::ItemId(first), effectcraft_engine::project::ItemId(second)]; let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); h.run_steps(3); let from = rect(&h, &format!("project.item.{first}.name")).center(); From 1c7fcccef2dff51bc7bd8c8f44f6e477aaa8612b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 22:18:06 +0000 Subject: [PATCH 094/107] Packaging: the Windows installer offers a desktop shortcut, on by default (#343) The MSI only installed a Start Menu shortcut. The wizard's folder page now has a "Create a desktop shortcut" box (DESKTOPSHORTCUT, 1 by default; DESKTOPSHORTCUT=0 for silent installs) that adds an EffectCraft shortcut to the Public desktop. The choice is recorded under HKLM\Software\EffectCraft so an upgrade keeps it, and an upgrade from a release before this option adds none. test-msi.ps1 checks the shortcut is created by default, kept by upgrades, removed on uninstall and absent with DESKTOPSHORTCUT=0. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f --- packaging/windows/effectcraft.wxs | 23 ++++++++++++++++++++++- packaging/windows/installer-ui.wxs | 3 ++- packaging/windows/test-msi.ps1 | 18 +++++++++++++++--- 3 files changed, 39 insertions(+), 5 deletions(-) diff --git a/packaging/windows/effectcraft.wxs b/packaging/windows/effectcraft.wxs index 6757c446..00b0c5d6 100644 --- a/packaging/windows/effectcraft.wxs +++ b/packaging/windows/effectcraft.wxs @@ -4,7 +4,8 @@ wix build effectcraft.wxs installer-ui.wxs -arch x64 -d Version=1.2.3 -d BinDir=... -d IconPath=... -o out.msi - Per-machine install into Program Files (or a folder the user picks) and a Start Menu shortcut. + Per-machine install into Program Files (or a folder the user picks), a Start Menu shortcut and, + unless DESKTOPSHORTCUT=0 (the wizard's "Create a desktop shortcut" box), a desktop shortcut. --> + + + + + + + + @@ -70,11 +82,20 @@ + + + + + + + + diff --git a/packaging/windows/installer-ui.wxs b/packaging/windows/installer-ui.wxs index 14989932..16ab58d7 100644 --- a/packaging/windows/installer-ui.wxs +++ b/packaging/windows/installer-ui.wxs @@ -3,7 +3,7 @@ EffectCraft installer wizard: original dialogs built from Windows Installer's own controls and events (no WixUI library), included by effectcraft.wxs with . - fresh install: DestDlg (folder, Change... opens BrowseDlg) -> ReadyDlg -> ProgressDlg -> ExitDlg + fresh install: DestDlg (folder, Change... opens BrowseDlg, desktop shortcut) -> ReadyDlg -> ProgressDlg -> ExitDlg upgrade: ReadyDlg (the installed copy's folder, not editable) -> ProgressDlg -> ExitDlg already there: MaintDlg (Repair or Remove) -> ProgressDlg -> ExitDlg @@ -29,6 +29,7 @@ + diff --git a/packaging/windows/test-msi.ps1 b/packaging/windows/test-msi.ps1 index 7cdaef86..5868c77b 100644 --- a/packaging/windows/test-msi.ps1 +++ b/packaging/windows/test-msi.ps1 @@ -10,8 +10,10 @@ installed effectcraft.exe, as there is no Path value yet), 3. upgrades to 0.0.3 the same way (found from the Path value 0.0.2 wrote), 4. repairs a damaged (unversioned) CLI there with the wizard's Repair mode, - 5. uninstalls: files and the App Paths registration go, - 6. installs 0.0.3 with no folder: it must land in Program Files, then uninstalls. + 5. uninstalls: files, the App Paths registration and the desktop shortcut go, + 6. installs 0.0.3 with no folder and DESKTOPSHORTCUT=0: it must land in Program Files with no + desktop shortcut, then uninstalls. + The installs before step 6 take the default desktop shortcut, which upgrades keep. The wizard itself needs a person (or the snapshot steps in the PR); this covers what msiexec /qn and upgrades do with the folder. Needs an elevated shell and WiX v5 (`wix`) on PATH. Run by .github/workflows/packaging-lint.yml. @@ -24,6 +26,8 @@ $ErrorActionPreference = 'Stop' trap { Write-Output "FAILED: $_"; exit 1 } $Root = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path $AppPaths = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\effectcraft.exe' +# A per-machine install's desktop is the Public one. +$DesktopLink = Join-Path ([Environment]::GetFolderPath('CommonDesktopDirectory')) 'EffectCraft.lnk' function Invoke-Native([string] $What, [scriptblock] $Block) { Write-Output "==> $What" @@ -50,6 +54,10 @@ function Assert-InstalledIn([string] $Dir, [switch] $NoPathValue) { } Write-Output "ok installed in $Dir" } +function Assert-DesktopShortcut([bool] $Present) { + if ((Test-Path -LiteralPath $DesktopLink) -ne $Present) { throw "desktop shortcut present: $(-not $Present), expected $Present" } + Write-Output "ok desktop shortcut present: $Present" +} Remove-Item -Recurse -Force $Work -ErrorAction SilentlyContinue $Bin = Join-Path $Work 'bin' @@ -87,6 +95,7 @@ if (Test-Path -LiteralPath $AppPaths) { throw 'EffectCraft is already installed Invoke-Msiexec 'install the legacy package to a custom folder' @('/i', "`"$($Msi['0.0.1'])`"", "INSTALLFOLDER=`"$Custom`"") Assert-InstalledIn $Custom -NoPathValue +Assert-DesktopShortcut $true if (Test-Path -LiteralPath $Default) { throw "a custom install also wrote $Default" } Invoke-Msiexec 'upgrade the legacy install without a folder' @('/i', "`"$($Msi['0.0.2'])`"") @@ -95,6 +104,7 @@ if (Test-Path -LiteralPath $Default) { throw "the upgrade moved the install to $ Invoke-Msiexec 'upgrade again without a folder' @('/i', "`"$($Msi['0.0.3'])`"") Assert-InstalledIn $Custom +Assert-DesktopShortcut $true # Read single values: Get-ItemProperty throws on some machines' malformed Uninstall entries. $arp = @(Get-ChildItem 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall' | ForEach-Object { if ($_.GetValue('DisplayName') -eq 'EffectCraft') { $_.GetValue('DisplayVersion') } }) @@ -117,9 +127,11 @@ Write-Output "ok repair ($mode) restored effectcraft-cli.exe" Invoke-Msiexec 'uninstall the custom install' @('/x', "`"$($Msi['0.0.3'])`"") if (Test-Path -LiteralPath (Join-Path $Custom 'effectcraft.exe')) { throw 'uninstall left effectcraft.exe behind' } if (Test-Path -LiteralPath $AppPaths) { throw 'uninstall left the App Paths registration behind' } +Assert-DesktopShortcut $false -Invoke-Msiexec 'install to the default folder' @('/i', "`"$($Msi['0.0.3'])`"") +Invoke-Msiexec 'install to the default folder without a desktop shortcut' @('/i', "`"$($Msi['0.0.3'])`"", 'DESKTOPSHORTCUT=0') Assert-InstalledIn $Default +Assert-DesktopShortcut $false Invoke-Msiexec 'uninstall the default install' @('/x', "`"$($Msi['0.0.3'])`"") if (Test-Path -LiteralPath (Join-Path $Default 'effectcraft.exe')) { throw 'uninstall left effectcraft.exe behind' } Write-Output 'MSI folder handling ok' From d6efb795e46416c665e1aa2f3942deddd0f454d0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 22:52:36 +0000 Subject: [PATCH 095/107] Keyframes: a straight auto-Bezier segment interpolates its chord exactly (#650 follow-up) Auto-Bezier end keys now carry handles along the path, so a straight two-key move went through the arc-length table and landed a rounding error off its keys' midpoint (80.00000000000001). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f --- crates/keyframe/src/lib.rs | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/crates/keyframe/src/lib.rs b/crates/keyframe/src/lib.rs index 6ac8edbe..787a0643 100644 --- a/crates/keyframe/src/lib.rs +++ b/crates/keyframe/src/lib.rs @@ -224,6 +224,20 @@ pub fn ease_progress(out_speed_n: f64, out_inf: f64, in_speed_n: f64, in_inf: f6 bez(0.0, out_speed_n * i0, 1.0 - in_speed_n * i1, 1.0, u) } +/// Whether control point `c` lies on the segment `a`–`b` (within rounding), so a cubic with +/// such handles traces the straight chord. +fn on_chord(a: [f64; 3], c: [f64; 3], b: [f64; 3]) -> bool { + let d = [b[0] - a[0], b[1] - a[1], b[2] - a[2]]; + let v = [c[0] - a[0], c[1] - a[1], c[2] - a[2]]; + let dd = d[0] * d[0] + d[1] * d[1] + d[2] * d[2]; + if dd <= 1e-24 { + return false; + } + let t = (v[0] * d[0] + v[1] * d[1] + v[2] * d[2]) / dd; + let off = [v[0] - d[0] * t, v[1] - d[1] * t, v[2] - d[2] * t]; + (0.0..=1.0).contains(&t) && off[0] * off[0] + off[1] * off[1] + off[2] * off[2] <= dd * 1e-18 +} + /// Spatial cubic segment with an arc-length table. struct SpatialSeg { p: [[f64; 3]; 4], @@ -529,7 +543,13 @@ pub fn segment_value(keys: &[Keyframe], i: usize, t: Tick, spatial: bool) -> Val let ei = if lin_in { Ease { speed: len / dur, influence: 1.0 / 3.0 } } else { effective_ease(keys, i + 1, 0, true, false) }; ease_progress(eo.speed * dur / len, eo.influence, ei.speed * dur / len, ei.influence, x) }; - let p = seg.at_length(f * len); + // A straight path (both handles on the chord, as auto-Bezier keys on a line have) is the + // chord itself: interpolate it exactly instead of through the arc-length table. + let p = if on_chord(p0, seg.p[1], p3) && on_chord(p0, seg.p[2], p3) { + [p0[0] + (p3[0] - p0[0]) * f, p0[1] + (p3[1] - p0[1]) * f, p0[2] + (p3[2] - p0[2]) * f] + } else { + seg.at_length(f * len) + }; return a.value.with_components(&p); } if lin_out && lin_in { @@ -819,6 +839,13 @@ mod tests { assert!((m[0] - m[1]).abs() > 1.0, "{m:?}"); } + #[test] + fn straight_auto_bezier_path_is_exact() { + // Auto-Bezier ends now have handles along the line; the chord is still hit exactly. + let keys = vec![Keyframe::new(s(0.0), Value::Vec2([40.0, 50.0])), Keyframe::new(s(2.0), Value::Vec2([120.0, 50.0]))]; + assert_eq!(segment_value(&keys, 0, s(1.0), true), Value::Vec2([80.0, 50.0])); + } + #[test] fn spatial_auto_bezier_tangents_follow_after_effects() { // Measured on After Effects 26.3 with original paths (docs/fidelity/README.md): each From 3c063b50750802aa33aebb0e9ae7f1c1359bc27b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 22:19:09 +0000 Subject: [PATCH 096/107] GPU: a failed allocation reported as an internal error, or a lost device, no longer crashes the app (#591) On Adreno (Windows ARM64) a failed vkAllocateMemory surfaces as an internal ERROR_INITIALIZATION_FAILED, not out-of-memory. Gpu::within_memory only scoped OutOfMemory, and wgpu's DeviceExt::create_buffer_init maps the new buffer at creation and panics ("Failed to get mapped range") when the allocation failed, taking down a frame thread. Buffers with initial contents are now created unmapped and filled through queue.write_buffer (context::buffer_init), so a failed allocation is only an invalid buffer and an error for the caller's scope, and within_memory scopes Internal errors too, discarding the frame for the CPU path as it does for out-of-memory. The device was then lost, and egui-wgpu panicked on the main thread staging the next frame's index buffer on the dead device. Once the host reports the presentation device lost, the app draws nothing more (no mesh to stage), auto-saves unsaved changes, keeps the crash-recovery marker so the next launch offers them, and closes its window. Tests: a device with a small buffer limit gets an over-limit and an unaligned buffer_init without a panic and sees the error in its scope; the UI harness reports a device loss and checks the next frame has no shapes, closes the window and wrote the auto-save. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f --- crates/gpu/src/adv3d.rs | 20 ++++++------------ crates/gpu/src/context.rs | 37 ++++++++++++++++++++++++++++------ crates/gpu/src/lib.rs | 13 ++++++++---- crates/gpu/src/particles.rs | 9 ++------- crates/gpu/src/tests.rs | 22 ++++++++++++++++++++ crates/ui-egui/src/lib.rs | 35 ++++++++++++++++++++++++++++++-- crates/ui-egui/tests/ui_gpu.rs | 27 +++++++++++++++++++++++++ 7 files changed, 130 insertions(+), 33 deletions(-) diff --git a/crates/gpu/src/adv3d.rs b/crates/gpu/src/adv3d.rs index f3b9fee2..6ae11445 100644 --- a/crates/gpu/src/adv3d.rs +++ b/crates/gpu/src/adv3d.rs @@ -15,7 +15,6 @@ use effectcraft_render::three_d::Dof; use effectcraft_render::three_d::adv::{Prepared, Rendered, Scene, Target}; use effectcraft_render::three_d::bokeh; -use wgpu::util::DeviceExt; use crate::context::{Enc, GpuContext, GpuImage}; @@ -443,14 +442,11 @@ fn raster_into(e: &mut Enc, s: &Scene) -> Option<(wgpu::Texture, wgpu::Texture)> b }) .collect(); - let vbuf = dev.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("adv3d vertices"), contents: &vb, usage: wgpu::BufferUsages::VERTEX }); + let vbuf = g.buffer_init(Some("adv3d vertices"), &vb, wgpu::BufferUsages::VERTEX); let ib: Vec = s.indices.iter().flat_map(|i| i.to_le_bytes()).collect(); - let ibuf = dev.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("adv3d indices"), contents: &ib, usage: wgpu::BufferUsages::INDEX }); - let ub = dev.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("adv3d globals"), contents: &bufs[0], usage: wgpu::BufferUsages::UNIFORM }); - let sbs: Vec = bufs[1..] - .iter() - .map(|b| dev.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("adv3d data"), contents: b, usage: wgpu::BufferUsages::STORAGE })) - .collect(); + let ibuf = g.buffer_init(Some("adv3d indices"), &ib, wgpu::BufferUsages::INDEX); + let ub = g.buffer_init(Some("adv3d globals"), &bufs[0], wgpu::BufferUsages::UNIFORM); + let sbs: Vec = bufs[1..].iter().map(|b| g.buffer_init(Some("adv3d data"), b, wgpu::BufferUsages::STORAGE)).collect(); let mut entries = vec![wgpu::BindGroupEntry { binding: 0, resource: ub.as_entire_binding() }]; for (i, b) in sbs.iter().enumerate() { entries.push(wgpu::BindGroupEntry { binding: i as u32 + 1, resource: b.as_entire_binding() }); @@ -736,7 +732,7 @@ fn dispatch(e: &mut Enc, kernel: &str, u0: [u32; 4], f0: [f32; 4], b: Binds, gro let g = e.g; let post = &pipes(g).post; let Some((_, pipe)) = post.kernels.iter().find(|(k, _)| *k == kernel) else { return }; - let ub = g.device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: None, contents: ¶ms(u0, f0), usage: wgpu::BufferUsages::UNIFORM }); + let ub = g.buffer_init(None, ¶ms(u0, f0), wgpu::BufferUsages::UNIFORM); let mut entries = vec![ wgpu::BindGroupEntry { binding: 0, resource: ub.as_entire_binding() }, wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(b.color.unwrap_or(&post.dummy_color)) }, @@ -859,11 +855,7 @@ fn depth_of_field(e: &mut Enc, acc: &wgpu::Buffer, depth: &wgpu::Buffer, (w, h): for v in [f32::INFINITY.to_bits(), 0, 0, 0] { init.extend_from_slice(&v.to_le_bytes()); } - let range = g.device.create_buffer_init(&wgpu::util::BufferInitDescriptor { - label: Some("adv3d radius range"), - contents: &init, - usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC, - }); + let range = g.buffer_init(Some("adv3d radius range"), &init, wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC); let f0 = [dof.focus as f32, dof.aperture as f32, dof.blur_level as f32, scale as f32]; let b = Binds { bufs: [None, Some(depth), None, None, Some(&radius), Some(&range), None], ..Default::default() }; dispatch(e, "radius_of", [w, h, 0, 0], f0, b, pixel_groups(w, h)); diff --git a/crates/gpu/src/context.rs b/crates/gpu/src/context.rs index c5065eae..788757bf 100644 --- a/crates/gpu/src/context.rs +++ b/crates/gpu/src/context.rs @@ -5,7 +5,6 @@ use std::sync::{Arc, Mutex, Weak}; use effectcraft_effects::Buf; use effectcraft_raster::Image; -use wgpu::util::DeviceExt; /// Compute entry points in `kernels.wgsl` (one pipeline each). const ENTRIES: &[&str] = &[ @@ -325,7 +324,34 @@ fn init_resource(_device: &wgpu::Device, _label: &str, create: impl FnOnce() Ok(create()) } +/// A buffer holding `contents` (padded to 4 bytes), written through the queue. Unlike +/// `DeviceExt::create_buffer_init`, which maps the buffer at creation and panics when the +/// allocation failed (a driver reports it as an internal error, not out-of-memory: Adreno's +/// `ERROR_INITIALIZATION_FAILED`, #591), a failed allocation only yields an invalid buffer and +/// an error for the caller's error scope. +pub(crate) fn buffer_init(device: &wgpu::Device, queue: &wgpu::Queue, label: Option<&str>, contents: &[u8], usage: wgpu::BufferUsages) -> wgpu::Buffer { + let align = wgpu::COPY_BUFFER_ALIGNMENT as usize; + let padded = contents.len().div_ceil(align).saturating_mul(align); + let buffer = + device.create_buffer(&wgpu::BufferDescriptor { label, size: padded as u64, usage: usage | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false }); + if padded > 0 { + if padded == contents.len() { + queue.write_buffer(&buffer, 0, contents); + } else { + let mut data = contents.to_vec(); + data.resize(padded, 0); + queue.write_buffer(&buffer, 0, &data); + } + } + buffer +} + impl GpuContext { + /// [`buffer_init`] on this context's device. + pub(crate) fn buffer_init(&self, label: Option<&str>, contents: &[u8], usage: wgpu::BufferUsages) -> wgpu::Buffer { + buffer_init(&self.device, &self.queue, label, contents, usage) + } + /// Build on an existing device (the desktop app shares egui-wgpu's). `Err` when the /// adapter cannot run the compositor (no compute shaders, e.g. WebGL2; no float storage /// textures) or the device was created with limits below what its pipelines bind. @@ -549,9 +575,8 @@ impl GpuContext { view_formats: &[], }) })?; - let dummy_buf = init_resource(&device, "dummy storage buffer", || { - device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("dummy"), contents: &[0u8; 16], usage: wgpu::BufferUsages::STORAGE }) - })?; + let dummy_buf = + init_resource(&device, "dummy storage buffer", || buffer_init(&device, &queue, Some("dummy"), &[0u8; 16], wgpu::BufferUsages::STORAGE))?; let dummy_tex = init_resource(&device, "dummy texture view", || dummy.create_view(&Default::default()))?; let max_dim = device.limits().max_texture_dimension_2d; Ok(GpuContext { @@ -996,7 +1021,7 @@ impl<'g> Enc<'g> { self.missing = true; return; }; - let ub = g.device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: None, contents: &p.bytes(), usage: wgpu::BufferUsages::UNIFORM }); + let ub = g.buffer_init(None, &p.bytes(), wgpu::BufferUsages::UNIFORM); let sv = src.texture.create_view(&Default::default()); let av = aux.map(|a| a.texture.create_view(&Default::default())); let ov = out.texture.create_view(&Default::default()); @@ -1229,7 +1254,7 @@ impl<'g> Enc<'g> { while bytes.len() < 16 || !bytes.len().is_multiple_of(4) { bytes.push(0); } - self.g.device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("data"), contents: &bytes, usage: wgpu::BufferUsages::STORAGE }) + self.g.buffer_init(Some("data"), &bytes, wgpu::BufferUsages::STORAGE) } /// Convert to the viewer's premultiplied RGBA8 texture (egui-wgpu's native texture format), diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 723da308..252145c1 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -264,17 +264,22 @@ impl Gpu { } } - /// Run work in a native thread-local out-of-memory scope. A failed result is discarded - /// and cached allocations are released; callers can retry with a fresh CPU renderer. + /// Run work in native thread-local out-of-memory and internal-error scopes. A failed + /// result is discarded and cached allocations are released; callers can retry with a fresh + /// CPU renderer. Some drivers report a failed allocation as an internal error rather than + /// out-of-memory (Adreno's `ERROR_INITIALIZATION_FAILED`, #591), so both count. /// Browser callers use the host/owned-device failure bridge and bounded readbacks instead /// of blocking their event loop on an error-scope future. pub fn within_memory(&self, f: impl FnOnce() -> T) -> Result { self.ctx.check_health()?; #[cfg(not(target_arch = "wasm32"))] { - let scope = self.ctx.device.push_error_scope(wgpu::ErrorFilter::OutOfMemory); + let oom = self.ctx.device.push_error_scope(wgpu::ErrorFilter::OutOfMemory); + let internal = self.ctx.device.push_error_scope(wgpu::ErrorFilter::Internal); let out = f(); - match pollster::block_on(scope.pop()) { + let internal = pollster::block_on(internal.pop()); + let oom = pollster::block_on(oom.pop()); + match internal.or(oom) { Some(e) => { self.ctx.clear_uploads(); Err(e.to_string()) diff --git a/crates/gpu/src/particles.rs b/crates/gpu/src/particles.rs index 015edc82..7934dee0 100644 --- a/crates/gpu/src/particles.rs +++ b/crates/gpu/src/particles.rs @@ -6,7 +6,6 @@ use std::sync::{Mutex, OnceLock}; use effectcraft_effects::psim::{ParticleSystem, SimParticle, SimRequest}; -use wgpu::util::DeviceExt; use crate::context::GpuContext; @@ -152,7 +151,7 @@ pub(crate) fn simulate(g: &GpuContext, req: &SimRequest) -> Option = req.births.iter().flat_map(|b| b.to_le_bytes()).collect(); - let births = d.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("births"), contents: &births, usage: wgpu::BufferUsages::STORAGE }); + let births = g.buffer_init(Some("births"), &births, wgpu::BufferUsages::STORAGE); let prev = prev.unwrap_or_else(|| { d.create_buffer(&wgpu::BufferDescriptor { label: Some("particles"), size: STATE_BYTES, usage: wgpu::BufferUsages::STORAGE, mapped_at_creation: false }) }); @@ -162,11 +161,7 @@ pub(crate) fn simulate(g: &GpuContext, req: &SimRequest) -> Option, gpu_failure_applied: Option, + /// The graphics device was lost and the window is closing (see `close_after_device_loss`); + /// `true` keeps crash recovery for the next launch. + device_lost_close: Option, /// Commands from the native menu bar. pub command_inbox: Option>, /// Pointer position in comp pixels (Info panel). @@ -347,6 +350,7 @@ impl EffectcraftApp { gpu_checked: false, gpu_failures: None, gpu_failure_applied: None, + device_lost_close: None, command_inbox: None, pointer_comp: None, dialog_state: Default::default(), @@ -643,6 +647,26 @@ impl EffectcraftApp { ctx.request_repaint(); } + /// After the graphics device is lost, eframe's renderer cannot draw again: any mesh makes + /// egui-wgpu panic on the main thread when it cannot stage its index buffer (#591). Draw + /// nothing from then on, auto-save a project with unsaved changes (keeping the crash + /// recovery marker, so the next launch offers it) and close the window. + fn close_after_device_loss(&mut self, ctx: &egui::Context) { + if self.device_lost_close.is_some() { + return; + } + let keep_recovery = self.session.is_dirty(); + if keep_recovery { + match self.session.autosave_now().and_then(|path| self.session.autosave_wait().map(|()| path)) { + Ok(path) => log::warn!("graphics device lost: auto-saved {path}; the next launch offers to recover it"), + Err(e) => log::error!("graphics device lost: {e}"), + } + } + self.device_lost_close = Some(keep_recovery); + log::warn!("graphics device lost: closing the window"); + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } + /// Set up the GPU compositor on egui-wgpu's device (once). Without an adapter that runs /// compute shaders (WebGL2, software GL) everything stays on the CPU. pub(crate) fn init_gpu(&mut self, frame: &eframe::Frame) { @@ -1672,8 +1696,11 @@ impl EffectcraftApp { impl eframe::App for EffectcraftApp { fn on_exit(&mut self) { - // Clean exit: no crash recovery next launch. - self.session.end_recovery(); + // Clean exit: no crash recovery next launch (unless the graphics device was lost with + // unsaved changes, which are in the auto-save it offers). + if self.device_lost_close != Some(true) { + self.session.end_recovery(); + } } fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) { @@ -1732,6 +1759,10 @@ impl eframe::App for EffectcraftApp { } fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) { + if self.gpu_failure_applied == Some(true) { + self.close_after_device_loss(ui.ctx()); + return; + } self.init_gpu(frame); if !self.fonts_ready { ui.ctx().request_repaint(); diff --git a/crates/ui-egui/tests/ui_gpu.rs b/crates/ui-egui/tests/ui_gpu.rs index 92d18a44..58536f16 100644 --- a/crates/ui-egui/tests/ui_gpu.rs +++ b/crates/ui-egui/tests/ui_gpu.rs @@ -86,3 +86,30 @@ fn viewer_draws_gpu_frames_that_match_the_cpu() { cpu_shot.save(format!("{d}/ui_cpu_viewer.png")).unwrap(); } } + +/// Once the graphics device is lost, eframe's renderer cannot stage buffers and egui-wgpu +/// panicked on the main thread drawing the next frame (#591). The app now draws nothing, +/// auto-saves unsaved changes and closes its window instead. +#[test] +fn a_lost_device_draws_nothing_autosaves_and_closes() { + let dir = std::env::temp_dir().join(format!("ec-device-lost-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let mut s = Session { autosave_folder_override: Some(dir.clone()), ..Default::default() }; + s.execute("comp.new", json!({"name": "Unsaved", "width": 64, "height": 64, "duration": 1})).unwrap(); + assert!(s.is_dirty()); + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.run_steps(3); + assert!(!h.output().shapes.is_empty(), "the UI draws before the loss"); + let bridge = effectcraft_ui_egui::gpu_failure::GpuFailureBridge::new(&h.ctx); + h.state_mut().set_gpu_failure_bridge(bridge.clone()); + bridge.report("GPU device lost (Unknown): Device is lost", true); + h.step(); + let closing = h.output().viewport_output.get(&egui::ViewportId::ROOT).is_some_and(|v| v.commands.contains(&egui::ViewportCommand::Close)); + assert!(closing, "the window closes"); + assert!(h.output().shapes.is_empty(), "nothing for the lost renderer to stage"); + h.step(); + assert!(h.output().shapes.is_empty()); + let saved = h.state().session.autosave.last_path.clone().expect("unsaved changes auto-saved"); + assert!(std::path::Path::new(&saved).is_file(), "{saved}"); + let _ = std::fs::remove_dir_all(&dir); +} From 6a3d5c670391e980540302cbf1f52f7b19134125 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 22:26:43 +0000 Subject: [PATCH 097/107] Viewer: the Pen, Add, Delete and Convert Vertex tools edit motion paths, with pen pointers (#553) Motion-path hits were only handled for the Selection tool, so the Pen slot's tools did nothing on a layer's motion path, and every Pen slot tool showed the same crosshair. As in After Effects, on a motion path: - Add Vertex (and the Pen) add a Position keyframe at the clicked frame dot, with the path's value there (prop.addKey), so the path keeps its shape; - Delete Vertex removes the clicked keyframe (keys.select + keys.delete); - Convert Vertex switches it between Linear and Auto Bezier spatial interpolation (keys.interpolation {spatial}). motion_path now reports its frame dots for the hit test. Over the viewer the Pen slot tools draw their own pen glyph (Pen, Add, Delete, Convert, Mask Feather) at the pointer in place of the system cursor, and the Pen shows Add Vertex's pen over a mask, shape or motion path it would add a point to. Test: ui_viewer clicks a two-key motion path with each tool and checks the added key, its spatial interpolation both ways, its removal, and the pointer. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f --- crates/ui-egui/src/panels/viewer.rs | 92 ++++++++++++++++++-- crates/ui-egui/src/panels/viewer_overlays.rs | 42 ++++++++- crates/ui-egui/tests/ui_viewer.rs | 47 ++++++++++ 3 files changed, 174 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index a532cdeb..248f9bcb 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -872,6 +872,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let mut handle_hits: Vec<(LayerId, usize, Pos2, egui::CursorIcon)> = Vec::new(); let mut vertex_hits: Vec = Vec::new(); let mut key_hits: Vec = Vec::new(); + let mut motion_frames: Vec = Vec::new(); let mut paths: Vec = Vec::new(); let sel_vertices = app.session.state.selected_vertices.clone(); // Pen path in progress ends when the tool changes or its mask is gone; Esc / Enter also end @@ -891,7 +892,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { for l in comp.layers.iter().filter(|l| selected.contains(&l.id) && l.is_active_at(time)) { let col = if label_handles { t.label(l.label) } else { t.accent }; // Motion path of animated position (keyframes and spatial tangents are draggable). - ov::motion_path(app, &painter, &map, &ectx, l, col, &mut key_hits); + ov::motion_path(app, &painter, &map, &ectx, l, col, &mut key_hits, &mut motion_frames); // Masks and shape paths. if app.ui.viewer.show_masks { let pen = ui.data(|d| d.get_temp::<(LayerId, u64)>(pen_id())); @@ -1153,7 +1154,15 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { Tool::Orbit => egui::CursorIcon::Alias, Tool::PanCamera => egui::CursorIcon::Move, Tool::Dolly => egui::CursorIcon::ResizeVertical, - t if t.is_shape() || t.is_pen() => egui::CursorIcon::Crosshair, + t if t.is_pen() => { + pen_cursor( + &ctx, + hp, + pen_cursor_icon(t, ov::segment_at(&paths, &map, hp, 5.0).is_some() || ov::motion_frame_at(&motion_frames, &key_hits, hp).is_some()), + ); + egui::CursorIcon::None + } + t if t.is_shape() => egui::CursorIcon::Crosshair, _ if app.ui.viewer.roi_draw => egui::CursorIcon::Crosshair, t if t.puppet_kind().is_some() => match super::puppet_tool::hit(&pin_hits, hp) { Some((_, super::puppet_tool::PinPart::Ring)) => egui::CursorIcon::Alias, @@ -1201,7 +1210,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { && ui.input(|i| i.pointer.primary_pressed()) && let Some(pos) = ui.input(|i| i.pointer.press_origin()) { - pen_press(app, ui, &ectx, &map, &paths, pos, mods); + pen_press(app, ui, &ectx, &map, &paths, ov::motion_frame_at(&motion_frames, &key_hits, pos), pos, mods); } // A pen click (press + release without a drag) ends its tangent gesture on release. if ui.input(|i| i.pointer.primary_released()) @@ -1703,19 +1712,35 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } Tool::Type | Tool::TypeVertical => {} Tool::Pen | Tool::MaskFeather => {} + // On a motion path (After Effects): Add Vertex adds a keyframe where it is clicked, + // Delete Vertex removes the keyframe, Convert Vertex switches it between Linear and + // Auto Bezier spatial interpolation. Tool::PenAdd => { if let Some((l, m, seg, tt)) = ov::segment_at(&paths, &map, pos, 6.0) { let _ = app.session.execute("mask.insertVertex", json!({"layer": l.0, "mask": m, "segment": seg, "t": tt})); + } else if let Some(f) = ov::motion_frame_at(&motion_frames, &key_hits, pos) { + add_motion_key(app, f); } } Tool::PenDelete => { if let Some(h) = vertex_at(pos, false) { let _ = app.session.execute("mask.deleteVertices", json!({"vertices": [{"layer": h.layer.0, "mask": h.mask, "index": h.index}]})); + } else if let Some(k) = ov::motion_key_at(&key_hits, pos) + && select_motion_key(app, &k) + { + let _ = app.session.execute("keys.delete", json!({})); } } Tool::PenConvert => { if let Some(h) = vertex_at(pos, false) { let _ = app.session.execute("mask.convertVertex", json!({"layer": h.layer.0, "mask": h.mask, "index": h.index})); + } else if let Some(k) = ov::motion_key_at(&key_hits, pos) { + // A key showing direction handles becomes Linear; one without, Auto Bezier. + let curved = key_hits.iter().any(|h| h.tangent.is_some() && h.layer == k.layer && h.prop == k.prop && h.time == k.time); + if select_motion_key(app, &k) { + let spatial = if curved { "linear" } else { "autoBezier" }; + let _ = app.session.execute("keys.interpolation", json!({"spatial": spatial})); + } } } Tool::Selection if mods.alt && vertex_at(pos, false).is_some() => { @@ -1907,7 +1932,60 @@ fn draw_rigs(app: &mut EffectcraftApp, painter: &egui::Painter, map: &ViewerMap, /// footage/solid layer (or shape layer, with Tool Creates Mask), a shape path (Shape n group with /// the Tools bar's Fill and Stroke) on the selected shape layer, or a new shape layer when /// nothing is selected. The placed point snaps. -fn pen_press(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, map: &ViewerMap, paths: &[ov::PathInfo], pos: Pos2, mods: egui::Modifiers) { +/// Add a Position keyframe at a motion path's frame, keeping the path where it is. +fn add_motion_key(app: &mut EffectcraftApp, f: ov::PathFrame) { + if let Err(e) = app.session.execute("prop.addKey", json!({"layer": f.layer.0, "prop": f.prop, "time": f.time.seconds()})) { + app.ui.status = e.to_string(); + } +} + +/// Select only the motion path's keyframe `k` (what the vertex tools act on). +fn select_motion_key(app: &mut EffectcraftApp, k: &ov::KeyHit) -> bool { + app.session.execute("keys.select", json!({"keys": [{"layer": k.layer.0, "prop": k.prop, "time": k.time.seconds()}]})).is_ok() +} + +/// The pointer for a Pen slot tool: the tool's own pen, the Pen showing Add Vertex's over a path +/// it would add a point to (After Effects). +fn pen_cursor_icon(tool: Tool, over_path: bool) -> crate::icons::Icon { + use crate::icons::Icon; + match tool { + Tool::Pen if over_path => Icon::PenAdd, + Tool::PenAdd => Icon::PenAdd, + Tool::PenDelete => Icon::PenDelete, + Tool::PenConvert => Icon::PenConvert, + Tool::MaskFeather => Icon::MaskFeather, + _ => Icon::Pen, + } +} + +/// Paint pen pointer `icon` with its tip at `at`, outlined for any background. +fn pen_cursor(ctx: &egui::Context, at: Pos2, icon: crate::icons::Icon) { + use crate::icons::Icon; + // Where each glyph's tip sits in its 16 × 16 box. + let tip = match icon { + Icon::PenAdd | Icon::PenDelete | Icon::MaskFeather => vec2(6.0, 2.5), + Icon::PenConvert => vec2(8.0, 3.5), + _ => vec2(8.0, 1.8), + }; + let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("viewer-pen-cursor"))); + let rect = Rect::from_min_size(at - tip, vec2(16.0, 16.0)); + for d in [vec2(-1.0, 0.0), vec2(1.0, 0.0), vec2(0.0, -1.0), vec2(0.0, 1.0)] { + crate::icons::paint(&painter, rect.translate(d), icon, Color32::BLACK); + } + crate::icons::paint(&painter, rect, icon, Color32::WHITE); +} + +#[allow(clippy::too_many_arguments)] +fn pen_press( + app: &mut EffectcraftApp, + ui: &mut egui::Ui, + ectx: &EvalCtx, + map: &ViewerMap, + paths: &[ov::PathInfo], + motion_frame: Option, + pos: Pos2, + mods: egui::Modifiers, +) { use effectcraft_engine::project::LayerSource; let gid = egui::Id::new("viewer-gesture"); let current: Option<(LayerId, u64)> = ui.data(|d| d.get_temp(pen_id())); @@ -1939,7 +2017,11 @@ fn pen_press(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, map: & } return; } - // On a visible path's segment the Pen adds a vertex there. + // On a visible path's segment the Pen adds a vertex there; on a motion path, a keyframe. + if let Some(f) = motion_frame { + add_motion_key(app, f); + return; + } if let Some((l, m, seg, t)) = ov::segment_at(paths, map, pos, 5.0) { let _ = app.session.execute("mask.insertVertex", json!({"layer": l.0, "mask": m, "segment": seg, "t": t})); return; diff --git a/crates/ui-egui/src/panels/viewer_overlays.rs b/crates/ui-egui/src/panels/viewer_overlays.rs index 0038f192..54218e9f 100644 --- a/crates/ui-egui/src/panels/viewer_overlays.rs +++ b/crates/ui-egui/src/panels/viewer_overlays.rs @@ -248,6 +248,32 @@ pub(crate) struct KeyHit { pub p2c: Mat3, } +/// A frame on a motion path (one of its dots): where the Pen and Add Vertex tools add a +/// keyframe. +#[derive(Clone, Copy)] +pub(crate) struct PathFrame { + pub layer: LayerId, + pub prop: u64, + /// Layer time of the frame. + pub time: Tick, + pub pos: Pos2, +} + +/// The keyframe dot on a motion path under `pos`. +pub(crate) fn motion_key_at(hits: &[KeyHit], pos: Pos2) -> Option { + hits.iter().rev().find(|h| h.tangent.is_none() && h.pos.distance(pos) < 6.0).copied() +} + +/// The motion-path frame nearest `pos` (within a few points) where a keyframe can be added: not +/// on a keyframe dot already there. +pub(crate) fn motion_frame_at(frames: &[PathFrame], hits: &[KeyHit], pos: Pos2) -> Option { + if motion_key_at(hits, pos).is_some() { + return None; + } + let near = frames.iter().filter(|f| f.pos.distance(pos) < 5.0).min_by(|a, b| a.pos.distance(pos).total_cmp(&b.pos.distance(pos)))?; + (!hits.iter().any(|h| h.tangent.is_none() && h.layer == near.layer && h.prop == near.prop && h.time == near.time)).then_some(*near) +} + /// Parent space → comp matrix of a 2D layer at the context time. pub(crate) fn parent_to_comp(ectx: &EvalCtx, l: &Layer) -> Mat3 { match l.parent.and_then(|p| ectx.comp.layer(p)) { @@ -261,7 +287,17 @@ pub(crate) fn parent_to_comp(ectx: &EvalCtx, l: &Layer) -> Mat3 { /// Draw the position motion path of a selected 2D layer: frame dots along the path, keyframe /// squares (filled when selected) and spatial Bezier tangent handles. Works through parents. -pub(crate) fn motion_path(app: &mut EffectcraftApp, painter: &egui::Painter, map: &ViewerMap, ectx: &EvalCtx, l: &Layer, col: Color32, hits: &mut Vec) { +#[allow(clippy::too_many_arguments)] +pub(crate) fn motion_path( + app: &mut EffectcraftApp, + painter: &egui::Painter, + map: &ViewerMap, + ectx: &EvalCtx, + l: &Layer, + col: Color32, + hits: &mut Vec, + frames: &mut Vec, +) { let Some(pos) = l.transform().and_then(|tr| tr.get("position")) else { return }; if pos.keys.len() < 2 || l.is_3d() { return; @@ -278,7 +314,9 @@ pub(crate) fn motion_path(app: &mut EffectcraftApp, painter: &egui::Painter, map let mut pts = vec![]; let mut lt = lt0; while lt <= lt1 && pts.len() < 20_000 { - pts.push(to_scr(pos.value_at(lt).as_vec3())); + let q = to_scr(pos.value_at(lt).as_vec3()); + frames.push(PathFrame { layer: l.id, prop: pos.uid, time: lt, pos: q }); + pts.push(q); lt += fd; } pts.push(to_scr(pos.value_at(lt1).as_vec3())); diff --git a/crates/ui-egui/tests/ui_viewer.rs b/crates/ui-egui/tests/ui_viewer.rs index 120c4db0..a1ca76ab 100644 --- a/crates/ui-egui/tests/ui_viewer.rs +++ b/crates/ui-egui/tests/ui_viewer.rs @@ -1900,3 +1900,50 @@ fn roto_brush_ctrl_drag_resizes_the_brush() { assert_eq!(s.len(), 1); assert!((s[0].radius - dia / 2.0).abs() < 1e-6, "{} vs {dia}", s[0].radius); } + +/// #553: the Pen slot's tools work on a motion path as in After Effects: Add Vertex (and the +/// Pen) add a keyframe where the path is clicked, Convert Vertex switches a keyframe between +/// Linear and Auto Bezier, Delete Vertex removes it; the pointer is the tool's pen. +#[test] +fn vertex_tools_edit_motion_path_keyframes() { + let mut h = harness(); + let box_id: LayerId = h.state().session.active_comp().unwrap().layers[0].id; + let s = &mut h.state_mut().session; + s.execute("layer.select", json!({"layers": [box_id.0]})).unwrap(); + s.execute("prop.addKey", json!({"layer": box_id.0, "path": "transform/position", "time": 0.0, "value": [100, 100, 0]})).unwrap(); + s.execute("prop.addKey", json!({"layer": box_id.0, "path": "transform/position", "time": 2.0, "value": [500, 100, 0]})).unwrap(); + s.execute("view.snapping", json!({"value": false})).unwrap(); + s.set_time(effectcraft_engine::time::Tick::from_seconds_f64(3.0)); + let keys = |h: &Harness<'_, EffectcraftApp>| { + h.state().session.active_comp().unwrap().layer(box_id).unwrap().props.prop("transform/position").unwrap().keys.clone() + }; + h.state_mut().ui.tool = Tool::PenAdd; + h.run_steps(3); + let mid = screen(&h, [300.0, 100.0]); + h.input_mut().events.push(Event::PointerMoved(mid)); + h.step(); + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::None, "the pen is painted in place of the system pointer"); + click(&mut h, mid); + let k = keys(&h); + assert_eq!(k.len(), 3, "Add Vertex adds a keyframe on the path"); + let v = k[1].value.as_vec3(); + assert!((v[0] - 300.0).abs() < 10.0 && (v[1] - 100.0).abs() < 1.0, "{v:?}"); + assert!((k[1].time.seconds() - 1.0).abs() < 0.1, "{:?}", k[1].time); + // Convert Vertex: the new key's handles go (Linear), then come back (Auto Bezier). + h.state_mut().ui.tool = Tool::PenConvert; + let at = screen(&h, [v[0] as f32, v[1] as f32]); + click(&mut h, at); + let k = keys(&h); + assert!(!k[1].spatial_auto && k[1].spatial_in == [0.0; 3] && k[1].spatial_out == [0.0; 3], "Linear"); + click(&mut h, at); + assert!(keys(&h)[1].spatial_auto, "Auto Bezier"); + // Delete Vertex removes it. + h.state_mut().ui.tool = Tool::PenDelete; + click(&mut h, at); + assert_eq!(keys(&h).len(), 2); + // The Pen adds one too. + h.state_mut().ui.tool = Tool::Pen; + let quarter = screen(&h, [200.0, 100.0]); + click(&mut h, quarter); + assert_eq!(keys(&h).len(), 3); +} From d6894249fa99dd7b6a858ca756f8a257b226c8cc Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 22:26:43 +0000 Subject: [PATCH 098/107] Inline editors: the whole name is selected only as a rename opens; arrows then move the caret (#488) widgets::keep_focus (Project panel rename and comment, Render Queue, Essential Graphics and Timeline fields) selected the whole text whenever it saw its field without the keyboard focus, not just on the frame the editor opened. Anything that took the focus for a frame (an input method, another handler) then re-selected the name, so the arrow keys seemed to do nothing and Backspace erased the whole name, as reported on Linux. The editor now remembers the pass it was last shown: it selects all only when it opens and otherwise just takes the keyboard back with the caret where it was. The harness does not reproduce the report's Linux behaviour itself (arrows reach the field there); the tests cover the path that did: a unit test drops the focus for a frame and checks the caret survives (and that reopening selects all), and a kittest renames a Project item and a Timeline layer with Left / Right / Up / Down / End and Backspace, editing part of each name. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f --- crates/ui-egui/src/widgets.rs | 61 ++++++++++++++++++++++++-- crates/ui-egui/tests/ui_inline_edit.rs | 41 +++++++++++++++++ 2 files changed, 98 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 364a2472..b4fa41f3 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -165,12 +165,24 @@ pub fn drag_value(value: egui::DragValue<'_>) -> impl egui::Widget { } /// Keep the keyboard on an inline editor that is open until it loses the focus. The frame it -/// opens (it has no focus yet) its whole `text` is selected, so typing replaces the value. -/// (Only then is the focus requested: a request resets what keys the field keeps for itself, -/// such as Tab with [`egui::TextEdit::lock_focus`].) +/// opens its whole `text` is selected, so typing replaces the value; after that the caret is the +/// user's (arrow keys move it), even if the field is without the focus for a frame (#488). +/// (The focus is only requested while the field doesn't have it: a request resets what keys the +/// field keeps for itself, such as Tab with [`egui::TextEdit::lock_focus`].) pub fn keep_focus(ctx: &egui::Context, field: &Response, text: &str) { + // The editor is open in consecutive passes; a gap means it was closed and opened again. + let shown = field.id.with("keep-focus-pass"); + let pass = ctx.cumulative_pass_nr(); + let previous = ctx.data_mut(|d| { + let previous = d.get_temp::(shown); + d.insert_temp(shown, pass); + previous + }); + let opening = previous.is_none_or(|p| p.saturating_add(1) < pass); if !ctx.memory(|m| m.has_focus(field.id)) { - select_all(ctx, field.id, text); + if opening { + select_all(ctx, field.id, text); + } field.request_focus(); } } @@ -592,6 +604,47 @@ pub(crate) fn constrained(c: &[f64], d: usize, v: f64) -> Vec { mod tests { use super::*; + /// #488: an inline editor selects its whole text only as it opens. Should it be without the + /// keyboard for a frame later (an input method, another handler), it takes the keyboard back + /// with the caret where the user put it instead of selecting everything again, which made + /// the arrows seem dead and Backspace erase the whole name. + #[test] + fn inline_editors_select_all_only_as_they_open() { + let ctx = egui::Context::default(); + let mut text = String::from("Comp 1"); + let mut id = egui::Id::NULL; + let frame = |ctx: &egui::Context, text: &mut String, id: &mut egui::Id| { + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + let r = ui.add(egui::TextEdit::singleline(text)); + keep_focus(ui.ctx(), &r, text); + *id = r.id; + }); + out.textures_delta.clear(); + }; + let range = |ctx: &egui::Context, id: egui::Id| egui::TextEdit::load_state(ctx, id).and_then(|s| s.cursor.char_range()); + let ix = |n: usize| egui::text::CCursor::new(n).index; + frame(&ctx, &mut text, &mut id); + frame(&ctx, &mut text, &mut id); + let all = range(&ctx, id).unwrap(); + assert_eq!((all.primary.index.min(all.secondary.index), all.primary.index.max(all.secondary.index)), (ix(0), ix(6)), "opened with all selected"); + // The caret after "Co", then a frame without the keyboard. + let mut state = egui::TextEdit::load_state(&ctx, id).unwrap(); + state.cursor.set_char_range(Some(egui::text::CCursorRange::one(egui::text::CCursor::new(2)))); + state.store(&ctx, id); + ctx.memory_mut(|m| m.surrender_focus(id)); + frame(&ctx, &mut text, &mut id); + frame(&ctx, &mut text, &mut id); + assert!(ctx.memory(|m| m.has_focus(id)), "the editor keeps the keyboard"); + let r = range(&ctx, id).unwrap(); + assert_eq!((r.primary.index, r.secondary.index), (ix(2), ix(2)), "the caret stays where it was"); + // Closed for a frame and opened again: all selected again. + ctx.run_ui(egui::RawInput::default(), |_| {}).textures_delta.clear(); + frame(&ctx, &mut text, &mut id); + frame(&ctx, &mut text, &mut id); + let r = range(&ctx, id).unwrap(); + assert_eq!((r.primary.index.min(r.secondary.index), r.primary.index.max(r.secondary.index)), (ix(0), ix(6))); + } + /// #581: a field's text edit fills it, so its text and hint sit on the field's middle line /// (it was a row at the top of the field, its text high). #[test] diff --git a/crates/ui-egui/tests/ui_inline_edit.rs b/crates/ui-egui/tests/ui_inline_edit.rs index fc5c522e..4119d79b 100644 --- a/crates/ui-egui/tests/ui_inline_edit.rs +++ b/crates/ui-egui/tests/ui_inline_edit.rs @@ -474,3 +474,44 @@ fn a_layout_switch_keeps_the_name_field_and_arabic_names_are_kept() { assert_eq!(focused.trim(), "File", "Alt focused the menu bar"); } } + +/// #488: once a rename field is open, the arrow keys move its caret (the whole name is selected +/// only as it opens), so part of a name can be edited. +#[test] +fn arrow_keys_move_the_caret_in_rename_fields() { + let (mut h, comp) = harness(); + let name = |h: &Harness<'_, EffectcraftApp>| h.state().session.project.items.values().find(|i| i.id.0 == comp).unwrap().name.clone(); + let p = center(&h, &format!("project.item.{comp}.name")); + click(&mut h, p, 1); + key(&mut h, Key::Enter); + // The arrows keep the field (Up / Down too); in "Main", two to the left of its end. + key(&mut h, Key::ArrowRight); + key(&mut h, Key::ArrowLeft); + key(&mut h, Key::ArrowUp); + key(&mut h, Key::ArrowDown); + key(&mut h, Key::End); + key(&mut h, Key::ArrowLeft); + key(&mut h, Key::ArrowLeft); + type_text(&mut h, "X"); + key(&mut h, Key::Backspace); + key(&mut h, Key::Backspace); + type_text(&mut h, "Y"); + key(&mut h, Key::Enter); + assert_eq!(name(&h), "MYin"); + // The Timeline's layer rename. + let layer = h.state_mut().session.execute("layer.newSolid", json!({"name": "Plate", "color": "#406080"})).unwrap()["layer"].as_u64().unwrap(); + h.state_mut().session.execute("layer.select", json!({"layers": [layer]})).unwrap(); + h.run_steps(3); + let p = center(&h, &format!("timeline.layer.{layer}.row")); + click(&mut h, p, 1); + key(&mut h, Key::Enter); + key(&mut h, Key::ArrowRight); + key(&mut h, Key::ArrowUp); + key(&mut h, Key::End); + key(&mut h, Key::ArrowLeft); + key(&mut h, Key::ArrowLeft); + type_text(&mut h, "-"); + key(&mut h, Key::Enter); + let layer_name = h.state().session.active_comp().unwrap().layer(effectcraft_engine::project::LayerId(layer)).unwrap().name.clone(); + assert_eq!(layer_name, "Pla-te"); +} From aaf23aa5f89fb088e315d10b3633a6929e280307 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 22:32:12 +0000 Subject: [PATCH 099/107] macOS: File menu dialogs open as sheets, so a drag over the window can't abort the app (#511) The file dialogs were blocking rfd::FileDialog calls made inside eframe's frame, that is inside winit's event handler. On macOS the dialog runs a nested modal event loop there; a file dragged over the window while it was open reached winit's handler again, which refuses re-entry with a panic across the Objective-C boundary, aborting the app (Import with Cmd+I, then a drag). Hooks gains file_dialog_async: a host that sets it shows a command's file dialog (Import, Open, Save As, Save Frame As, presets, guides, templates, Collect Files, Relink...) without blocking, and the command runs with the answer on a later frame (menus::dialog_answered, drained at the start of each frame); a cancelled dialog runs nothing. The macOS app implements it with rfd::AsyncFileDialog, a sheet on the window answered when it closes, outside the frame. Other platforms keep the blocking dialogs (winit queues their re-entrant events), as do the few panel buttons that pick a file in place (Settings paths, shortcut import/export, effect file pickers). Test: with a non-blocking hook, Save As and Save Frame As return at once without the blocking dialog, the save happens when the answer arrives from another thread, and a cancelled dialog writes nothing. The macOS sheet itself needs a manual check (Cmd+I, drag a file over the window, pick or cancel). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f --- Cargo.lock | 1 + apps/effectcraft/Cargo.toml | 1 + apps/effectcraft/src/main.rs | 6 ++ apps/effectcraft/src/sheet_dialogs.rs | 76 ++++++++++++++++++++ crates/ui-egui/src/lib.rs | 49 +++++++++++++ crates/ui-egui/src/menus.rs | 99 +++++++++++++++++++++++---- 6 files changed, 220 insertions(+), 12 deletions(-) create mode 100644 apps/effectcraft/src/sheet_dialogs.rs diff --git a/Cargo.lock b/Cargo.lock index cfab570d..2609335f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1148,6 +1148,7 @@ dependencies = [ "objc2 0.6.4", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", + "pollster 0.4.0", "rfd", "serde_json", "winit", diff --git a/apps/effectcraft/Cargo.toml b/apps/effectcraft/Cargo.toml index ad3fc8b5..5fabc10c 100644 --- a/apps/effectcraft/Cargo.toml +++ b/apps/effectcraft/Cargo.toml @@ -18,6 +18,7 @@ egui = { workspace = true } eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "links", "wayland", "x11"] } serde_json = { workspace = true } rfd = { workspace = true } +pollster = { workspace = true } cpal = { workspace = true } log = { workspace = true } diff --git a/apps/effectcraft/src/main.rs b/apps/effectcraft/src/main.rs index 70457a47..a5269789 100644 --- a/apps/effectcraft/src/main.rs +++ b/apps/effectcraft/src/main.rs @@ -21,6 +21,9 @@ mod control_server; mod launch_guard; #[cfg(target_os = "macos")] mod native_menu; +// Built everywhere so it stays checked; only the macOS app uses it. +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +mod sheet_dialogs; use effectcraft_ui_egui::EffectcraftApp; use serde_json::json; @@ -227,6 +230,9 @@ fn main() -> eframe::Result { })); #[cfg(target_os = "macos")] { + // The commands' file dialogs open as sheets instead of nested modal loops (#511). + let ctx = cc.egui_ctx.clone(); + app.hooks.file_dialog_async = Some(Box::new(move |dialog, done| sheet_dialogs::show(dialog, done, ctx.clone()))); app.hooks.app_action = Some(Box::new(native_menu::app_action)); app.hooks.clipboard_text = Some(Box::new(native_menu::clipboard_text)); } diff --git a/apps/effectcraft/src/sheet_dialogs.rs b/apps/effectcraft/src/sheet_dialogs.rs new file mode 100644 index 00000000..577eb0b6 --- /dev/null +++ b/apps/effectcraft/src/sheet_dialogs.rs @@ -0,0 +1,76 @@ +//! File dialogs that don't block the frame (macOS), for `Hooks::file_dialog_async`. +//! +//! A blocking `rfd::FileDialog` runs a nested modal event loop inside eframe's frame, that is +//! inside winit's event handler. On macOS a file dragged over the window while that loop ran +//! re-entered winit's handler, which panics across the Objective-C boundary and aborts the app +//! (#511). `rfd::AsyncFileDialog` shows the dialog as a sheet on the window and returns at once; +//! the answer arrives when the sheet closes, outside the frame, and the app runs the command then. + +use effectcraft_ui_egui::{DialogDone, FileDialog}; +use rfd::{AsyncFileDialog, FileHandle}; +use std::future::Future; +use std::pin::Pin; + +type Answer = Pin> + Send>>; + +/// Show `dialog` (on the main thread: the sheet opens here) and hand its paths to `done` once it +/// closes, then wake the UI. +pub fn show(dialog: FileDialog, done: DialogDone, ctx: egui::Context) { + let answer = open(dialog); + let spawned = std::thread::Builder::new().name("ec-file-dialog".into()).spawn(move || { + done(pollster::block_on(answer)); + ctx.request_repaint(); + }); + if let Err(e) = spawned { + log::error!("file dialog: {e}"); + } +} + +fn paths(files: Option>) -> Vec { + files.unwrap_or_default().into_iter().map(|f| f.path().to_string_lossy().to_string()).collect() +} + +fn path(file: Option) -> Vec { + paths(file.map(|f| vec![f])) +} + +/// The dialog, as the blocking hooks in `main.rs` show it. +fn open(dialog: FileDialog) -> Answer { + match dialog { + FileDialog::Open { exts, many } => { + let d = AsyncFileDialog::new().add_filter("Media", &exts); + if many { + let f = d.pick_files(); + Box::pin(async move { paths(f.await) }) + } else { + let f = d.pick_file(); + Box::pin(async move { path(f.await) }) + } + } + FileDialog::OpenProject => { + let f = AsyncFileDialog::new().add_filter("EffectCraft Project", &["ecproj", "ecprojx"]).pick_file(); + Box::pin(async move { path(f.await) }) + } + FileDialog::Folder => { + let f = AsyncFileDialog::new().pick_folder(); + Box::pin(async move { path(f.await) }) + } + FileDialog::Save { default, ext, project } => { + let at = std::path::Path::new(&default); + let name = at.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); + let mut d = if project { + AsyncFileDialog::new().add_filter("EffectCraft Project", &["ecproj"]) + } else if ext.is_empty() { + AsyncFileDialog::new() + } else { + AsyncFileDialog::new().add_filter(&ext, &[ext.as_str()]) + }; + // A default with a folder that exists opens the dialog there. + if let Some(dir) = at.parent().filter(|d| d.is_dir()) { + d = d.set_directory(dir); + } + let f = d.set_file_name(name).save_file(); + Box::pin(async move { path(f.await) }) + } + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 70293909..a9cf3f32 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -98,6 +98,39 @@ pub enum Dialog { /// winit no theme). `None` = the host does not know. pub type SystemThemeFn = Box Option>; +/// A file dialog a host shows without blocking the frame ([`Hooks::file_dialog_async`]). +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum FileDialog { + /// Files to open or import with these extensions; several when `many`. + Open { exts: Vec, many: bool }, + /// An EffectCraft project to open. + OpenProject, + /// A folder. + Folder, + /// Where to save `default` (a file name or path) with extension `ext` (empty: no filter); + /// `project`: the project save dialog, given the file name. + Save { default: String, ext: String, project: bool }, +} + +impl FileDialog { + /// The save dialog for `default`, as [`Hooks::save_dialog`] picks it. + pub fn save(default: &str) -> Self { + let ext = std::path::Path::new(default).extension().map(|e| e.to_string_lossy().to_string()).unwrap_or_default(); + Self::Save { default: default.to_string(), project: ext.eq_ignore_ascii_case("ecproj"), ext } + } +} + +/// Called with the paths a [`FileDialog`] picked (empty: cancelled), from any thread. +pub type DialogDone = Box) + Send>; + +/// A non-blocking file dialog's answer for the command that asked (`menus::dialog_answered`). +pub(crate) struct DialogAnswer { + pub command: String, + pub key: &'static str, + pub params: serde_json::Value, + pub paths: Vec, +} + /// Host hooks provided by the native app (file pickers etc.). #[derive(Default)] pub struct Hooks { @@ -121,6 +154,11 @@ pub struct Hooks { /// Application actions the OS performs (`app.hide`, `app.hideOthers`, `app.showAll` on /// macOS). Returns false when the host doesn't handle the id. pub app_action: Option bool>>, + /// Shows a command's file dialog without blocking the frame and calls `done` with the + /// answer (the host then asks for a repaint). Used in place of the blocking pickers above for + /// the commands' dialogs (File ▸ Import, Open, Save As…) where a modal dialog's nested event + /// loop inside a frame is unsafe: on macOS a drag over the window then aborted the app (#511). + pub file_dialog_async: Option>, } impl Hooks { @@ -264,6 +302,8 @@ pub struct EffectcraftApp { /// The graphics device was lost and the window is closing (see `close_after_device_loss`); /// `true` keeps crash recovery for the next launch. device_lost_close: Option, + /// Answers of file dialogs shown without blocking ([`Hooks::file_dialog_async`]). + pub(crate) dialog_answers: (Sender, Receiver), /// Commands from the native menu bar. pub command_inbox: Option>, /// Pointer position in comp pixels (Info panel). @@ -351,6 +391,7 @@ impl EffectcraftApp { gpu_failures: None, gpu_failure_applied: None, device_lost_close: None, + dialog_answers: std::sync::mpsc::channel(), command_inbox: None, pointer_comp: None, dialog_state: Default::default(), @@ -667,6 +708,13 @@ impl EffectcraftApp { ctx.send_viewport_cmd(egui::ViewportCommand::Close); } + /// Run the commands whose non-blocking file dialogs have been answered. + pub(crate) fn run_dialog_answers(&mut self) { + while let Ok(answer) = self.dialog_answers.1.try_recv() { + menus::dialog_answered(self, answer); + } + } + /// Set up the GPU compositor on egui-wgpu's device (once). Without an adapter that runs /// compute shaders (WebGL2, software GL) everything stays on the CPU. pub(crate) fn init_gpu(&mut self, frame: &eframe::Frame) { @@ -1507,6 +1555,7 @@ impl EffectcraftApp { fn frame(&mut self, ui: &mut egui::Ui) { let ctx = ui.ctx().clone(); self.apply_gpu_failure(&ctx); + self.run_dialog_answers(); self.auto.begin_frame(); self.frames.set_context(&ctx); if self.session.render_job.is_some() { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 6e7ec9fc..52ae3b15 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1200,7 +1200,31 @@ fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option crate::FileDialog::Folder, + Ask::Import => crate::FileDialog::Open { exts: MEDIA.iter().map(|e| e.to_string()).collect(), many: key == "paths" }, + Ask::OpenProject => crate::FileDialog::OpenProject, + Ask::Open(exts) => crate::FileDialog::Open { exts: exts.iter().map(|e| e.to_string()).collect(), many: false }, + Ask::Save(default) => crate::FileDialog::save(default), + }; + let tx = app.dialog_answers.0.clone(); + let answer = crate::DialogAnswer { command: id.to_string(), key, params: params.clone(), paths: Vec::new() }; + spawn( + request, + Box::new(move |paths| { + let _ = tx.send(crate::DialogAnswer { paths, ..answer }); + }), + ); + return Some(Ok(json!({"dialog": id}))); + } let picked: Option = match ask { Ask::Folder => { let Some(f) = app.hooks.pick_folder.as_ref() else { return Some(Err("no folder dialog available (pass `folder`)".into())) }; @@ -1208,15 +1232,7 @@ fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option { let Some(f) = app.hooks.pick_files.as_ref() else { return Some(Err("no file dialog available (pass `paths`)".into())) }; - let paths = f(&[ - "mp4", "mov", "m4v", "mkv", "webm", "png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "exr", "wav", "aif", "aiff", "mp3", "flac", - "ogg", "opus", "svg", "pdf", "ai", "eps", "psd", "psb", "gltf", "glb", "obj", "json", "csv", "tsv", - ]); - match (paths.is_empty(), key) { - (true, _) => None, - (false, "paths") => Some(json!(paths)), - (false, _) => paths.first().map(|s| json!(s)), - } + picked_value(key, f(MEDIA)) } Ask::OpenProject => { let Some(f) = app.hooks.pick_open_project.as_ref() else { return Some(Err("no file dialog available (pass `path`)".into())) }; @@ -1232,6 +1248,30 @@ fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option) -> Option { + match (paths.is_empty(), key) { + (true, _) => None, + (false, "paths") => Some(json!(paths)), + (false, _) => paths.into_iter().next().map(Value::from), + } +} + +/// A file dialog shown without blocking ([`crate::Hooks::file_dialog_async`]) answered: run its +/// command with the picked path (nothing when it was cancelled). +pub(crate) fn dialog_answered(app: &mut EffectcraftApp, answer: crate::DialogAnswer) { + let Some(v) = picked_value(answer.key, answer.paths) else { return }; + if let Err(e) = run_with_picked(app, &answer.command, answer.key, &answer.params, v) { + app.ui.status = e; + } +} + +/// Run command `id` with the path picked for it in parameter `key`. +fn run_with_picked(app: &mut EffectcraftApp, id: &str, key: &str, params: &Value, v: Value) -> Result { + let mut p = params.as_object().cloned().unwrap_or_default(); p.insert(key.to_string(), v); // Picked files import in the background, with the Importing card showing progress (#270). if matches!(id, "file.import" | "file.importMultiple") { @@ -1243,7 +1283,7 @@ fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option Option>> = Arc::default(); + let queue = pending.clone(); + app.hooks.file_dialog_async = Some(Box::new(move |dialog, done| queue.lock().unwrap().push((dialog, done)))); + assert_eq!(invoke(&mut app, &ctx, "file.saveAs", json!({})).unwrap(), json!({"dialog": "file.saveAs"})); + let (dialog, done) = pending.lock().unwrap().pop().expect("a dialog was shown"); + assert_eq!(dialog, crate::FileDialog::Save { default: "Untitled Project.ecproj".into(), ext: "ecproj".into(), project: true }); + assert!(app.session.path.is_none(), "nothing saved until the dialog answers"); + let dir = std::env::temp_dir().join(format!("ec-dialog-answer-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("Answered.ecproj"); + // Answered from another thread, as the host's dialog does. + std::thread::spawn(move || done(vec![path.to_string_lossy().to_string()])).join().unwrap(); + app.run_dialog_answers(); + assert_eq!(app.session.path.as_deref(), Some(dir.join("Answered.ecproj").to_string_lossy().as_ref())); + assert!(dir.join("Answered.ecproj").is_file()); + // Cancelled: nothing runs. + invoke(&mut app, &ctx, "comp.saveFrameAs", json!({})).unwrap(); + let (dialog, done) = pending.lock().unwrap().pop().expect("a dialog was shown"); + assert_eq!(dialog, crate::FileDialog::Save { default: "Frame.png".into(), ext: "png".into(), project: false }); + done(Vec::new()); + app.run_dialog_answers(); + assert!(!dir.join("Frame.png").exists()); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn menu_items_cover_the_tree() { assert_eq!(menus().last(), Some(&"Help")); From 8fb42816324531fc52696aaf04d42629b48a784f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 22:44:01 +0000 Subject: [PATCH 100/107] MCP: --automation-read-root / --automation-write-root confine the agent's file access (#500) `effectcraft-cli mcp` had no way to keep an agent's file access to a folder; the flags were unknown options. Both now take a folder (repeatable, as in PhotoCraft): with either given, files are read only under a read or write root and written only under a write root (none: no writes). Defaults are unchanged. automation::confine::Confinement checks every engine command the backend runs (execute_command, command_run, open/save_project, each engine.batch step), in both headless and bridge mode, before it runs: the file parameters of the commands that read or write files must be absolute paths inside the roots (symbolic links resolved through the longest existing ancestor, `..` refused), as must any other absolute path in a command's parameters. Commands that write where they aren't told are checked against the target they would write: Save / Increment and Save the open project's file, renderQueue.render every queued output (renderQueue.list). Scripts, Execute File, Reveal in Finder, Open Recent by index, `$N` file-path references in a batch, render_frame/screenshot paths outside a write root and the bridge's UI-driving tools are refused while confined. docs/mcp.md describes it. Test: a confined headless server opens and saves inside its roots and is refused outside them, for a relative path, `..`, a batch step, a `$N` path, Save to a read-only root's project, the Render Queue's default output there, a symbolic link out of the write root, and scripts; the unconfined server still writes anywhere. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f --- apps/effectcraft-cli/src/main.rs | 17 +- crates/automation/src/backend.rs | 64 +++++-- crates/automation/src/confine.rs | 276 +++++++++++++++++++++++++++++++ crates/automation/src/lib.rs | 1 + crates/automation/src/tests.rs | 71 ++++++++ crates/automation/src/tools.rs | 13 ++ docs/mcp.md | 13 ++ 7 files changed, 437 insertions(+), 18 deletions(-) create mode 100644 crates/automation/src/confine.rs diff --git a/apps/effectcraft-cli/src/main.rs b/apps/effectcraft-cli/src/main.rs index a653b26b..fe523807 100644 --- a/apps/effectcraft-cli/src/main.rs +++ b/apps/effectcraft-cli/src/main.rs @@ -117,6 +117,9 @@ const USAGE: &str = "usage: effectcraft-cli : id or name, '-' = active comp; : id, '#n' or name; : JSON or bare string"; @@ -163,6 +166,9 @@ const VALUED: &[&str] = &[ "--webm-codec", "--audio-bitrate", "--opus-app", + // mcp + "--automation-read-root", + "--automation-write-root", ]; /// Options without a value. Any other `--option` is a usage error. @@ -232,6 +238,10 @@ impl Args { fn flag(&self, k: &str) -> bool { self.opts.iter().any(|(o, _)| o == k) } + /// Every value of a repeatable option, in order. + fn opts(&self, k: &str) -> Vec<&str> { + self.opts.iter().filter(|(o, _)| o == k).filter_map(|(_, v)| v.as_deref()).collect() + } fn opt(&self, k: &str) -> Option<&str> { self.opts.iter().rev().find(|(o, _)| o == k).and_then(|(_, v)| v.as_deref()) } @@ -595,7 +605,12 @@ fn run(cmd: &str, args: &Args, json_out: bool) -> Result<(), Failure> { if args.flag("--autosave") && args.opt("--bridge").is_some() { return usage_err("--autosave is for headless MCP; the bridged app owns its auto-saves"); } - let b = backend(args, false)?; + let mut b = backend(args, false)?; + let (read, write) = (args.opts("--automation-read-root"), args.opts("--automation-write-root")); + if !read.is_empty() || !write.is_empty() { + let roots = |r: Vec<&str>| r.into_iter().map(std::path::PathBuf::from).collect::>(); + b.confine(effectcraft_automation::confine::Confinement::new(&roots(read), &roots(write))?); + } let server = McpServer::new(b); let mut server = if args.flag("--autosave") { let root = diff --git a/crates/automation/src/backend.rs b/crates/automation/src/backend.rs index 685c978f..c3947c87 100644 --- a/crates/automation/src/backend.rs +++ b/crates/automation/src/backend.rs @@ -4,9 +4,16 @@ use effectcraft_engine::Session; use effectcraft_engine::time::Tick; use serde_json::{Value, json}; +use crate::confine::Confinement; use crate::{BridgeClient, Error, Result, base64, encode_png, recompress_png}; -pub enum Backend { +pub struct Backend { + target: Target, + /// File access confined to folders (`--automation-read-root` / `--automation-write-root`). + confinement: Option, +} + +enum Target { /// An in-process session (no window): engine commands, inspection and rendering. Headless(Box), /// A running desktop app reached over its control channel: everything, plus the live UI. @@ -24,43 +31,66 @@ pub struct Frame { impl Backend { pub fn headless(session: Session) -> Self { - Backend::Headless(Box::new(session)) + Backend { target: Target::Headless(Box::new(session)), confinement: None } } /// Bridge to the app's control channel at `addr` (`9877` or `127.0.0.1:9877`). pub fn bridge(addr: &str) -> Result { - Ok(Backend::Bridge(BridgeClient::new(addr)?)) + Ok(Backend { target: Target::Bridge(BridgeClient::new(addr)?), confinement: None }) + } + + /// Confine the files the commands run here may read and write ([`Confinement`]). + pub fn confine(&mut self, confinement: Confinement) { + self.confinement = Some(confinement); + } + + pub fn confinement(&self) -> Option<&Confinement> { + self.confinement.as_ref() } pub fn is_bridge(&self) -> bool { - matches!(self, Backend::Bridge(_)) + matches!(self.target, Target::Bridge(_)) } pub fn session(&mut self) -> Option<&mut Session> { - match self { - Backend::Headless(s) => Some(s), - Backend::Bridge(_) => None, + match &mut self.target { + Target::Headless(s) => Some(s), + Target::Bridge(_) => None, } } - /// Run an engine command by id. + /// Run an engine command by id (checked against the [`Confinement`], if any). pub fn exec(&mut self, id: &str, params: Value) -> Result { let params = if params.is_null() { json!({}) } else { params }; - match self { - Backend::Headless(s) => { + if let Some(c) = self.confinement.clone() { + let lookup = match c.check_command(id, ¶ms)? { + Some(crate::confine::Lookup::ProjectFile) => Some((crate::confine::Lookup::ProjectFile, self.run("project.summary", json!({}))?)), + Some(crate::confine::Lookup::RenderOutputs) => Some((crate::confine::Lookup::RenderOutputs, self.run("renderQueue.list", json!({}))?)), + None => None, + }; + if let Some((lookup, found)) = lookup { + c.check_lookup(&lookup, &found)?; + } + } + self.run(id, params) + } + + fn run(&mut self, id: &str, params: Value) -> Result { + match &mut self.target { + Target::Headless(s) => { let r = s.execute_checked(id, params); s.drain_events(); Ok(r?) } - Backend::Bridge(b) => b.call("engine.execute", json!({"command": id, "params": params})), + Target::Bridge(b) => b.call("engine.execute", json!({"command": id, "params": params})), } } /// Call a raw control-channel method (bridge only). pub fn control(&mut self, method: &str, params: Value) -> Result { - match self { - Backend::Bridge(b) => b.call(method, if params.is_null() { json!({}) } else { params }), - Backend::Headless(_) => Err(Error::BadArgs(NEED_BRIDGE.into())), + match &mut self.target { + Target::Bridge(b) => b.call(method, if params.is_null() { json!({}) } else { params }), + Target::Headless(_) => Err(Error::BadArgs(NEED_BRIDGE.into())), } } @@ -73,15 +103,15 @@ impl Backend { /// [`Backend::render`]; `transparent` keeps the frame's alpha instead of compositing it over /// the comp's background colour. pub fn render_with(&mut self, comp: Option<&Value>, time: Option, max_side: u32, transparent: bool) -> Result { - match self { - Backend::Headless(s) => { + match &mut self.target { + Target::Headless(s) => { let cid = s.resolve_comp(comp).map_err(|_| no_comp(comp))?; let t = time.map(Tick::from_seconds_f64).unwrap_or_else(|| s.time()); let (w, h, rgba) = s.render_rgba8_alpha(cid, t, max_side, transparent)?; let png = encode_png(w, h, rgba, max_side)?; Ok(Frame { png, comp: json!(cid.0), time: t.seconds(), width: w, height: h }) } - Backend::Bridge(b) => { + Target::Bridge(b) => { let mut p = json!({"max_side": max_side, "base64": true}); if transparent { p["transparent"] = json!(true); diff --git a/crates/automation/src/confine.rs b/crates/automation/src/confine.rs new file mode 100644 index 00000000..f396b3da --- /dev/null +++ b/crates/automation/src/confine.rs @@ -0,0 +1,276 @@ +//! File access confinement for automation (`effectcraft-cli mcp --automation-read-root DIR +//! --automation-write-root DIR`, as in PhotoCraft): an agent may only read files under a read +//! root (or a write root) and only write under a write root. +//! +//! Every engine command an agent runs ([`crate::Backend::exec`], also each step of an +//! `engine.batch`) is checked before it runs: its file parameters must be absolute paths inside +//! the roots (symbolic links resolved, no `..`), any other absolute path in its parameters too, +//! and commands that write to a file they are not given (Save, Increment and Save, rendering the +//! Render Queue) are checked against where they would write. Commands that run code or open +//! other applications (scripts, Execute File, Reveal in Finder…) are refused while confined. + +use std::path::{Component, Path, PathBuf}; + +use serde_json::Value; + +use crate::{Error, Result}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Access { + Read, + Write, +} + +/// The folders an agent may read and write. +#[derive(Clone, Debug)] +pub struct Confinement { + read: Vec, + write: Vec, +} + +/// Commands that run code, act outside the project or open other applications. +const REFUSED: &[&str] = &[ + "script.run", + "file.runScript", + "file.installScript", + "file.installScriptUIPanel", + "file.uninstallScript", + "file.executeFile", + "file.revealInFinder", + "layer.openSource", + "help.revealLogFile", + "file.autoSave", +]; + +/// Commands that read files named by these parameters. +const READS: &[(&str, &[&str])] = &[ + ("file.open", &["path"]), + ("file.openRecent", &["path"]), + ("file.import", &["path", "paths"]), + ("file.importMultiple", &["paths"]), + ("file.importRecent", &["path"]), + ("file.importLottie", &["path"]), + ("file.importTimeline", &["path"]), + ("file.replaceFootage", &["path"]), + ("file.relinkFootage", &["folder"]), + ("file.setProxy", &["path"]), + ("file.watchFolder", &["folder"]), + ("anim.applyPreset", &["path"]), + ("anim.applyRecentPreset", &["path"]), + ("keys.rpfCameraImport", &["path"]), + ("essential.importTemplate", &["path"]), + ("view.importGuides", &["path"]), + ("shortcuts.import", &["path"]), + ("effect.plugins.load", &["path", "folder"]), + ("mediaBrowser.go", &["path"]), + ("mediaBrowser.addFavorite", &["path"]), +]; + +/// Commands that write files named by these parameters. +const WRITES: &[(&str, &[&str])] = &[ + ("file.save", &["path"]), + ("file.saveAs", &["path"]), + ("file.saveCopy", &["path"]), + ("file.saveCopyAsXml", &["path"]), + ("file.collectFiles", &["folder"]), + ("file.exportLottie", &["path"]), + ("comp.saveFrameAs", &["path"]), + ("comp.saveFrameAsPsd", &["path"]), + ("comp.saveFrameAsExr", &["path"]), + ("anim.savePreset", &["path"]), + ("view.exportGuides", &["path"]), + ("render.saveCurrentPreview", &["path"]), + ("render.preRender", &["output"]), + ("essential.exportTemplate", &["path"]), + ("shortcuts.export", &["path"]), + ("renderQueue.add", &["output", "path"]), +]; + +/// Commands that write where they are not told: the project's file, the Render Queue's outputs. +const IMPLICIT_WRITES: &[&str] = &["file.save", "file.incrementAndSave", "renderQueue.render", "render.preRender"]; + +/// What must be looked up before an implicitly writing command may run. +#[derive(Debug, PartialEq, Eq)] +pub enum Lookup { + /// The open project's file (`project.summary` → `path`). + ProjectFile, + /// Every output of the queued Render Queue items (`renderQueue.list`). + RenderOutputs, +} + +impl Confinement { + /// Roots that must exist (each resolved to its real path). With no write root nothing may be + /// written; write roots may also be read. + pub fn new(read: &[PathBuf], write: &[PathBuf]) -> Result { + let real = |p: &PathBuf| p.canonicalize().map_err(|e| Error::BadArgs(format!("automation root {}: {e}", p.display()))); + let read = read.iter().map(real).collect::>>()?; + let write = write.iter().map(real).collect::>>()?; + Ok(Self { read, write }) + } + + fn roots(&self, access: Access) -> impl Iterator { + let read = if access == Access::Read { &self.read[..] } else { &[] }; + read.iter().chain(&self.write) + } + + /// `path` resolved, if it is inside the roots for `access`. + pub fn check(&self, path: &str, access: Access) -> Result { + let denied = |why: &str| { + let kind = if access == Access::Read { "read" } else { "write" }; + let roots: Vec = self.roots(access).map(|r| r.display().to_string()).collect(); + let roots = if roots.is_empty() { "none".to_string() } else { roots.join(", ") }; + Error::BadArgs(format!("cannot {kind} `{path}`: {why} (automation {kind} roots: {roots})")) + }; + let p = Path::new(path); + if !p.is_absolute() { + return Err(denied("give an absolute path")); + } + if p.components().any(|c| matches!(c, Component::ParentDir)) { + return Err(denied("`..` is not allowed")); + } + let real = resolve(p).ok_or_else(|| denied("it can't be resolved"))?; + if self.roots(access).any(|r| real.starts_with(r)) { Ok(real) } else { Err(denied("it is outside the automation roots")) } + } + + /// Check engine command `id` with `params` before it runs. Returns what must still be looked + /// up and passed to [`Confinement::check_lookup`] for commands that write implicitly. + pub fn check_command(&self, id: &str, params: &Value) -> Result> { + if REFUSED.contains(&id) { + return Err(Error::BadArgs(format!("`{id}` is not available while file access is confined (--automation-read-root/--automation-write-root)"))); + } + if id == "engine.batch" { + for step in params.get("steps").and_then(Value::as_array).into_iter().flatten() { + let Some(inner) = step.get("command").or_else(|| step.get("id")).and_then(Value::as_str) else { continue }; + let inner_params = step.get("params").cloned().unwrap_or(Value::Null); + let named = keys_of(READS, inner).or_else(|| keys_of(WRITES, inner)).unwrap_or(&[]); + // A step's `$N` reference is resolved only as the batch runs: never for a file. + if named.iter().filter_map(|k| inner_params.get(*k)).any(has_reference) { + return Err(Error::BadArgs(format!("`{inner}` in a batch: file paths can't be `$N` references while file access is confined"))); + } + if self.check_command(inner, &inner_params)?.is_some() { + return Err(Error::BadArgs(format!("`{inner}` writes to its project's or queue's files: run it on its own while file access is confined"))); + } + } + return Ok(None); + } + let (named, access) = match (keys_of(WRITES, id), keys_of(READS, id)) { + (Some(k), _) => (k, Access::Write), + (None, Some(k)) => (k, Access::Read), + (None, None) if id.starts_with("renderQueue.") || id == "prefs.set" => (&[][..], Access::Write), + (None, None) => (&[][..], Access::Read), + }; + let mut given = false; + for key in named { + let Some(v) = params.get(*key).filter(|v| !v.is_null()) else { continue }; + given = true; + let paths: Vec<&Value> = match v { + Value::Array(a) => a.iter().collect(), + v => vec![v], + }; + for p in paths { + // A Render Queue output may name a template instead of a file. + match p.as_str() { + Some(s) if id == "render.preRender" && !looks_like_path(s) => {} + Some(s) => { + self.check(s, access)?; + } + None => return Err(Error::BadArgs(format!("`{id}`: `{key}` must be a path"))), + } + } + } + // Any other absolute path the command is given. + let mut other = vec![]; + absolute_strings(params, named, &mut other); + for s in other { + self.check(&s, access)?; + } + // An entry of a recent list by index is a path the agent didn't give. + if matches!(id, "file.openRecent" | "file.importRecent" | "anim.applyRecentPreset") && !given { + return Err(Error::BadArgs(format!("`{id}`: give `path` while file access is confined"))); + } + if IMPLICIT_WRITES.contains(&id) && !(given && id == "file.save") { + return Ok(Some(if id == "file.save" || id == "file.incrementAndSave" { Lookup::ProjectFile } else { Lookup::RenderOutputs })); + } + Ok(None) + } + + /// Check what a [`Lookup`] found: `project.summary` or `renderQueue.list`'s result. + pub fn check_lookup(&self, lookup: &Lookup, found: &Value) -> Result<()> { + match lookup { + Lookup::ProjectFile => match found.get("path").and_then(Value::as_str) { + // Increment and Save writes beside it. + Some(p) => self.check(p, Access::Write).map(drop), + None => Err(Error::BadArgs("the project has no file yet: give `path` (Save As) inside an automation write root".into())), + }, + Lookup::RenderOutputs => { + let items = found.get("items").or(Some(found)).and_then(Value::as_array).cloned().unwrap_or_default(); + for it in items.iter().filter(|it| it.get("render").and_then(Value::as_bool) != Some(false)) { + let extra = it.get("extraOutputs").and_then(Value::as_array).into_iter().flatten().map(|o| o.get("outputPath")); + for p in std::iter::once(it.get("outputPath")).chain(extra).chain([it.get("logPath")]).flatten() { + if let Some(p) = p.as_str() { + self.check(p, Access::Write)?; + } + } + } + Ok(()) + } + } + } +} + +fn keys_of(table: &[(&str, &'static [&'static str])], id: &str) -> Option<&'static [&'static str]> { + table.iter().find(|(c, _)| *c == id).map(|(_, k)| *k) +} + +fn has_reference(v: &Value) -> bool { + match v { + Value::String(s) => s.starts_with('$'), + Value::Array(a) => a.iter().any(has_reference), + _ => false, + } +} + +/// A file path rather than a template name: has a separator or an extension. +fn looks_like_path(s: &str) -> bool { + s.contains(['/', '\\']) || Path::new(s).extension().is_some() +} + +/// Strings in `v` (recursively, except under the top-level `skip` keys) that are absolute paths, +/// `~` paths or `file:` URLs. +fn absolute_strings(v: &Value, skip: &[&str], out: &mut Vec) { + fn walk(v: &Value, out: &mut Vec, depth: usize) { + if depth > 64 { + return; + } + match v { + Value::String(s) => { + let t = s.trim(); + let drive = t.len() > 2 && t.as_bytes().get(1) == Some(&b':') && t.as_bytes().first().is_some_and(u8::is_ascii_alphabetic); + if t.starts_with('/') || t.starts_with('\\') || t.starts_with('~') || drive || t.to_ascii_lowercase().starts_with("file:") { + out.push(t.to_string()); + } + } + Value::Array(a) => a.iter().for_each(|x| walk(x, out, depth + 1)), + Value::Object(o) => o.values().for_each(|x| walk(x, out, depth + 1)), + _ => {} + } + } + match v { + Value::Object(o) => o.iter().filter(|(k, _)| !skip.contains(&k.as_str())).for_each(|(_, x)| walk(x, out, 0)), + v => walk(v, out, 0), + } +} + +/// `p` with symbolic links resolved: its longest existing ancestor's real path plus the rest +/// (which doesn't exist yet, so can't be a link). +fn resolve(p: &Path) -> Option { + let mut rest = vec![]; + let mut at = p; + loop { + if let Ok(real) = at.canonicalize() { + return Some(rest.iter().rev().fold(real, |acc: PathBuf, c| acc.join(c))); + } + rest.push(at.file_name()?.to_os_string()); + at = at.parent()?; + } +} diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 6a673309..a755e0dc 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -19,6 +19,7 @@ mod autosave; pub mod backend; pub mod base64; pub mod bridge; +pub mod confine; mod long_job; pub mod server; pub mod tools; diff --git a/crates/automation/src/tests.rs b/crates/automation/src/tests.rs index 51d15694..37de10a5 100644 --- a/crates/automation/src/tests.rs +++ b/crates/automation/src/tests.rs @@ -1181,3 +1181,74 @@ fn command_tools_accept_id_as_an_alias() { let (_, err) = call(&mut s, "execute_command", json!({})); assert!(err, "neither key is an error"); } + +/// #500: `--automation-read-root` / `--automation-write-root` confine the files an agent reads +/// and writes: inside the roots works, outside (or relative, `..`, through a symbolic link, by +/// a batch step, by Save to the project's own file, from a script) is refused. +#[test] +fn automation_roots_confine_file_access() { + use crate::confine::Confinement; + let base = std::env::temp_dir().join(format!("ec-mcp-confine-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + let (read, write, outside) = (base.join("in"), base.join("out"), base.join("elsewhere")); + for d in [&read, &write, &outside] { + std::fs::create_dir_all(d).unwrap(); + } + let p = |d: &std::path::Path, f: &str| d.join(f).to_string_lossy().into_owned(); + // Unconfined (the default): anywhere. + let mut s = server(); + call_json(&mut s, "execute_command", json!({"command": "comp.new", "params": {"name": "A", "width": 64, "height": 64}})); + call_json(&mut s, "save_project", json!({"path": p(&outside, "free.ecproj")})); + call_json(&mut s, "save_project", json!({"path": p(&read, "source.ecproj")})); + + let mut s = server(); + s.backend().confine(Confinement::new(std::slice::from_ref(&read), std::slice::from_ref(&write)).unwrap()); + let denied = |s: &mut McpServer, name: &str, args: Value| { + let (c, err) = call(s, name, args.clone()); + assert!(err, "{name} {args} should be refused: {c:?}"); + c[0]["text"].as_str().unwrap_or_default().to_string() + }; + // Reading: inside a read root, not outside. + call_json(&mut s, "open_project", json!({"path": p(&read, "source.ecproj")})); + let msg = denied(&mut s, "open_project", json!({"path": p(&outside, "free.ecproj")})); + assert!(msg.contains("outside the automation roots"), "{msg}"); + // Writing: inside a write root only; not into a read root, not relative, not with `..`. + call_json(&mut s, "save_project", json!({"path": p(&write, "saved.ecproj")})); + assert!(write.join("saved.ecproj").is_file()); + denied(&mut s, "save_project", json!({"path": p(&read, "copy.ecproj")})); + denied(&mut s, "save_project", json!({"path": "relative.ecproj"})); + denied(&mut s, "save_project", json!({"path": p(&write, "../elsewhere/up.ecproj")})); + denied(&mut s, "execute_command", json!({"command": "comp.saveFrameAs", "params": {"path": p(&outside, "f.png")}})); + // Any other absolute path a command is given. + denied(&mut s, "execute_command", json!({"command": "renderQueue.add", "params": {"output": p(&outside, "movie.mp4")}})); + // A batch step. + let steps = json!([{"command": "comp.new", "params": {"name": "B"}}, {"command": "file.saveAs", "params": {"path": p(&outside, "b.ecproj")}}]); + denied(&mut s, "batch", json!({"steps": steps})); + let steps = json!([{"command": "file.saveAs", "params": {"path": "$1.path"}}]); + denied(&mut s, "batch", json!({"steps": steps})); + // Save (and Increment and Save) write the open project's own file: it must be writable. + call_json(&mut s, "open_project", json!({"path": p(&read, "source.ecproj")})); + denied(&mut s, "save_project", json!({})); + denied(&mut s, "execute_command", json!({"command": "file.incrementAndSave"})); + // Rendering the queue writes its items' outputs (by default beside the project). + call_json(&mut s, "execute_command", json!({"command": "renderQueue.add", "params": {"comp": "A"}})); + let queue = call_json(&mut s, "execute_command", json!({"command": "renderQueue.list"})); + assert!(queue["items"][0]["outputPath"].as_str().is_some_and(|o| o.starts_with(&*read.to_string_lossy())), "{queue}"); + let msg = denied(&mut s, "execute_command", json!({"command": "renderQueue.render"})); + assert!(msg.contains("cannot write"), "{msg}"); + call_json(&mut s, "open_project", json!({"path": p(&write, "saved.ecproj")})); + call_json(&mut s, "save_project", json!({})); + // Through a symbolic link out of a root. + #[cfg(unix)] + { + std::os::unix::fs::symlink(&outside, write.join("link")).unwrap(); + denied(&mut s, "save_project", json!({"path": p(&write.join("link"), "escaped.ecproj")})); + assert!(!outside.join("escaped.ecproj").exists()); + } + // Scripts can read and write anything: refused. + denied(&mut s, "run_script", json!({"code": "1 + 1"})); + denied(&mut s, "execute_command", json!({"command": "file.runScript", "params": {"path": p(&read, "x.jsx")}})); + // Writes to the files the agent named: none outside. + assert_eq!(std::fs::read_dir(&outside).unwrap().count(), 1, "only the unconfined save"); + let _ = std::fs::remove_dir_all(&base); +} diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 12ea3167..fa86095b 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -103,12 +103,19 @@ pub fn available(bridge: bool) -> impl Iterator { TOOLS.iter().filter(move |t| bridge || !t.bridge_only) } +/// Tools refused while file access is confined: they drive the live app like a person (menus, +/// shortcuts, Open Recent…) or call control-channel methods directly, past the command checks. +const UNCONFINED_ONLY: &[&str] = &["control", "ui_click", "ui_drag", "ui_key", "ui_type", "ui_set", "script_ui"]; + /// Run a tool by name. pub fn run(b: &mut Backend, name: &str, args: &Value) -> Result { let t = find(name).ok_or_else(|| Error::BadArgs(format!("unknown tool `{name}`")))?; if t.bridge_only && !b.is_bridge() { return Err(Error::BadArgs(NEED_BRIDGE.into())); } + if b.confinement().is_some() && UNCONFINED_ONLY.contains(&name) { + return Err(Error::BadArgs(format!("`{name}` is not available while file access is confined (--automation-read-root/--automation-write-root)"))); + } let empty = json!({}); (t.run)(b, if args.is_null() { &empty } else { args }) } @@ -389,6 +396,9 @@ fn render_frame(b: &mut Backend, a: &Value) -> Result { let f = b.render_with(get(a, "comp"), get(a, "time").and_then(Value::as_f64), max_side, transparent)?; let mut info = json!({"comp": f.comp, "time": f.time, "width": f.width, "height": f.height}); if let Some(p) = get(a, "path").and_then(Value::as_str) { + if let Some(c) = b.confinement() { + c.check(p, crate::confine::Access::Write)?; + } std::fs::write(p, &f.png).map_err(|e| Error::Other(format!("cannot write {p}: {e}")))?; info["path"] = json!(p); } @@ -482,6 +492,9 @@ fn batch_tool(b: &mut Backend, a: &Value) -> Result { fn screenshot(b: &mut Backend, a: &Value) -> Result { let max_side = get(a, "max_side").and_then(Value::as_u64).unwrap_or(1600) as u32; let keep = get(a, "path").and_then(Value::as_str).map(str::to_string); + if let (Some(c), Some(p)) = (b.confinement(), &keep) { + c.check(p, crate::confine::Access::Write)?; + } let path = keep.clone().unwrap_or_else(|| std::env::temp_dir().join(format!("effectcraft-mcp-shot-{}.png", std::process::id())).to_string_lossy().into_owned()); b.control("ui.screenshot", obj(&[("path", Some(&json!(path))), ("panel", get(a, "panel")), ("id", get(a, "id"))]))?; diff --git a/docs/mcp.md b/docs/mcp.md index 0d7f83e9..1104927a 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3,6 +3,19 @@ Run `effectcraft-cli mcp` for a headless session, or `effectcraft-cli mcp --bridge 9877` to use the desktop app's control channel. Existing ports are unchanged. +## Confining file access + +`--automation-read-root DIR` and `--automation-write-root DIR` (each repeatable, as in +PhotoCraft) confine what the agent can reach: files are read only under a read or write root and +written only under a write root; with no write root nothing is written. Every engine command +(`execute_command`, `command_run`, each `batch` step) is checked before it runs: its file +parameters must be absolute paths inside the roots (symbolic links resolved, no `..`), and so must +any other absolute path in its parameters. Save, Increment and Save and rendering the Render +Queue are checked against the files they write (the open project's, the queued outputs). Scripts +(`run_script`, `file.runScript`…), Execute File, Reveal in Finder, `$N` references to file paths +in a batch, and the bridge's UI-driving tools (`ui_click`, `ui_key`, `ui_type`, `ui_drag`, +`ui_set`, `script_ui`, `control`) are refused while confined. Without the flags nothing changes. + The server provides the same core conventions as FilmCraft #28: | Tool | Arguments | Result | From e7735f08755b34bf21284b6684a56c516c799c28 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sun, 11 Oct 2026 10:20:10 +1100 Subject: [PATCH 101/107] Tests: pointer-wrap frames drain egui's texture deltas instead of dropping them. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Scrub helper and the real-properties test discarded FullOutput, so the first pass' font-atlas texture delta panicked on drop in debug builds — which is how the FreeBSD CI gate failed. Consume the delta the way the rest of the crate's egui tests do. --- crates/ui-egui/src/pointer_wrap.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/pointer_wrap.rs b/crates/ui-egui/src/pointer_wrap.rs index d4c29b17..a4aefe33 100644 --- a/crates/ui-egui/src/pointer_wrap.rs +++ b/crates/ui-egui/src/pointer_wrap.rs @@ -224,7 +224,7 @@ mod tests { }; raw.events.push(Event::ModifiersChanged(self.modifiers)); prepare_input(&self.ctx, &mut raw, self.injected); - let _ = self.ctx.run_ui(raw, |ui| { + let mut out = self.ctx.run_ui(raw, |ui| { if self.standard { ui.put( Rect::from_min_size(pos2(100.0, 100.0), vec2(60.0, 20.0)), @@ -266,6 +266,7 @@ mod tests { self.accept_warp.then_some(p) }); }); + out.textures_delta.clear(); } fn move_to(&mut self, p: Pos2) { @@ -482,7 +483,7 @@ mod tests { let mut raw = RawInput { screen_rect: Some(bounds), time: Some(time), events, ..Default::default() }; app.raw_input_hook(&ctx, &mut raw); let mut warp = None; - let _ = ctx.run_ui(raw, |ui| { + let mut out = ctx.run_ui(raw, |ui| { app.logic(ui.ctx(), &mut frame); app.ui(ui, &mut frame); finish_frame(ui.ctx(), bounds, |p| { @@ -490,6 +491,7 @@ mod tests { Some(p) }); }); + out.textures_delta.clear(); warp }; for _ in 0..4 { From da53c828dd7e926c478cf4469b173a515b2b6d4b Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sun, 11 Oct 2026 10:20:10 +1100 Subject: [PATCH 102/107] Credit Vikram Bhaskaran by real name and display name. --- contributors/people.toml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index 72e247c8..03f8c667 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -49,3 +49,8 @@ sync_github_name = false real_name = "Julen Llop" display_name = "Julen Llop" sync_github_name = false + +[people.vbasky] +real_name = "Vikram Bhaskaran" +display_name = "Vikram Bhaskaran" +sync_github_name = false From d247b98b95634a82614413496d715de8f8e97384 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 23:29:44 +0000 Subject: [PATCH 103/107] Viewer: dragging a layer no longer piles up frame renders until memory runs out (#647) Every viewer request spawns a job on the frame pool. A pool thread blocked inside a render (waiting on a parallel loop's join) runs other pool work meanwhile, rayon's injected jobs included: the spawn of the next request started a whole frame render on top of the waiting one. Dragging makes a new revision, and a new viewer frame, every UI frame, so renders nested without bound on every thread, each holding its buffers (a large image's full-resolution copies), until all memory was used. Holding the layer still makes no new revisions, which is why only moving it did this. A spawn now drains the queue, and one that lands inside a render on its thread returns at once; the render below takes the queued jobs when it is done. Each pool thread renders one frame at a time. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f --- crates/ui-egui/src/frames.rs | 115 +++++++++++++++++++++++++++++++++-- 1 file changed, 111 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/frames.rs b/crates/ui-egui/src/frames.rs index 33fd676d..ccffeb9f 100644 --- a/crates/ui-egui/src/frames.rs +++ b/crates/ui-egui/src/frames.rs @@ -768,10 +768,8 @@ impl Frames { #[cfg(not(target_arch = "wasm32"))] if self.remote.is_none() { let w = self.worker(); - // One spawn per job; each spawn renders whichever queued job matters most right now. - let job = move || { - w.run_next(); - }; + // One spawn per job; each spawn renders whichever queued jobs matter most right now. + let job = move || w.drain(); match &self.pool { Some(pool) => pool.spawn(job), None => rayon::spawn(job), @@ -924,7 +922,42 @@ struct Worker { gpu_retired: Arc, } +#[cfg(not(target_arch = "wasm32"))] +thread_local! { + /// This pool thread is inside [`Worker::drain`] (rendering a frame). + static DRAINING: std::cell::Cell = const { std::cell::Cell::new(false) }; +} + +/// Clears [`DRAINING`] when the drain ends, even by a panic. +#[cfg(not(target_arch = "wasm32"))] +struct Draining; + +#[cfg(not(target_arch = "wasm32"))] +impl Drop for Draining { + fn drop(&mut self) { + DRAINING.set(false); + } +} + impl Worker { + /// Render queued jobs, most important first, until the queue is empty. + /// + /// A pool thread waiting inside a render (on a parallel loop's join) runs other pool work + /// meanwhile, including the spawns of newer requests: each would start a whole frame render + /// on top of the one waiting, with all of its buffers. Dragging a layer asks for a new frame + /// every UI frame, so renders piled up on every thread until memory ran out (#647). A spawn + /// that lands inside a render returns at once instead; the render below it takes the queued + /// jobs when it is done, so each thread renders one frame at a time. + #[cfg(not(target_arch = "wasm32"))] + fn drain(&self) { + if DRAINING.get() { + return; + } + DRAINING.set(true); + let _reset = Draining; + while self.run_next() {} + } + /// Render the queued job that matters most right now. `false` when the queue was empty. fn run_next(&self) -> bool { let Worker { queue, content_keys, .. } = self; @@ -1234,6 +1267,80 @@ mod tests { assert!(!f.is_cached(&key)); } + /// #647: a pool thread waiting inside a render (on a parallel loop) never starts another + /// frame render on top of it. Dragging a layer queues a frame every UI frame; renders used to + /// nest on every thread, each holding its buffers, until memory ran out. + #[test] + fn a_waiting_render_never_starts_another_on_its_thread() { + use effectcraft_engine::Session; + use effectcraft_engine::project::{Footage, FootageKind}; + use effectcraft_engine::render::Image; + use effectcraft_engine::time::FrameRate; + use serde_json::json; + use std::cell::Cell; + use std::sync::atomic::AtomicUsize; + thread_local! { + static DEPTH: Cell = const { Cell::new(0) }; + } + /// A decoder that works in parallel, as the renderer does: its thread joins the loop and + /// may wait for the parts other threads took. + #[derive(Default)] + struct Parallel { + calls: AtomicUsize, + deepest: AtomicUsize, + } + impl FootageSource for Parallel { + fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option> { + let d = DEPTH.get() + 1; + DEPTH.set(d); + self.deepest.fetch_max(d, Ordering::SeqCst); + self.calls.fetch_add(1, Ordering::SeqCst); + (0..64).into_par_iter().for_each(|_| std::thread::sleep(std::time::Duration::from_millis(1))); + DEPTH.set(d - 1); + Some(Arc::new(Image::new(4, 4))) + } + } + let mut s = Session::default(); + let cid = ItemId(s.execute("comp.new", json!({"name": "A", "width": 8, "height": 8, "duration": 10})).unwrap()["comp"].as_u64().unwrap()); + let clip = Footage { + kind: FootageKind::Video, + width: 4, + height: 4, + has_video: true, + frame_rate: FrameRate::FPS_30, + duration: Tick::from_seconds_f64(10.0), + ..Default::default() + }; + let item = Arc::make_mut(&mut s.project).add_item("clip.mov", Default::default(), None, ItemKind::Footage(clip)); + s.execute("layer.addItem", json!({"item": item.0})).unwrap(); + let footage = Arc::new(Parallel::default()); + let src = RenderSource { + project: s.project.clone(), + footage: footage.clone(), + expr: None, + layer_cache: Arc::default(), + gpu: None, + gpu_display: false, + disk: None, + }; + let f = Frames::default(); + let content = f.content_of(&s.project, s.revision, cid); + let frames = 60; + for frame in 0..frames { + let key = FrameKey { revision: s.revision, content, comp: cid.0, frame, scale: 1000, view: 0, opts: 0 }; + f.request(&src, key, cid, FrameRate::FPS_30.tick_of(frame), RenderOpts::default()); + } + for _ in 0..4000 { + if f.inflight() == 0 { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert_eq!(f.inflight(), 0, "every queued frame rendered"); + assert!(footage.calls.load(Ordering::SeqCst) >= frames as usize); + assert_eq!(footage.deepest.load(Ordering::SeqCst), 1, "renders nested on a waiting thread"); + } + /// #284: scrubbing asks for a viewer frame at every step; a newer one drops the comp's viewer /// frames still waiting (they would all render first), but not prefetch frames or another /// comp's viewer frame. From 2aefe3e6445879a1d1856aecd43ee375c8fda57e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 23:35:47 +0000 Subject: [PATCH 104/107] Effects: CC Smear and CC Tiler on text and shape layers reach past the content's box (#659) A text or shape layer's effect bounds are comp-sized (as in After Effects), but its buffer surrounds only its content, and each effect grows the buffer itself when it needs room. CC Smear and CC Tiler never did, so a smear was cut off where the glyphs end and tiles filled only the text's box. CC Tiler now covers the whole layer rectangle; CC Smear grows by how far it moves pixels (Reach x From->To), at most to the layer bounds, so footage layers, whose buffer is their bounds already, are unchanged. CPU and GPU alike. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f --- crates/effects/src/distort2.rs | 5 ++- crates/effects/src/distort3.rs | 57 +++++++++++++++++++++++++++++++++- crates/effects/src/lib.rs | 2 +- crates/effects/src/util.rs | 11 +++++++ crates/gpu/src/fx_stylize.rs | 12 +++++-- 5 files changed, 81 insertions(+), 6 deletions(-) diff --git a/crates/effects/src/distort2.rs b/crates/effects/src/distort2.rs index f672d149..10ec5973 100644 --- a/crates/effects/src/distort2.rs +++ b/crates/effects/src/distort2.rs @@ -20,7 +20,7 @@ use effectcraft_raster::{Image, Px}; use rayon::prelude::*; use crate::generate::value_noise; -use crate::util::{SRC_NAMES, Src, fit_layer, layer_rect, lerp4, map_xy, pick, premul, remap, smoothstep, src_at, unpremul}; +use crate::util::{SRC_NAMES, Src, fit_layer, layer_cover_pad, layer_rect, lerp4, map_xy, pick, premul, remap, smoothstep, src_at, unpremul}; use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider}; fn spec(id: &'static str, name: &'static str, params: Vec, render: crate::RenderFn) -> EffectSpec { @@ -678,6 +678,9 @@ fn page_turn(ctx: &EffectCtx, mut b: Buf) -> Buf { fn cc_tiler(ctx: &EffectCtx, mut b: Buf) -> Buf { let s = (ctx.params.f("scale") / 100.0).max(0.001); + if !ctx.adjustment { + b.pad(layer_cover_pad(ctx, [b.img.width, b.img.height], b.offset, b.scale)); + } let c = b.to_px(ctx.params.v2("center")); let blend = (ctx.params.f("blendWithOriginal") / 100.0) as f32; let (x0, y0, w, h) = layer_rect(ctx, &b); diff --git a/crates/effects/src/distort3.rs b/crates/effects/src/distort3.rs index 27da970f..99139483 100644 --- a/crates/effects/src/distort3.rs +++ b/crates/effects/src/distort3.rs @@ -330,9 +330,12 @@ fn cc_slant(ctx: &EffectCtx, mut b: Buf) -> Buf { // ---------------------------------------------------------------- CC Smear fn cc_smear(ctx: &EffectCtx, mut b: Buf) -> Buf { + let reach = ctx.params.f("reach") / 100.0; + if !ctx.adjustment { + b.pad(cc_smear_pad(ctx, [b.img.width, b.img.height], b.offset, b.scale)); + } let from = b.to_px(ctx.params.v2("from")); let to = b.to_px(ctx.params.v2("to")); - let reach = ctx.params.f("reach") / 100.0; let radius = (ctx.params.f("radius") * b.scale).max(0.5); let (dx, dy) = (to.0 - from.0, to.1 - from.1); let l2 = dx * dx + dy * dy; @@ -351,6 +354,21 @@ fn cc_smear(ctx: &EffectCtx, mut b: Buf) -> Buf { b } +/// The transparent pixels to add on every side of a buffer `size` pixels big (with `offset` and +/// `scale`) for CC Smear: it moves pixels by up to Reach × From→To, and they may move out to the +/// layer bounds. A text or shape layer's buffer ends at its content while its bounds are +/// comp-sized, so the smear was cut off at the content's box (#659). +pub fn cc_smear_pad(ctx: &EffectCtx, size: [u32; 2], offset: [f64; 2], scale: f64) -> u32 { + let reach = ctx.params.f("reach") / 100.0; + let (from, to) = (ctx.params.v2("from"), ctx.params.v2("to")); + let len = ((to[0] - from[0]).powi(2) + (to[1] - from[1]).powi(2)).sqrt() * scale; + let moved = (reach.abs() * len).ceil(); + if !moved.is_finite() || moved <= 0.0 { + return 0; + } + (moved.min(4096.0) as u32).min(crate::util::layer_cover_pad(ctx, size, offset, scale)) +} + // ---------------------------------------------------------------- CC Split / CC Split 2 fn split_impl(ctx: &EffectCtx, mut b: Buf, s1: f64, s2: f64) -> Buf { @@ -1109,6 +1127,43 @@ mod tests { } } + /// A text layer's buffer: 20 × 10 opaque pixels at (90, 45)–(110, 55) of its comp-sized + /// (200 × 100) layer bounds, after `id` with `set`. + fn on_text(id: &str, set: &[(&str, Value)]) -> Buf { + let s = crate::find(id).unwrap(); + let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }; + for (k, v) in set { + assert!(params.values.contains_key(*k), "{id}: {k}"); + params.values.insert(k.to_string(), v.clone()); + } + let mut im = Image::new(20, 10); + im.data.iter_mut().for_each(|p| *p = [1.0; 4]); + let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [200.0, 100.0], seed: 1, adjustment: false, env: Default::default() }; + crate::apply(s, &ctx, Buf { img: im, offset: [-90.0, -45.0], scale: 1.0 }) + } + + /// Alpha at layer point `p` (0 outside the buffer). + fn alpha_at(b: &Buf, p: [f64; 2]) -> f32 { + let (x, y) = b.to_px(p); + b.img.get(x.floor() as i64, y.floor() as i64)[3] + } + + /// #659: CC Smear and CC Tiler on a text layer reach past the text's box to the layer bounds + /// (comp-sized, as in After Effects), not cut off where the glyphs end. + #[test] + fn effects_on_text_reach_past_its_box() { + let smear = on_text("ec.distort.ccsmear", &[("from", pt(100.0, 50.0)), ("to", pt(140.0, 50.0)), ("reach", num(100.0)), ("radius", num(30.0))]); + assert!(alpha_at(&smear, [130.0, 50.0]) > 0.9, "the smear is cut off at the text's box"); + assert!(smear.img.width < 200, "it grows only as far as pixels move"); + let tiles = on_text("ec.distort.cctiler", &[("scale", num(50.0)), ("center", pt(100.0, 50.0))]); + assert!(alpha_at(&tiles, [100.0, 50.0]) > 0.9); + assert!(alpha_at(&tiles, [2.0, 50.0]) > 0.9, "tiles fill the layer bounds"); + assert!(alpha_at(&tiles, [50.0, 50.0]) < 0.1); + // A footage layer's buffer is its bounds already: no change. + let o = run("ec.distort.ccsmear", &[], img(40, 30), &[]); + assert_eq!((o.img.width, o.img.height, o.offset), (40, 30, [0.0, 0.0])); + } + #[test] fn every_warp_style_runs_and_bends() { let im = img(40, 30); diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 45e2be1c..7e968297 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -78,7 +78,7 @@ pub use color2::{Curve, auto_correct_settings, color_link_mode, color_link_sampl pub use color3::OffsetCurve; pub use distort::transform_shutter; pub use distort2::{MAGNIFY_MODES, bezier_grid, parse_mesh}; -pub use distort3::{RESHAPE_POINTS, ReshapeSetup, SmearSetup, reshape_setup, smear_setup, warp_inverse_map}; +pub use distort3::{RESHAPE_POINTS, ReshapeSetup, SmearSetup, cc_smear_pad, reshape_setup, smear_setup, warp_inverse_map}; pub use distort4::liquify_mesh; use effectcraft_keyframe::Value; use effectcraft_project::build::Ids; diff --git a/crates/effects/src/util.rs b/crates/effects/src/util.rs index c3416618..1527e221 100644 --- a/crates/effects/src/util.rs +++ b/crates/effects/src/util.rs @@ -433,6 +433,17 @@ pub fn layer_rect_at(ctx: &crate::EffectCtx, offset: [f64; 2], scale: f64) -> (f (offset[0], offset[1], ctx.layer_size[0] * scale, ctx.layer_size[1] * scale) } +/// Transparent pixels to add on every side ([`crate::Buf::pad`]) of a buffer `size` pixels big, +/// with `offset` and `scale`, so it covers the whole layer rectangle (at most 4096). A text or +/// shape layer's buffer only surrounds its content, but its layer bounds are comp-sized (see +/// the renderer's `effect_bounds`): an effect that fills the layer, such as CC Tiler, fills +/// those as in After Effects (#659). +pub fn layer_cover_pad(ctx: &crate::EffectCtx, size: [u32; 2], offset: [f64; 2], scale: f64) -> u32 { + let (x0, y0, w, h) = layer_rect_at(ctx, offset, scale); + let need = [-x0, -y0, x0 + w - f64::from(size[0]), y0 + h - f64::from(size[1])].into_iter().fold(0.0, f64::max); + need.ceil().min(4096.0) as u32 +} + /// Resample another layer (a layer parameter) into `b`'s pixel grid. With `stretch` the other /// layer is scaled to this layer's size ("Stretch to Fit"); otherwise it is centred. pub fn fit_layer(ctx: &crate::EffectCtx, b: &crate::Buf, other: &crate::LayerPixels, stretch: bool) -> Image { diff --git a/crates/gpu/src/fx_stylize.rs b/crates/gpu/src/fx_stylize.rs index 8aee71ed..f5b7708d 100644 --- a/crates/gpu/src/fx_stylize.rs +++ b/crates/gpu/src/fx_stylize.rs @@ -299,10 +299,13 @@ fn cc_slant(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { ) } -fn cc_smear(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { +fn cc_smear(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { + let reach = ctx.params.f("reach") / 100.0; + if !ctx.adjustment { + b.pad(e, effectcraft_effects::cc_smear_pad(ctx, [b.img.width, b.img.height], b.offset, b.scale))?; + } let from = b.to_px(ctx.params.v2("from")); let to = b.to_px(ctx.params.v2("to")); - let reach = ctx.params.f("reach") / 100.0; let radius = (ctx.params.f("radius") * b.scale).max(0.5); let (dx, dy) = (to.0 - from.0, to.1 - from.1); let l2 = dx * dx + dy * dy; @@ -585,8 +588,11 @@ fn offset(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { table(e, b, &data, false, blend.max(0.0)) } -fn cc_tiler(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { +fn cc_tiler(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { let s = (ctx.params.f("scale") / 100.0).max(0.001); + if !ctx.adjustment { + b.pad(e, effectcraft_effects::util::layer_cover_pad(ctx, [b.img.width, b.img.height], b.offset, b.scale))?; + } let c = b.to_px(ctx.params.v2("center")); let blend = (ctx.params.f("blendWithOriginal") / 100.0) as f32; let (x0, y0, w, h) = layer_rect(ctx, &b); From cb37c2511cbc6cdb1828cf3b71eb57e66a2473d3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 23:36:52 +0000 Subject: [PATCH 105/107] App: a lost graphics device tells the user why the window closes and where the recovery copy is (#591) Ported from #673: a native error dialog names the auto-saved recovery copy before the window closes; agent runs through the control channel skip it. The UI calls Hooks::device_lost once. Co-Authored-By: Dennis M. Francis <44986763+didpublishing@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f --- apps/effectcraft/src/main.rs | 16 ++++++++++++++++ crates/ui-egui/src/lib.rs | 20 +++++++++++++++----- crates/ui-egui/tests/ui_gpu.rs | 4 ++++ 3 files changed, 35 insertions(+), 5 deletions(-) diff --git a/apps/effectcraft/src/main.rs b/apps/effectcraft/src/main.rs index a5269789..1b5f74f3 100644 --- a/apps/effectcraft/src/main.rs +++ b/apps/effectcraft/src/main.rs @@ -210,6 +210,22 @@ fn main() -> eframe::Result { app.hooks.pick_save = Some(Box::new(|name: &str| { rfd::FileDialog::new().add_filter("EffectCraft Project", &["ecproj"]).set_file_name(name).save_file().map(|p| p.to_string_lossy().to_string()) })); + // The window closes after a lost graphics device (#591); say why, unless an agent drives + // the app through the control channel. + if control_port.is_none() { + app.hooks.device_lost = Some(Box::new(|saved: Option>| { + let saved = match saved { + Some(Ok(path)) => format!("\n\nYour unsaved changes were saved to:\n{path}\n\nReopen EffectCraft to recover them."), + Some(Err(e)) => format!("\n\nYour unsaved changes could not be saved ({e})."), + None => String::new(), + }; + rfd::MessageDialog::new() + .set_level(rfd::MessageLevel::Error) + .set_title("EffectCraft: graphics device lost") + .set_description(format!("The graphics driver stopped responding, so EffectCraft has to close.{saved}")) + .show(); + })); + } app.hooks.pick_open_project = Some(Box::new(|| { rfd::FileDialog::new().add_filter("EffectCraft Project", &["ecproj", "ecprojx"]).pick_file().map(|p| p.to_string_lossy().to_string()) })); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index a9cf3f32..d0ed7315 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -159,6 +159,9 @@ pub struct Hooks { /// the commands' dialogs (File ▸ Import, Open, Save As…) where a modal dialog's nested event /// loop inside a frame is unsafe: on macOS a drag over the window then aborted the app (#511). pub file_dialog_async: Option>, + /// Tells the user the graphics device was lost and the window is closing, once. `saved` is + /// the auto-save of unsaved changes (`None`: nothing was unsaved), its path or why it failed. + pub device_lost: Option>)>>, } impl Hooks { @@ -697,14 +700,21 @@ impl EffectcraftApp { return; } let keep_recovery = self.session.is_dirty(); - if keep_recovery { - match self.session.autosave_now().and_then(|path| self.session.autosave_wait().map(|()| path)) { - Ok(path) => log::warn!("graphics device lost: auto-saved {path}; the next launch offers to recover it"), - Err(e) => log::error!("graphics device lost: {e}"), + let saved = keep_recovery.then(|| match self.session.autosave_now().and_then(|path| self.session.autosave_wait().map(|()| path)) { + Ok(path) => { + log::warn!("graphics device lost: auto-saved {path}; the next launch offers to recover it"); + Ok(path) } - } + Err(e) => { + log::error!("graphics device lost: {e}"); + Err(e.to_string()) + } + }); self.device_lost_close = Some(keep_recovery); log::warn!("graphics device lost: closing the window"); + if let Some(tell) = &self.hooks.device_lost { + tell(saved); + } ctx.send_viewport_cmd(egui::ViewportCommand::Close); } diff --git a/crates/ui-egui/tests/ui_gpu.rs b/crates/ui-egui/tests/ui_gpu.rs index 58536f16..906ac0cd 100644 --- a/crates/ui-egui/tests/ui_gpu.rs +++ b/crates/ui-egui/tests/ui_gpu.rs @@ -98,6 +98,9 @@ fn a_lost_device_draws_nothing_autosaves_and_closes() { s.execute("comp.new", json!({"name": "Unsaved", "width": 64, "height": 64, "duration": 1})).unwrap(); assert!(s.is_dirty()); let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(|_| EffectcraftApp::new(s)); + let told = std::rc::Rc::new(std::cell::RefCell::new(Vec::new())); + let log = told.clone(); + h.state_mut().hooks.device_lost = Some(Box::new(move |saved| log.borrow_mut().push(saved))); h.run_steps(3); assert!(!h.output().shapes.is_empty(), "the UI draws before the loss"); let bridge = effectcraft_ui_egui::gpu_failure::GpuFailureBridge::new(&h.ctx); @@ -111,5 +114,6 @@ fn a_lost_device_draws_nothing_autosaves_and_closes() { assert!(h.output().shapes.is_empty()); let saved = h.state().session.autosave.last_path.clone().expect("unsaved changes auto-saved"); assert!(std::path::Path::new(&saved).is_file(), "{saved}"); + assert_eq!(*told.borrow(), vec![Some(Ok(saved))], "the user is told once, with the recovery copy"); let _ = std::fs::remove_dir_all(&dir); } From 8e14c00eaf968e117a5244801aac4d4eef8e6e9e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 23:41:43 +0000 Subject: [PATCH 106/107] Project: footage saved with a relative path is found next to the project file (#652) file.import stores a path as given, so footage imported by the CLI or an agent as `clip.mp4` was saved relative, and opening the project looked it up from the working directory. The CLI, started in the project's folder, found it; the desktop app, started from the Finder, Explorer or a launcher in another folder, showed and rendered neither its picture nor its sound, while the Project panel still listed the saved duration. On open, a relative footage path that doesn't exist from the working directory but does next to the project file is resolved there (After Effects also looks for footage relative to the project). Paths that exist as they are, and absolute ones, are kept. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f --- crates/engine/src/commands/file.rs | 25 ++++++++++++++++++++++++- crates/engine/src/footage_check.rs | 24 ++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/commands/file.rs b/crates/engine/src/commands/file.rs index 55b2c2b6..6f3d2e70 100644 --- a/crates/engine/src/commands/file.rs +++ b/crates/engine/src/commands/file.rs @@ -43,7 +43,8 @@ pub(crate) fn open(s: &mut Session, p: &Value) -> Result { let text = String::from_utf8(bytes).map_err(|_| EngineError::Other("not a text project file".into()))?; // XML copies (File ▸ Save a Copy As XML…) open like the JSON project. let text = if crate::xml_project::is_xml(&text) { crate::xml_project::from_xml(&text).map_err(EngineError::Other)? } else { text }; - let proj = Project::from_json(&text).map_err(|e| EngineError::Other(format!("{path} is not a project EffectCraft can open: {e}")))?; + let mut proj = Project::from_json(&text).map_err(|e| EngineError::Other(format!("{path} is not a project EffectCraft can open: {e}")))?; + resolve_relative_footage(&mut proj, path, s.services.as_ref()); s.replace_project(proj, Some(path.to_string())); s.note_project_path(path); // Written by a newer EffectCraft: what this version doesn't know would be lost on saving. @@ -62,6 +63,28 @@ pub(crate) fn open(s: &mut Session, p: &Value) -> Result { Ok(json!({"path": path, "savedBy": saved_by})) } +/// Footage saved with a relative path (imported by the CLI or an agent as `clip.mp4`, or written +/// by hand) is found next to the project file, as After Effects looks for footage relative to +/// the project. It used to be looked up from the working directory: the CLI started in the +/// project's folder found it, the desktop app (started from the Finder, Explorer or a launcher, +/// in another folder) showed and rendered nothing (#652). A path that exists from the working +/// directory is kept. +fn resolve_relative_footage(p: &mut Project, project_path: &str, services: &dyn crate::Services) { + use std::path::Path; + let Some(dir) = Path::new(project_path).parent().filter(|d| !d.as_os_str().is_empty()) else { return }; + let beside = |f: &str| dir.join(f).to_string_lossy().to_string(); + for item in p.items.values_mut() { + let ItemKind::Footage(f) = &mut item.kind else { continue }; + if f.path.is_empty() || !Path::new(&f.path).is_relative() || services.exists(&f.path) || !services.exists(&beside(&f.path)) { + continue; + } + f.path = beside(&f.path); + for frame in f.sequence.iter_mut().filter(|q| Path::new(q.as_str()).is_relative()) { + *frame = beside(frame); + } + } +} + /// The project file's text for `path`: `.ecproj` JSON, or XML for `.ecprojx` / `.xml` (a /// project opened from an XML copy saves as XML again). Fails, writing nothing, when the project /// wouldn't read back. diff --git a/crates/engine/src/footage_check.rs b/crates/engine/src/footage_check.rs index c3765f1e..f5db284e 100644 --- a/crates/engine/src/footage_check.rs +++ b/crates/engine/src/footage_check.rs @@ -193,6 +193,30 @@ mod tests { let _ = std::fs::remove_dir_all(dir); } + /// #652: footage saved with a relative path is found next to the project, wherever the + /// app was started (the desktop app doesn't start in the project's folder, the CLI often + /// does). + #[test] + fn relative_footage_is_found_next_to_the_project() { + let dir = std::env::temp_dir().join(format!("ec-relative-{}", std::process::id())); + std::fs::create_dir_all(dir.join("media")).unwrap(); + let clip = dir.join("media").join("ec-relative-clip.png"); + std::fs::write(&clip, b"x").unwrap(); + let mut s = session_with(&["media/ec-relative-clip.png", "/nonexistent/ec/abs.png", "media/ec-relative-gone.png"]); + let path = dir.join("p.ecproj"); + s.execute("file.saveAs", json!({"path": path.to_string_lossy()})).unwrap(); + let mut o = Session::default(); + o.execute("file.open", json!({"path": path.to_string_lossy()})).unwrap(); + let mut paths: Vec = + o.project.items.values().filter_map(|i| if let ItemKind::Footage(f) = &i.kind { Some(f.path.clone()) } else { None }).collect(); + paths.sort(); + assert_eq!(paths, ["/nonexistent/ec/abs.png", clip.to_str().unwrap(), "media/ec-relative-gone.png"]); + assert!(!o.is_dirty(), "finding the files is no edit"); + let r = o.execute("footage.check", json!({"wait": true})).unwrap(); + assert_eq!(r["missing"], 2); + let _ = std::fs::remove_dir_all(dir); + } + #[test] fn open_starts_the_check_when_asked() { let mut s = session_with(&["/nonexistent/ec/a.mov"]); From f3fe25fd67d3afcda29a0ec2541ce9bbcbdb3ce9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 23:50:08 +0000 Subject: [PATCH 107/107] Import: test that an animated GIF imported from disk plays (#651) Animated GIFs came in as one still frame in 0.7.0; bfbdadd3 (after that release) probes them as movies. This end-to-end test imports a GIF file through the wired session into a comp and checks the layer shows each of its frames at its time, the path the viewer and exports share. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f --- Cargo.lock | 1 + crates/host/Cargo.toml | 1 + crates/host/src/lib.rs | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 34 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index cfab570d..4f6d9c87 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1350,6 +1350,7 @@ dependencies = [ "effectcraft-svg", "effectcraft-time", "exr", + "gif", "image", "rayon", "serde_json", diff --git a/crates/host/Cargo.toml b/crates/host/Cargo.toml index 13fcb87a..344d5433 100644 --- a/crates/host/Cargo.toml +++ b/crates/host/Cargo.toml @@ -31,5 +31,6 @@ effectcraft-raster = { workspace = true } effectcraft-time = { workspace = true } effectcraft-interchange = { workspace = true } image = { workspace = true } +gif = { workspace = true } exr = { workspace = true } rayon = { workspace = true } diff --git a/crates/host/src/lib.rs b/crates/host/src/lib.rs index 7f2d1a5c..cf5661a1 100644 --- a/crates/host/src/lib.rs +++ b/crates/host/src/lib.rs @@ -257,6 +257,38 @@ mod tests { assert!(red * 20 > img.data.len(), "red cube pixels: {red} of {}", img.data.len()); } + /// #651: an animated GIF imported from disk plays: the layer shows each frame at its time, + /// in the viewer's renders and in exports alike. + #[test] + fn an_imported_animated_gif_plays() { + let dir = std::env::temp_dir().join(format!("ec-gif-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("anim.gif"); + let mut bytes = Vec::new(); + { + let mut enc = gif::Encoder::new(&mut bytes, 4, 4, &[]).unwrap(); + for rgb in [[255u8, 0, 0], [0, 255, 0], [0, 0, 255]] { + let mut f = gif::Frame::from_rgb(4, 4, &rgb.repeat(16)); + f.delay = 10; + enc.write_frame(&f).unwrap(); + } + } + std::fs::write(&path, bytes).unwrap(); + let mut s = super::session(); + s.execute("comp.new", json!({"name": "G", "width": 4, "height": 4, "frameRate": 30, "duration": 1})).unwrap(); + s.execute("file.import", json!({"paths": [path.to_string_lossy()], "addToComp": true})).unwrap(); + let cid = s.active_comp_id().unwrap(); + let rgb = |t: f64| { + let img = s.render(cid, effectcraft_time::Tick::from_seconds_f64(t), effectcraft_engine::render::RenderOpts::default()); + let p = img.data[5]; + [p[0].round(), p[1].round(), p[2].round()] + }; + assert_eq!(rgb(0.05), [1.0, 0.0, 0.0]); + assert_eq!(rgb(0.15), [0.0, 1.0, 0.0]); + assert_eq!(rgb(0.25), [0.0, 0.0, 1.0]); + let _ = std::fs::remove_dir_all(dir); + } + /// The web app's path: outputs go to a sink (downloads), never to the file system. #[test] fn render_queue_exports_to_a_sink() {