Merge branch 'fix/f4': community issue fixes

This commit is contained in:
Claude 2026-10-10 20:00:37 +00:00
commit 8b42f970b6
No known key found for this signature in database
16 changed files with 368 additions and 19 deletions

View File

@ -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",

View File

@ -577,6 +577,14 @@ fn project_settings(s: &mut Session, p: &Value) -> Result<Value> {
),
};
}
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<CommandSpec> {
"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
),

View File

@ -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<String>,
/// 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<String>,
/// Expression syntax checker (set by the host that links the expression engine).
pub expr_check: Option<fn(&str) -> std::result::Result<(), String>>,
pub importer: Option<Arc<dyn Importer>>,
@ -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,

View File

@ -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")));

View File

@ -219,6 +219,23 @@ pub struct ProjectSettings {
/// sRGB; Rec. 2100 PQ / HLG give HDR output.
#[serde(default)]
pub output_space: Option<ColorSpace>,
/// 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,
}
}
}

View File

@ -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
}
}

View File

@ -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<Arc<Image>> {
@ -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];

View File

@ -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<ColorSpace>| {
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()
});
}

View File

@ -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,

View File

@ -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"; },

View File

@ -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<f32> {
.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));

View File

@ -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())));

View File

@ -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(

View File

@ -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();

View File

@ -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");
}

View File

@ -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}");
}