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/commands/file.rs b/crates/engine/src/commands/file.rs index fb557de0..f2347593 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/engine/src/lib.rs b/crates/engine/src/lib.rs index eae96a81..a9e9b606 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -372,6 +372,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>, @@ -523,6 +526,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 972bcdc3..183ae65f 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/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/dock.rs b/crates/ui-egui/src/dock.rs index ba5f5eaa..39be3f59 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -897,12 +897,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)), @@ -917,7 +912,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), @@ -984,6 +979,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 @@ -1180,6 +1196,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 @@ -1365,6 +1396,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/src/lib.rs b/crates/ui-egui/src/lib.rs index 64997057..92eba0fb 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -653,6 +653,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()))); diff --git a/crates/ui-egui/src/panels/forms.rs b/crates/ui-egui/src/panels/forms.rs index dfc00642..039ebb69 100644 --- a/crates/ui-egui/src/panels/forms.rs +++ b/crates/ui-egui/src/panels/forms.rs @@ -547,6 +547,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( diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 4104871f..086e46dc 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)); @@ -3609,6 +3653,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_tabs.rs b/crates/ui-egui/tests/ui_tabs.rs index d3a71ec3..8b52a72a 100644 --- a/crates/ui-egui/tests/ui_tabs.rs +++ b/crates/ui-egui/tests/ui_tabs.rs @@ -298,3 +298,50 @@ 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); +} + +/// 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"); +} diff --git a/crates/ui-egui/tests/ui_timeline_zoom.rs b/crates/ui-egui/tests/ui_timeline_zoom.rs index 5fb16943..2c802600 100644 --- a/crates/ui-egui/tests/ui_timeline_zoom.rs +++ b/crates/ui-egui/tests/ui_timeline_zoom.rs @@ -339,3 +339,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}"); +}