From f440adbb5a4931709a40609b9e209b46d6787c89 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 19:53:56 +0000 Subject: [PATCH 1/5] 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 2/5] 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 3/5] 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 4/5] 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 5/5] =?UTF-8?q?Color:=20Project=20Settings=20=E2=96=B8=20W?= =?UTF-8?q?orking=20Gamma=202.2=20/=202.4=20with=20no=20working=20space,?= =?UTF-8?q?=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(