Merge branch 'fix/f4': community issue fixes
This commit is contained in:
commit
8b42f970b6
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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")));
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"; },
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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())));
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue