From e6d5297a86cf16140b50048d3b10a3d5f5dac2e0 Mon Sep 17 00:00:00 2001 From: Egon 5000 <210198019+elZanco@users.noreply.github.com> Date: Fri, 16 Oct 2026 13:16:36 +0200 Subject: [PATCH 01/46] UI: the menu bar browses from the press until the release A title opens on the press and the menus follow the cursor while the button is held: hovering a category opens its submenu and releasing over an entry picks it. An outside press or a focus loss closes the open menu again. --- crates/ui-egui/src/menu_keys.rs | 39 ++++++++- crates/ui-egui/src/menus.rs | 134 ++++++++++++++++++++++++++++--- crates/ui-egui/tests/ui_menus.rs | 128 +++++++++++++++++++++++++++++ 3 files changed, 289 insertions(+), 12 deletions(-) diff --git a/crates/ui-egui/src/menu_keys.rs b/crates/ui-egui/src/menu_keys.rs index 397385309..9477e142b 100644 --- a/crates/ui-egui/src/menu_keys.rs +++ b/crates/ui-egui/src/menu_keys.rs @@ -10,7 +10,7 @@ //! shortcuts and the panels don't see them (Space must not start a preview). use egui::containers::menu::{MenuState, SubMenu, find_menu_root}; -use egui::{Context, Event, Id, Key, Modifiers, Popup, Pos2, Response, Ui}; +use egui::{Context, Event, Id, Key, Modifiers, Popup, Pos2, Rect, Response, Ui}; /// An entry of an open menu as drawn this frame. #[derive(Clone, Copy)] @@ -39,6 +39,12 @@ pub struct Nav { tops: Vec<(Id, Id)>, /// Open menus by depth: the top-level menu, then each open submenu. levels: Vec, + /// The titles and open menus drawn this frame: a press outside them is an outside press, + /// which closes the menu (native behaviour; the press itself still reaches what's under it). + rects: Vec, + /// A title press opened (or kept) the menu this frame: releasing the button over an entry + /// chooses it (After Effects' press-and-browse). + browsing: bool, } /// What to highlight once a menu opened from the keyboard has been drawn: the first entry of the @@ -73,6 +79,37 @@ impl Nav { self.tops.push((button.id, Popup::default_response_id(button))); } + /// The rect `rect` (a menu title or an open menu) holds, so a press there doesn't close. + pub fn cover(&mut self, rect: Rect) { + self.rects.push(rect); + } + + /// Whether `pos` is inside a title or menu drawn this frame. + pub fn covers(&self, pos: Pos2) -> bool { + self.rects.iter().any(|r| r.contains(pos)) + } + + /// A title press is in progress (its button still held): see [`Nav::browsing`]. + pub fn set_browsing(&mut self) { + self.browsing = true; + } + + /// Whether the menu bar is mid press-and-browse (a title was pressed this pointer-down and + /// hasn't been released yet): releasing over an entry then chooses it. + pub fn browsing(&self) -> bool { + self.browsing + } + + /// The popup of the open top-level menu (only one can be open), if any. + pub fn open_popup(&self, ctx: &Context) -> Option { + self.tops.iter().find(|(_, p)| Popup::is_id_open(ctx, *p)).map(|(_, p)| *p) + } + + /// The position in the bar of the open top-level menu, if any. + pub fn open_index(&self, ctx: &Context) -> Option { + self.tops.iter().position(|(_, p)| Popup::is_id_open(ctx, *p)) + } + /// An entry drawn at `depth` (0: a top-level menu) in the menu `ui`. Hovering an entry moves /// the keyboard highlight to it, and a highlighted entry scrolls into view. pub fn entry(&mut self, ui: &Ui, depth: usize, r: &Response, submenu: bool) { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index b1eba4f0c..dbdd32e39 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1974,32 +1974,118 @@ fn handle_shortcuts_impl(app: &mut EffectcraftApp, ctx: &egui::Context, tab_only } } +/// The id under which [`menu_bar`] remembers which of its menus was open at the end of the last +/// frame (its title's open look, and the hover that switches between the titles). +fn open_menu_id() -> egui::Id { + egui::Id::new("menu-bar-open") +} + +/// The id under which [`menu_bar`] remembers a title press still in progress (press-and-browse). +fn browse_menu_id() -> egui::Id { + egui::Id::new("menu-bar-browse") +} + /// Draw the in-window menu bar (the engine's AE menu tree). +/// +/// The titles open on the press, not the release (native menu bars): while one is held the menus +/// can be browsed by moving onto them, and releasing over an entry chooses it. Hovering another +/// title switches the open menu to it, and the open one closes on a press outside it or when the +/// window loses focus. The pointer leaving the window — onto the OS title bar, directly above the +/// bar — closes nothing, however long: the platform forwards none of the title bar's clicks to +/// egui (only the cursor leaving), so there is no click to close on and a graze must not drop the +/// menu. The closing press itself still reaches the widget under it, as a click does in After +/// Effects. pub fn menu_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui) { let ctx = ui.ctx().clone(); let mut clicked: Option<(String, Value)> = None; let mut nav = crate::menu_keys::Nav::default(); + // The open menu as of the last frame: the titles are drawn before this frame can open or + // close anything, and what happened last frame is what they must react to. + let open_prev: Option = ctx.data(|d| d.get_temp(open_menu_id())).flatten(); + if ctx.data(|d| d.get_temp(browse_menu_id())).unwrap_or(false) { + nav.set_browsing(); + } + let mut drawn = 0; ui.horizontal_centered(|ui| { ui.add_space(6.0); egui::MenuBar::new().config(egui::containers::menu::MenuConfig::new().style(crate::widgets::menu_style)).ui(ui, |ui| { for node in effectcraft_engine::menus::menu_bar() { - if let MenuNode::Submenu { label, children } = node { - let r = ui.menu_button(crate::i18n::label(app, "", label), |ui| { + let MenuNode::Submenu { label, children } = node else { continue }; + // The open title takes the open button's look while its menu is up (as in AE). + let inactive = ui.style().visuals.widgets.inactive; + if open_prev == Some(drawn) { + ui.style_mut().visuals.widgets.inactive = ui.style().visuals.widgets.open; + } + let mut response = ui.add(egui::Button::new(crate::i18n::label(app, "", label))); + ui.style_mut().visuals.widgets.inactive = inactive; + // A press on a title opens its menu at once (a second press on the open one closes + // it), and while it is held the menus below are browsable. The keyboard and the + // accessibility activation report `clicked` without the primary button (the press + // then happened somewhere else), and toggle the menu. Hovering another title + // switches to it once the pointer has moved: merely resting there must not take a + // keyboard-driven menu over. + let moved = ctx.input(|i| i.pointer.delta() != egui::Vec2::ZERO); + // With the button down elsewhere nothing is hovered, so the title under the + // pointer takes the highlight by hand: the hover-switch to it shows, one frame + // later like egui's own hover (the open title keeps its open look). + if moved && response.contains_pointer() && open_prev.is_some_and(|p| p != drawn) { + response = response.highlight(); + } + let pressed = ctx.input(|i| i.pointer.button_pressed(egui::PointerButton::Primary)) && response.hovered(); + let cmd = if pressed { + nav.set_browsing(); + Some(egui::SetOpenCommand::Bool(open_prev != Some(drawn))) + } else if response.clicked() && !response.clicked_by(egui::PointerButton::Primary) { + Some(egui::SetOpenCommand::Toggle) + } else if moved && response.enabled() && response.contains_pointer() && open_prev.is_some_and(|p| p != drawn) { + Some(egui::SetOpenCommand::Bool(true)) + } else { + None + }; + let inner = egui::Popup::menu(&response) + .close_behavior(egui::PopupCloseBehavior::IgnoreClicks) + .style(crate::widgets::menu_style) + .open_memory(cmd) + .show(|ui| { ui.set_min_width(if label == "Effect" { 200.0 } else { 280.0 }); crate::widgets::menu_scroll(ui, |ui| menu_nodes(app, ui, children, &mut clicked, &mut nav, 0)); }); - nav.top(&r.response); - app.auto.add(&format!("menu.{label}"), r.response.rect, label); + nav.top(&response); + // The titles and the open menus: a press inside them is not an outside press. + nav.cover(response.rect); + if let Some(inner) = &inner { + nav.cover(inner.response.rect); } + app.auto.add(&format!("menu.{label}"), response.rect, label); + drawn += 1; } }); }); crate::menu_keys::handle(&ctx, &nav); + // egui leaves the closing to us (`IgnoreClicks`), so the menus close here: at once on a press + // outside them (the press itself still reaches the widget below) and when the window loses + // focus. The pointer leaving the window closes nothing: moving onto the OS title bar fires + // the cursor-left event and the platform forwards none of its clicks to egui, so there is no + // click to close on — and a graze or a stay must not drop the menu, as in native apps. + if let Some(top) = nav.open_popup(&ctx) { + let focus_lost = ctx.input(|i| i.events.iter().any(|e| matches!(e, egui::Event::WindowFocused(false)))); + let outside_press = ctx.input(|i| i.pointer.any_pressed() && i.pointer.interact_pos().is_some_and(|p| !nav.covers(p))); + if focus_lost || outside_press { + egui::Popup::close_id(&ctx, top); + } + } if let Some((id, params)) = clicked && let Err(e) = invoke(app, &ctx, &id, if params.is_null() { json!({}) } else { params }) { app.ui.status = e; } + // What the next frame starts from: the open title, and a title press still in progress. + let open = nav.open_index(&ctx); + let browsing = open.is_some() && nav.browsing() && ctx.input(|i| i.pointer.primary_down()); + ctx.data_mut(|d| { + d.insert_temp(open_menu_id(), open); + d.insert_temp(browse_menu_id(), browsing); + }); } /// The entries of top-level menu `name` (the Effect menu is the Effect Controls panel's context @@ -2051,9 +2137,10 @@ fn menu_nodes( Some(t) => r.on_hover_text(t), None => r, }; + let r = browse_hover(nav, r); app.auto.add(&format!("menu.{name}.{i}"), r.rect, &e.label); nav.entry(ui, depth, &r, false); - if r.clicked() { + if r.clicked() || browse_pick(nav, ui, &r) { *clicked = Some((e.command.clone(), e.params.clone())); ui.close(); } @@ -2065,19 +2152,27 @@ fn menu_nodes( // every other submenu goes through the menu catalog. let shown = crate::i18n::effect_category(children, label) .unwrap_or_else(|| crate::i18n::submenu(app, label, effectcraft_engine::menus::submenu_label(&app.session, label, &dyn_ctx(&ws, &[])))); - let r = ui.menu_button((gutter(false), shown.as_str()), |ui| { + let browsing = nav.browsing(); + let egui::InnerResponse { inner, response } = ui.menu_button((gutter(false), shown.as_str()), |ui| { ui.set_min_width(if children.len() > 30 { 200.0 } else { 240.0 }); crate::widgets::menu_scroll(ui, |ui| menu_nodes(app, ui, children, clicked, nav, depth + 1)); }); - nav.entry(ui, depth, &r.response, true); - if r.inner.is_some() { - nav.opened(depth, &r.response); + let response = browse_hover(nav, response); + nav.entry(ui, depth, &response, true); + if inner.is_some() { + // `ui.menu_button` drops the popup's own response on the floor: read it back, + // so that a press inside this submenu doesn't count as an outside press. + let sub = egui::containers::menu::SubMenu::id_from_widget_id(response.id); + if let Some(resp) = ui.ctx().read_response(sub) { + nav.cover(resp.rect); + } + nav.opened(depth, &response); } } MenuNode::Item(e) => { - let r = menu_entry(app, ui, e); + let r = browse_hover(nav, menu_entry(app, ui, e)); nav.entry(ui, depth, &r, false); - if r.clicked() { + if r.clicked() || browse_pick(nav, ui, &r) { *clicked = Some((e.command.clone(), e.params.clone())); ui.close(); } @@ -2086,6 +2181,23 @@ fn menu_nodes( } } +/// The row `r` highlighted while the bar is mid press-and-browse: with a button down egui reports +/// nothing as hovered — which would leave browsing with no hover feedback at all — so the row +/// under the pointer takes the highlight by hand. It reads as the hover look one frame later, +/// exactly egui's own hover latency; disabled rows ignore it (`Noninteractive`), as in native +/// menus. Context menus use their own [`crate::menu_keys::Nav`], where the bar never browses. +fn browse_hover(nav: &crate::menu_keys::Nav, r: egui::Response) -> egui::Response { + if nav.browsing() && r.contains_pointer() { r.highlight() } else { r } +} + +/// Whether a press-and-browse (a menu-bar title still held down) releases over entry `r`, which +/// then chooses it like a click does. egui reports neither for it: the release is far from the +/// press, so there is no click, and nothing but the pressed widget is hovered while a button is +/// down, so the row has to be tested by the pointer being over it. +fn browse_pick(nav: &crate::menu_keys::Nav, ui: &egui::Ui, r: &egui::Response) -> bool { + nav.browsing() && r.enabled() && r.contains_pointer() && ui.input(|i| i.pointer.button_released(egui::PointerButton::Primary)) +} + /// The menu bar's submenu at `path` (`["File", "Interpret Footage"]`) in a panel's context menu, /// with the menu bar's labels, shortcuts and enabled state; a chosen entry goes to `clicked`. pub(crate) fn submenu_at(app: &mut EffectcraftApp, ui: &mut egui::Ui, path: &[&str], clicked: &mut Option<(String, Value)>) { diff --git a/crates/ui-egui/tests/ui_menus.rs b/crates/ui-egui/tests/ui_menus.rs index ca4c8cabc..a59599dfd 100644 --- a/crates/ui-egui/tests/ui_menus.rs +++ b/crates/ui-egui/tests/ui_menus.rs @@ -29,6 +29,25 @@ fn click_at(h: &mut Harness<'_, EffectcraftApp>, p: Pos2) { h.run_steps(2); } +/// Press the primary button at `p` and keep it down. +fn press(h: &mut Harness<'_, EffectcraftApp>, p: Pos2) { + h.input_mut().events.push(Event::PointerMoved(p)); + h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.run_steps(2); +} + +/// Move the pointer to `p` (press or no press, whichever is held). +fn move_to(h: &mut Harness<'_, EffectcraftApp>, p: Pos2) { + h.input_mut().events.push(Event::PointerMoved(p)); + h.run_steps(2); +} + +/// Release the primary button at `p`. +fn release(h: &mut Harness<'_, EffectcraftApp>, p: Pos2) { + h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + h.run_steps(2); +} + /// Press and release `key` (one frame each), then let the UI settle. fn key(h: &mut Harness<'_, EffectcraftApp>, key: Key) { h.input_mut().events.push(Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); @@ -244,3 +263,112 @@ fn space_previews_after_opening_the_demo_from_home() { key(&mut h, Key::Space); assert!(h.state().playback.playing, "Space previews the demo (#1059)"); } + +/// The titles open on the press, not the release, so the menus can be browsed with the button +/// still held down: moving onto an entry and releasing there chooses it (as in After Effects). +#[test] +fn a_menu_opens_on_press_and_browsing_releases_choose_an_entry() { + let mut h = harness(); + let edit = rect(&h, "menu.Edit"); + press(&mut h, edit.center()); + assert!(shown(&h, "Keyboard Shortcuts"), "the Edit menu opens on the press, before any release"); + let item = h.query_by_label_contains("Keyboard Shortcuts").expect("the Edit menu is open").rect(); + move_to(&mut h, item.center()); + release(&mut h, item.center()); + assert_eq!(h.state().dialog, Some(effectcraft_ui_egui::Dialog::Shortcuts), "the release over the entry chose it"); + assert!(!shown(&h, "Quick Apply"), "and closed the menu"); +} + +/// While a menu is open, hovering another title switches to that menu (native menu bars). +#[test] +fn hovering_another_title_while_a_menu_is_open_switches_to_it() { + let mut h = harness(); + let edit = rect(&h, "menu.Edit"); + click_at(&mut h, edit.center()); + assert!(shown(&h, "Keyboard Shortcuts"), "the Edit menu is open"); + let file = rect(&h, "menu.File"); + move_to(&mut h, file.center()); + assert!(shown(&h, "Open Project"), "hovering File opened the File menu"); + assert!(!shown(&h, "Keyboard Shortcuts"), "and closed Edit"); +} + +/// The press on the title of the open menu closes it again, and the click's release — a plain +/// click on the title — leaves it closed. +#[test] +fn pressing_the_open_title_again_closes_its_menu() { + let mut h = harness(); + let file = rect(&h, "menu.File"); + click_at(&mut h, file.center()); + assert!(shown(&h, "Open Project"), "the File menu is open"); + click_at(&mut h, file.center()); + assert!(!shown(&h, "Open Project"), "the second press closed it"); +} + +/// Press-and-hold browse (AE's gesture) reaches into a submenu: while the button is still held, +/// moving onto the submenu row opens it, and the release inside the submenu picks the entry. +#[test] +fn hold_browse_opens_a_submenu_and_the_release_inside_picks_an_entry() { + let mut h = harness(); + // Applying an effect needs a selected layer, as in the app after clicking one in the timeline. + let first = { + let cid = h.state().session.active_comp_id().expect("the demo opens a comp"); + let comp = h.state().session.project.comp(cid).expect("the demo comp exists"); + comp.layers.iter().find(|l| l.effects().is_some()).expect("the demo comp has a layer that takes effects").id + }; + h.state_mut().session.state.selected_layers = vec![first]; + let effect = rect(&h, "menu.Effect"); + press(&mut h, effect.center()); + assert!(shown(&h, "Effect Controls"), "the Effect menu opens on the press"); + // The categories come from the effect registry; the first alphabetically is "3D Channel". + let cat = h.query_by_label_contains("3D Channel").expect("the category row is shown").rect(); + move_to(&mut h, cat.center()); + assert!(shown(&h, "Cryptomatte"), "moving onto a category while still holding opened its submenu"); + let entry = h.query_by_label_contains("Cryptomatte").expect("the submenu is open").rect(); + move_to(&mut h, entry.center()); + release(&mut h, entry.center()); + assert!(!shown(&h, "Effect Controls"), "the release closed the menus"); + assert!(h.state().session.state.last_effect.is_some(), "and the release chose the entry: it applied its effect"); +} + +/// The open menus close on a press outside them and when the window loses focus. The pointer +/// leaving the window — onto the OS title bar, whose clicks are never forwarded to egui — closes +/// nothing, however long it stays away: there is no click to close on, and a graze or a stay +/// must not drop the menu. The closing press itself is not swallowed: it reaches the widget under +/// it, as a click does in After Effects. +#[test] +fn outside_presses_and_focus_loss_close_the_menus() { + let mut h = harness(); + let file = rect(&h, "menu.File"); + click_at(&mut h, file.center()); + assert!(shown(&h, "Open Project")); + // A press in the empty part of the bar, outside every title and menu. + click_at(&mut h, pos2(1100.0, 12.0)); + assert!(!shown(&h, "Open Project"), "a press outside the menus closes them"); + // The press reaches the header's workspace tab behind the open menu: the menu closes and the + // tab still acts (the menus don't swallow input). + click_at(&mut h, file.center()); + assert!(shown(&h, "Open Project")); + let tab = rect(&h, "header.workspace.Review"); + click_at(&mut h, tab.center()); + assert_eq!(h.state().ui.workspace, "Review", "the press reached the tab behind the open menu"); + assert!(!shown(&h, "Open Project"), "and closed the menu"); + // Moving onto the OS title bar fires the cursor-left event — its clicks are never forwarded + // to egui — which egui reports as `PointerGone`. It must not close the menus, however long + // the pointer stays away. + click_at(&mut h, file.center()); + assert!(shown(&h, "Open Project")); + h.input_mut().events.push(Event::PointerGone); + h.run_steps(1); + assert!(shown(&h, "Open Project"), "leaving the window alone doesn't drop the menus at once"); + let now = h.ctx.input(|i| i.time); + h.input_mut().time = Some(now + 60.0); + h.run_steps(2); + assert!(shown(&h, "Open Project"), "staying away doesn't close them either"); + h.input_mut().events.push(Event::PointerMoved(file.center())); + h.run_steps(2); + assert!(shown(&h, "Open Project"), "the pointer coming back keeps them open too"); + // Losing focus (alt-tabbing away, or clicking another window). + h.input_mut().events.push(Event::WindowFocused(false)); + h.run_steps(2); + assert!(!shown(&h, "Open Project"), "losing focus closes the menus"); +} From b7ae6603792cc14a1663aa9f241d5f34964577c7 Mon Sep 17 00:00:00 2001 From: Egon 5000 <210198019+elZanco@users.noreply.github.com> Date: Fri, 16 Oct 2026 13:16:36 +0200 Subject: [PATCH 02/46] UI: a menu scrolls from its edge while browsing with the button held Resting the pointer in an 18 pt band at a menu's bottom edge crawls the list after a short delay and speeds up the longer it stays there, so long menus stay reachable without letting go of the press-and-hold gesture. Context menus do not crawl. --- crates/ui-egui/src/menus.rs | 5 +- crates/ui-egui/src/panels/timeline.rs | 8 +-- crates/ui-egui/src/panels/viewer.rs | 2 +- crates/ui-egui/src/widgets.rs | 73 ++++++++++++++++++++++++++- crates/ui-egui/tests/ui_scrolling.rs | 73 +++++++++++++++++++++++++++ 5 files changed, 152 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index dbdd32e39..5be1e80a4 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -2048,7 +2048,8 @@ pub fn menu_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui) { .open_memory(cmd) .show(|ui| { ui.set_min_width(if label == "Effect" { 200.0 } else { 280.0 }); - crate::widgets::menu_scroll(ui, |ui| menu_nodes(app, ui, children, &mut clicked, &mut nav, 0)); + let browsing = nav.browsing(); + crate::widgets::menu_scroll(ui, browsing, |ui| menu_nodes(app, ui, children, &mut clicked, &mut nav, 0)); }); nav.top(&response); // The titles and the open menus: a press inside them is not an outside press. @@ -2155,7 +2156,7 @@ fn menu_nodes( let browsing = nav.browsing(); let egui::InnerResponse { inner, response } = ui.menu_button((gutter(false), shown.as_str()), |ui| { ui.set_min_width(if children.len() > 30 { 200.0 } else { 240.0 }); - crate::widgets::menu_scroll(ui, |ui| menu_nodes(app, ui, children, clicked, nav, depth + 1)); + crate::widgets::menu_scroll(ui, browsing, |ui| menu_nodes(app, ui, children, clicked, nav, depth + 1)); }); let response = browse_hover(nav, response); nav.entry(ui, depth, &response, true); diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 2de97a4b8..d9c5b2078 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -2605,7 +2605,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { egui::Popup::menu(&lang).style(crate::widgets::menu_style).show(|ui| { for (cat, items) in effectcraft_engine::commands::expr_tools::language_menu() { ui.menu_button(cat, |ui| { - widgets::menu_scroll(ui, |ui| { + widgets::menu_scroll(ui, false, |ui| { for (label, text) in items { if ui.button(label).clicked() { picked = Some(text); @@ -3926,7 +3926,7 @@ pub(crate) fn layer_menu(ui: &mut egui::Ui, layers: &[u64], caps: LayerMenuCaps, item(ui, "Add Marker", "layer.addMarker", json!({"layers": layers})); }); ui.menu_button(crate::i18n::tr("Blending Mode"), |ui| { - widgets::menu_scroll(ui, |ui| { + widgets::menu_scroll(ui, false, |ui| { for m in BlendMode::ALL { item(ui, m.label(), "layer.setBlendMode", json!({"layers": layers, "mode": m.label()})); if m.ends_group() { @@ -3960,11 +3960,11 @@ fn effects_submenu(ui: &mut egui::Ui, layers: &[u64], item: &mut dyn FnMut(&mut } } cats.sort_by_key(|(c, _)| c.to_ascii_lowercase()); - widgets::menu_scroll(ui, |ui| { + widgets::menu_scroll(ui, false, |ui| { for (cat, mut fx) in cats { fx.sort_by_key(|e| e.name.to_ascii_lowercase()); ui.menu_button(cat, |ui| { - widgets::menu_scroll(ui, |ui| { + widgets::menu_scroll(ui, false, |ui| { for e in fx { item(ui, e.name, "effect.apply", json!({"effect": e.id, "layers": layers})); } diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index 1064854f0..2eee93dde 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -1251,7 +1251,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let hits = ui.data(|d| d.get_temp::>(menu_hits_id)).unwrap_or_default(); if !hits.is_empty() { ui.menu_button(crate::i18n::tr("Select"), |ui| { - crate::widgets::menu_scroll(ui, |ui| { + crate::widgets::menu_scroll(ui, false, |ui| { for (id, name) in &hits { if ui.button(name).clicked() { context_actions.push(("layer.select".to_owned(), serde_json::json!({"layers":[id]}))); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index c005224d0..7aee3d0be 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -607,9 +607,78 @@ pub fn any_menu_open(ctx: &egui::Context) -> bool { /// whole: egui sizes a new menu from a default 400 pt area, so without a minimum a longer one /// (Window ▸ Workspace) got stuck at that height with its last entries scrolled out of view /// (#191). -pub fn menu_scroll(ui: &mut Ui, add: impl FnOnce(&mut Ui) -> R) -> R { +/// +/// While the bar is mid press-and-browse (`browsing`), resting the pointer against the menu's +/// bottom or top edge scrolls it from there — a crawl at first, then a run, as native menus do — +/// so a long list can be walked with no wheel. Without the hold the edges do nothing: the wheel +/// and the scroll bar are how a normally opened menu scrolls. +pub fn menu_scroll(ui: &mut Ui, browsing: bool, add: impl FnOnce(&mut Ui) -> R) -> R { let max_h = ui.ctx().content_rect().height() - 40.0; - egui::ScrollArea::vertical().max_height(max_h).min_scrolled_height(max_h).show(ui, add).inner + let out = egui::ScrollArea::vertical().max_height(max_h).min_scrolled_height(max_h).show(ui, add); + edge_scroll(ui, &out, browsing); + out.inner +} + +/// The edge scroll [`menu_scroll`] adds while browsing: with the pointer resting in the bottom +/// (top) band of an overflowing menu, its content crawls up (down) after a short pause, ramps to +/// a run and stops at the end — and stops at once when the pointer leaves the band or the hold. +/// +/// This runs after `ScrollArea::show`, which laid the menu out for the offset it had, so the new +/// offset goes into the area's state and shows on the next frame: a frame of lag, invisible at +/// these speeds. egui's own scrolling (wheel, drag, scroll bar) is untouched. +fn edge_scroll(ui: &Ui, out: &egui::containers::scroll_area::ScrollAreaOutput, browsing: bool) { + /// How far from the top / bottom edge the pointer starts the crawl (points). + const BAND: f32 = 18.0; + /// The pause before the crawl starts: a pointer merely grazing the edge must not jump. + const DELAY: f32 = 0.12; + /// Points per second: a crawl at first, reaching the run about three quarters of a second in. + const CRAWL: f32 = 45.0; + const RAMP: f32 = 700.0; + const RUN: f32 = 560.0; + + let ctx = ui.ctx(); + let over = out.content_size.y - out.inner_rect.height(); + let dir = match (browsing, over > 0.0, ctx.input(|i| i.pointer.latest_pos())) { + (true, true, Some(p)) if out.inner_rect.contains(p) => { + if p.y >= out.inner_rect.max.y - BAND { + 1 + } else if p.y <= out.inner_rect.min.y + BAND { + -1 + } else { + 0 + } + } + _ => 0, + }; + // The clock the pause counts from, kept per menu: entering or leaving an edge band restarts + // it, so a later stay there crawls off again instead of jumping straight to the run. + let now = ctx.input(|i| i.time); + let key = out.id.with("menu-edge-scroll"); + let (since, prev) = ctx.data(|d| d.get_temp::<(f64, i32)>(key)).unwrap_or((now, 0)); + let since = if dir == prev { + since + } else { + ctx.data_mut(|d| d.insert_temp(key, (now, dir))); + now + }; + if dir == 0 { + return; + } + if now - since < f64::from(DELAY) { + // A pointer resting still fires no events: keep the frames coming until the crawl runs. + ctx.request_repaint(); + return; + } + let speed = (CRAWL + RAMP * ((now - since) as f32 - DELAY)).min(RUN); + let crawl = ctx.input(|i| i.unstable_dt).min(0.05) * speed * dir as f32; + let new = (out.state.offset.y + crawl).clamp(0.0, over); + if new != out.state.offset.y { + let mut state = out.state; + state.offset.y = new; + state.store(ctx, out.id); + // At the end of the list the crawl stands still and asks for no more frames. + ctx.request_repaint(); + } } /// egui's menu style with no gap between the rows: the gap the app's item spacing left between diff --git a/crates/ui-egui/tests/ui_scrolling.rs b/crates/ui-egui/tests/ui_scrolling.rs index 5f6816230..a0bc1fe67 100644 --- a/crates/ui-egui/tests/ui_scrolling.rs +++ b/crates/ui-egui/tests/ui_scrolling.rs @@ -40,6 +40,33 @@ fn wheel(h: &mut Harness<'_, EffectcraftApp>, at: Pos2, dy: f32) { h.run_steps(20); } +/// Press the primary button at `p` and keep it down (press-and-browse, as on a menu title). +fn hold(h: &mut Harness<'_, EffectcraftApp>, p: Pos2) { + h.input_mut().events.push(Event::PointerMoved(p)); + h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.run_steps(2); +} + +/// Move the pointer to `p`, keeping whatever button is held. +fn move_to(h: &mut Harness<'_, EffectcraftApp>, p: Pos2) { + h.input_mut().events.push(Event::PointerMoved(p)); + h.run_steps(2); +} + +/// The open menu popup's rect (frame included), from egui's area memory: the only tall area up. +fn open_popup(h: &Harness<'_, EffectcraftApp>) -> Rect { + h.ctx + .memory(|m| { + m.areas() + .visible_layer_ids() + .into_iter() + .filter(|l| l.order != egui::Order::Background) + .filter_map(|l| m.area_rect(l.id)) + .find(|r| r.height() > 100.0) + }) + .unwrap_or_else(|| panic!("no menu popup is open")) +} + /// Edit is taller than a short window: it scrolls (with the wheel, and a scroll bar that always /// shows) so its last entries can be reached and clicked, instead of running off the bottom of /// the window (#269). @@ -59,6 +86,52 @@ fn a_menu_taller_than_the_window_scrolls() { assert_eq!(h.state().dialog, Some(effectcraft_ui_egui::Dialog::Shortcuts), "the entry takes the click"); } +/// With the title still held down (press-and-browse), resting the pointer against the bottom +/// edge of a menu too tall for the window scrolls it from there — a crawl, then a run, as native +/// menus do — so the long list can be walked with no wheel. +#[test] +fn holding_the_title_scrolls_the_menu_from_its_bottom_edge() { + let mut h = harness(1200.0, 520.0); + let edit = rect(&h, "menu.Edit"); + hold(&mut h, edit.center()); + let before = h.query_by_label_contains("Keyboard Shortcuts").expect("the Edit menu opens on the press").rect(); + assert!(before.max.y > 520.0, "Edit is taller than the window ({before:?})"); + let menu = open_popup(&h); + // Creeping down the edge: the 18 pt band above the menu's inner bottom lies at one of these + // positions, whatever the frame's margin (they are 6 pt apart). + let mut walked = false; + for step in 0..5 { + move_to(&mut h, pos2(menu.center().x, menu.max.y - 6.0 - 6.0 * step as f32)); + h.run_steps(8); + let now = h.query_by_label_contains("Keyboard Shortcuts").expect("still open").rect(); + if now.min.y < before.min.y { + walked = true; + break; + } + } + assert!(walked, "resting on the bottom edge of the held-down menu scrolled its entries up"); +} + +/// Without the title held down — the menu opened by a plain click — the same edge rest scrolls +/// nothing: the wheel (above) is how a click-opened menu scrolls, the pointer alone must not +/// move it (a hover may align the row under it by a few points; a crawl there would be a bug). +#[test] +fn without_the_hold_the_edge_does_not_scroll_the_menu() { + let mut h = harness(1200.0, 520.0); + let edit = rect(&h, "menu.Edit"); + click_at(&mut h, edit.center()); + let before = h.query_by_label_contains("Keyboard Shortcuts").expect("the Edit menu is open").rect(); + assert!(before.max.y > 520.0, "Edit is taller than the window ({before:?})"); + let menu = open_popup(&h); + for step in 0..5 { + move_to(&mut h, pos2(menu.center().x, menu.max.y - 6.0 - 6.0 * step as f32)); + h.run_steps(8); + let now = h.query_by_label_contains("Keyboard Shortcuts").expect("still open").rect(); + let crawled = before.min.y - now.min.y; + assert!(crawled < 60.0, "step {step}: the edge scrolled the menu with no title held down ({before:?} -> {now:?})"); + } +} + fn drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2) { h.input_mut().events.push(Event::PointerMoved(from)); h.input_mut().events.push(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); From c1d411b2495f49ead824876339d92d19e06eb030 Mon Sep 17 00:00:00 2001 From: iannitram <4624371+iannitram@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:48:07 -0600 Subject: [PATCH 03/46] Tests: serialise disk-cache-opening tests with the roto tests The Roto/Object Matte segment store is one per process (roto::set_seg_store), and every Session::configure_disk_cache that opens a cache takes it over. The roto and disk-cache tests already serialise on hold_roto_cache_test_lock(), but many other engine tests build a Session with a settings store, which opens the disk cache at the platform folder and takes the store; once that session drops, the weak store is dead and object_matte_mattes_persist_in_the_disk_cache sees no disk writes. It failed 3 of 3 in the full parallel suite here. configure_disk_cache now takes that lock under cfg(test) before opening a cache, so every such test runs apart from the roto tests. Re-entry on the same thread is a no-op. Product code is unchanged: only the app's one session ever opens a disk cache, so users cannot hit the race. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/session_settings.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/engine/src/session_settings.rs b/crates/engine/src/session_settings.rs index ef3e748a6..0b457926c 100644 --- a/crates/engine/src/session_settings.rs +++ b/crates/engine/src/session_settings.rs @@ -116,6 +116,11 @@ impl Session { self.layer_cache.set_disk(None); return; }; + // Tests: the Roto/Object Matte segment store below is one per process, so a test that + // opens a disk cache (often the platform folder, through a settings store) would take + // over the store of a roto test running beside it. Serialise them with the roto tests. + #[cfg(test)] + crate::tests_roto::hold_roto_cache_test_lock(); let dc = match &self.disk_cache { Some(c) if c.folder() == folder.as_path() => { c.set_max_bytes(max); From 76098665b0d78f3f42f22275b1b8e8bd8442baf6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Kozlovsk=C3=BD?= Date: Mon, 19 Oct 2026 05:36:34 +0200 Subject: [PATCH 04/46] Cryptomatte: effect.pickCryptomatte picks the ID under a point into the Selection (#473) Engine part of viewer picking: replace / add / remove the top-coverage ID at a layer-space point in one undo step, at the time the Selection was read. The aux channels come from the render's own path (Renderer::layer_aux, shared with the effect host; footage at its source time, 3D precomps) through a renderer set up like Session::render (expressions, render prefs, layer cache). An optional scale snaps the point to the pixel a preview at that resolution samples. New effects::crypto_select quotes names and falls back to for unnamed, wildcard-looking or ambiguous names. effect.cryptomatteAt is the read-only hover query. --- crates/effects/src/channel3d.rs | 2 +- crates/effects/src/crypto_select.rs | 122 +++++++++++++++++ crates/effects/src/lib.rs | 1 + crates/engine/src/commands/effect.rs | 100 ++++++++++++++ crates/engine/src/lib.rs | 10 +- crates/engine/src/tests_crypto_pick.rs | 181 +++++++++++++++++++++++++ crates/host/tests/cryptomatte_pick.rs | 127 +++++++++++++++++ crates/raster/src/channels3d.rs | 47 +++++++ crates/render/src/lib.rs | 49 ++++--- docs/agents.md | 5 +- 10 files changed, 620 insertions(+), 24 deletions(-) create mode 100644 crates/effects/src/crypto_select.rs create mode 100644 crates/engine/src/tests_crypto_pick.rs create mode 100644 crates/host/tests/cryptomatte_pick.rs diff --git a/crates/effects/src/channel3d.rs b/crates/effects/src/channel3d.rs index 7884e8084..7c4f0e1df 100644 --- a/crates/effects/src/channel3d.rs +++ b/crates/effects/src/channel3d.rs @@ -326,7 +326,7 @@ fn wild(pat: &str, name: &str) -> bool { /// Split a selection at its commas. A name in double or single quotes stays whole, commas /// included (Blender allows `Green, Glossy`), and a backslash takes the next character /// literally (`Green\, Glossy`, `\"`). Unquoted text is trimmed; the quotes are dropped. -fn selection_names(sel: &str) -> Vec { +pub(crate) fn selection_names(sel: &str) -> Vec { let mut out = vec![]; let mut cur = String::new(); let mut quote: Option = None; diff --git a/crates/effects/src/crypto_select.rs b/crates/effects/src/crypto_select.rs new file mode 100644 index 000000000..19d93cdba --- /dev/null +++ b/crates/effects/src/crypto_select.rs @@ -0,0 +1,122 @@ +//! Editing a Cryptomatte effect's Selection text from a pick in the viewer (#473): replace it +//! with the picked ID, add it, or remove it, in the grammar `selection_hashes` reads. + +use crate::channel3d::{selection_hashes, selection_names}; + +/// How a pick changes the selection. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PickMode { + Replace, + Add, + Remove, +} + +/// A name as a selection token: quoted (with `\` escapes) when it has a comma, a quote or a +/// backslash, which the selection parser would otherwise split on or read as syntax. +fn quote(name: &str) -> String { + if !name.contains([',', '"', '\'', '\\']) { + return name.to_string(); + } + let mut out = String::from("\""); + for c in name.chars() { + if c == '"' || c == '\\' { + out.push('\\'); + } + out.push(c); + } + out.push('"'); + out +} + +/// The selection token for the ID `hash` named `name` in `manifest`: the (quoted) name when it +/// reads back as exactly that ID, else ``. That covers unnamed IDs, names with `*` (which +/// would select by wildcard), names that look like ``, names with surrounding spaces (the +/// parser trims them) and names shared by several IDs. +pub fn token_for(name: Option<&str>, hash: u32, manifest: Option<&[(String, u32)]>) -> String { + match name { + Some(n) if !n.is_empty() && !n.contains('*') && selection_hashes("e(n), manifest) == [hash] => quote(n), + _ => format!("<{hash:08x}>"), + } +} + +/// `sel` edited by a pick of the ID `hash` whose token is `token` (see [`token_for`]). The text +/// stays as typed when the edit wouldn't change what it selects: Replace when it selects just +/// that ID, Add when it already selects it, Remove when no name or `` token selects it (a +/// wildcard that matches it stays). Other tokens (wildcards included) keep their text, +/// re-quoted where needed, separated by `, `. +pub fn edit_selection(sel: &str, token: &str, hash: u32, mode: PickMode, manifest: Option<&[(String, u32)]>) -> String { + let tokens: Vec = selection_names(sel).into_iter().filter(|t| !t.is_empty()).collect(); + let selects = |t: &String| !t.contains('*') && selection_hashes("e(t), manifest).contains(&hash); + let kept: Vec = match mode { + PickMode::Replace if selection_hashes(sel, manifest) == [hash] => return sel.to_string(), + PickMode::Replace => return token.to_string(), + PickMode::Add if selection_hashes(sel, manifest).contains(&hash) => return sel.to_string(), + PickMode::Add => tokens.iter().map(|t| quote(t)).chain([token.to_string()]).collect(), + PickMode::Remove if !tokens.iter().any(selects) => return sel.to_string(), + PickMode::Remove => tokens.iter().filter(|t| !selects(t)).map(|t| quote(t)).collect(), + }; + kept.join(", ") +} + +#[cfg(test)] +mod tests { + use super::*; + use effectcraft_raster::channels3d::crypto_hash; + + fn manifest() -> Vec<(String, u32)> { + ["bunny", "Green, Glossy", r#"say "hi""#, r"back\slash", " pad ", "star*", "<0000abcd>", "twin"] + .iter() + .map(|n| (n.to_string(), crypto_hash(n))) + .chain([("twin".to_string(), 7)]) + .collect() + } + + /// Every name's token reads back as exactly its ID, through the effect's own parser. + #[test] + fn tokens_round_trip_through_selection_hashes() { + let m = manifest(); + for (n, h) in &m { + let t = token_for(Some(n), *h, Some(&m)); + assert_eq!(selection_hashes(&t, Some(&m)), [*h], "{n:?} → {t:?}"); + } + assert_eq!(token_for(Some("bunny"), crypto_hash("bunny"), Some(&m)), "bunny"); + assert_eq!(token_for(Some("Green, Glossy"), crypto_hash("Green, Glossy"), Some(&m)), r#""Green, Glossy""#); + assert_eq!(token_for(Some(r#"say "hi""#), crypto_hash(r#"say "hi""#), Some(&m)), r#""say \"hi\"""#); + // Wildcard-looking, hash-looking, empty, unnamed and shared names: the hash. + assert_eq!(token_for(Some("star*"), crypto_hash("star*"), Some(&m)), format!("<{:08x}>", crypto_hash("star*"))); + assert_eq!(token_for(Some("<0000abcd>"), crypto_hash("<0000abcd>"), Some(&m)), format!("<{:08x}>", crypto_hash("<0000abcd>"))); + assert_eq!(token_for(Some(""), 5, Some(&m)), "<00000005>"); + assert_eq!(token_for(Some(" pad "), crypto_hash(" pad "), Some(&m)), format!("<{:08x}>", crypto_hash(" pad "))); + assert_eq!(token_for(None, 0xdead_beef, None), ""); + assert_eq!(token_for(Some("twin"), 7, Some(&m)), "<00000007>", "the name selects the other twin"); + } + + #[test] + fn add_and_remove_keep_other_tokens_and_skip_duplicates() { + let m = manifest(); + let (b, g) = (crypto_hash("bunny"), crypto_hash("Green, Glossy")); + let tok = |n: &str| token_for(Some(n), crypto_hash(n), Some(&m)); + let edit = |sel: &str, n: &str, mode| edit_selection(sel, &tok(n), crypto_hash(n), mode, Some(&m)); + assert_eq!(edit("anything, else", "bunny", PickMode::Replace), "bunny"); + assert_eq!(edit(" bunny ", "bunny", PickMode::Replace), " bunny ", "same meaning: unchanged"); + assert_eq!(edit("", "bunny", PickMode::Add), "bunny"); + assert_eq!(edit("bunny", "Green, Glossy", PickMode::Add), r#"bunny, "Green, Glossy""#); + // Already selected: by name, by hash, by a wildcard. The text stays as typed. + assert_eq!(edit("bunny,floor", "bunny", PickMode::Add), "bunny,floor"); + assert_eq!(edit(&format!("<{b:08x}>"), "bunny", PickMode::Add), format!("<{b:08x}>")); + assert_eq!(edit("bun*", "bunny", PickMode::Add), "bun*"); + // Other tokens keep their meaning: quoted where needed, wildcards stay wildcards. + assert_eq!(edit(r#"'a, b', fl*, "x, *""#, "bunny", PickMode::Add), r#""a, b", fl*, "x, *", bunny"#); + assert_eq!(edit(r#""Green, Glossy", bunny"#, "bunny", PickMode::Remove), r#""Green, Glossy""#); + assert_eq!(edit(&format!("bunny, <{b:08x}>, x, bunny"), "bunny", PickMode::Remove), "x"); + assert_eq!(edit(&format!("<{g:08x}>"), "Green, Glossy", PickMode::Remove), ""); + // Not selected, or only by a wildcard: unchanged. + assert_eq!(edit("a ,b", "bunny", PickMode::Remove), "a ,b"); + assert_eq!(edit("bun*, x", "bunny", PickMode::Remove), "bun*, x"); + for sel in [r#""a, b", fl*, bunny"#, r#"x, "Green, Glossy", "say \"hi\"", "back\\slash""#] { + let before = selection_hashes(sel, Some(&m)); + let after = selection_hashes(&edit(sel, " pad ", PickMode::Add), Some(&m)); + assert_eq!(after, [before, vec![crypto_hash(" pad ")]].concat(), "{sel}"); + } + } +} diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 40ed79a7d..773bef07c 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -21,6 +21,7 @@ mod color2; mod color3; mod color_fx; pub mod controls; +pub mod crypto_select; mod distort; mod distort2; mod distort3; diff --git a/crates/engine/src/commands/effect.rs b/crates/engine/src/commands/effect.rs index ce773e545..fca4baa5b 100644 --- a/crates/engine/src/commands/effect.rs +++ b/crates/engine/src/commands/effect.rs @@ -186,6 +186,91 @@ fn pick_color(s: &mut Session, p: &Value) -> Result { Ok(json!({"prop": uid, "color": color})) } +/// What a Cryptomatte effect has at a layer-space point: the ID with the most coverage in its +/// Cryptomatte layer, with the aux channels (for the manifest) and the effect's Selection. +struct CryptoHit { + at: (effectcraft_project::ItemId, LayerId, Tick), + aux: std::sync::Arc, + layer: String, + hash: u32, + selection: String, + prop: Uid, + /// The Selection has an expression: setting its value wouldn't change what it reads. + driven: bool, +} + +impl CryptoHit { + fn manifest(&self) -> Option<&[(String, u32)]> { + self.aux.manifests.iter().find(|(l, _)| *l == self.layer).map(|(_, m)| m.as_slice()) + } + fn reply(&self, sel: &str) -> Value { + let selected = effectcraft_effects::selection_hashes(sel, self.manifest()).contains(&self.hash); + let name = self.aux.manifest_name(&self.layer, self.hash); + json!({"name": name, "hash": format!("{:08x}", self.hash), "layer": self.layer, "selected": selected}) + } +} + +/// The ID under `{x, y}` (effect space, which is layer space on footage and precomp layers) for +/// the Cryptomatte effect `{layer?, effect?}`, from the aux channels its render reads. The point +/// snaps to the centre of the pixel a render at `scale` (the viewer's resolution) samples. +fn crypto_hit(s: &Session, p: &Value, cmd: &str) -> Result> { + let (cid, lid, fx_uid) = find_fx(s, p, cmd)?; + let (Some(x), Some(y)) = (f_p(p, "x"), f_p(p, "y")) else { return Err(bad(cmd, "missing `x` / `y` (layer pixels)")) }; + let scale = f_p(p, "scale").unwrap_or(1.0); + if !(scale.is_finite() && scale > 0.0 && scale <= 1.0) { + return Err(bad(cmd, "`scale` is the preview's resolution factor, above 0 and at most 1")); + } + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + let layer = comp.layer(lid).ok_or(EngineError::NoComp)?; + let g = layer.effects().and_then(|fx| fx.groups().find(|g| g.uid == fx_uid)).ok_or_else(|| bad(cmd, "no such effect"))?; + let prop = g.prop("selection").filter(|_| g.match_id == "ec.3d.cryptomatte").ok_or_else(|| bad(cmd, "not a Cryptomatte effect"))?; + let t = super::time_p(s, p, Some(comp)); + let ctx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None }; + let params = effectcraft_effects::flatten_params(g, &mut |pr| ctx.value(layer, pr)); + let aux = s.renderer(effectcraft_render::RenderOpts { scale, ..Default::default() }).layer_aux(&ctx, layer); + let want = effectcraft_effects::CRYPTO_LAYERS.get(params.e("layer") as usize).copied().unwrap_or("CryptoAsset"); + let Some((aux, layer_name)) = aux.and_then(|a| a.resolve_crypto_layer(want).map(|l| (a, l))) else { + return Err(bad(cmd, "this layer has no Cryptomatte channels (a multi-layer OpenEXR with Cryptomatte, or a 3D precomp)")); + }; + let snap = |v: f64| ((v * scale).floor() + 0.5) / scale; + let Some(hash) = aux.index_at(snap(x), snap(y)).and_then(|i| aux.crypto_id_at(&layer_name, i)) else { return Ok(None) }; + let (selection, driven) = (params.s("selection").to_string(), prop.expr.as_ref().is_some_and(|e| e.enabled)); + Ok(Some(CryptoHit { at: (cid, lid, t), aux, layer: layer_name, hash, selection, prop: prop.uid, driven })) +} + +/// `effect.cryptomatteAt`: the ID under a point, for the viewer's hover hint. +fn crypto_at(s: &mut Session, p: &Value) -> Result { + Ok(crypto_hit(s, p, "effect.cryptomatteAt")?.map_or(Value::Null, |h| h.reply(&h.selection))) +} + +/// The Cryptomatte eyedropper: replace the effect's Selection with the ID under a point, add it +/// or remove it, in one undo step at the time it was read (none when its meaning stays). +fn pick_crypto(s: &mut Session, p: &Value) -> Result { + use effectcraft_effects::crypto_select::{PickMode, edit_selection, token_for}; + let cmd = "effect.pickCryptomatte"; + let mode = match str_p(p, "mode").unwrap_or("replace") { + "replace" => PickMode::Replace, + "add" => PickMode::Add, + "remove" => PickMode::Remove, + m => return Err(bad(cmd, format!("unknown `mode` {m:?} (replace, add or remove)"))), + }; + let h = crypto_hit(s, p, cmd)?.ok_or_else(|| bad(cmd, "no Cryptomatte ID there (empty or outside the layer)"))?; + if h.driven { + return Err(bad(cmd, "the Selection is driven by an expression; disable it to pick")); + } + let m = h.manifest(); + let sel = edit_selection(&h.selection, &token_for(h.aux.manifest_name(&h.layer, h.hash), h.hash, m), h.hash, mode, m); + let changed = sel != h.selection; + if changed { + let (cid, lid, t) = h.at; + s.execute("prop.set", json!({"comp": cid.0, "layer": lid.0, "prop": h.prop, "value": sel, "time": t.seconds(), "timeBase": "comp"}))?; + } + let mut out = h.reply(&sel); + out["selection"] = json!(sel); + out["changed"] = json!(changed); + Ok(out) +} + fn remove(s: &mut Session, p: &Value) -> Result { let cid = super::comp_id(s, p)?; let fx = effects_p(s, p, "effect.remove")?; @@ -632,6 +717,21 @@ pub fn specs() -> Vec { has_layers, pick_color ), + cmd!( + "effect.pickCryptomatte", + "Pick Cryptomatte", + [], + None, + "{layer?, effect?: index|uid|name, x, y (effect space), mode?: replace|add|remove (default replace), scale? (preview resolution, default 1), time?} → {name, hash, layer, selection, changed, selected} — edits the Cryptomatte effect's Selection with the ID under the point", + has_layers, + pick_crypto + ), + crate::query!( + "effect.cryptomatteAt", + "Cryptomatte At", + "{layer?, effect?: index|uid|name, x, y (effect space), scale?, time?} → {name, hash, layer, selected} | null — the Cryptomatte ID under the point", + crypto_at + ), cmd!("effect.applyLast", "Last Effect", ["Effect"], Some("Cmd+Alt+Shift+E"), "{layers?}", has_layers, last), cmd!("effect.removeAll", "Remove All", ["Effect"], Some("Cmd+Shift+E"), "{layers?}", has_layers, remove_all), cmd!("effect.remove", "Remove Effect", [], None, "{layer?, effect: index|uid|name}", has_layers, remove), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index cdfc7468e..8fa4483f2 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1036,12 +1036,18 @@ impl Session { /// Render a comp frame with the session's footage + expression hosts. pub fn render(&self, comp: ItemId, t: Tick, opts: RenderOpts) -> Image { + self.renderer(opts).comp_frame(comp, t) + } + + /// The renderer [`Session::render`] uses: the session's footage, expressions, layer cache and + /// accelerator, and the render prefs (nested switches, draft shadows) over `opts`. + pub fn renderer(&self, opts: RenderOpts) -> Renderer<'_> { let opts = RenderOpts { nested_switches: self.prefs.general.switches_affect_nested_comps, draft_shadows: self.prefs.three_d.realtime_shadows, ..opts }; let mut r = Renderer::new(&self.project, self.footage.as_ref(), opts); r.expr = self.expr.as_deref(); r.cache = Some(&self.layer_cache); r.accel = self.accel.as_deref(); - r.comp_frame(comp, t) + r } /// Resolve a comp reference (item id number or name; `None`/null = the active comp). @@ -1242,6 +1248,8 @@ mod tests_codec_options; #[cfg(test)] mod tests_color_view; #[cfg(test)] +mod tests_crypto_pick; +#[cfg(test)] mod tests_delete_keys; #[cfg(test)] mod tests_disk_cache; diff --git a/crates/engine/src/tests_crypto_pick.rs b/crates/engine/src/tests_crypto_pick.rs new file mode 100644 index 000000000..4fdbcbf41 --- /dev/null +++ b/crates/engine/src/tests_crypto_pick.rs @@ -0,0 +1,181 @@ +//! `effect.pickCryptomatte` / `effect.cryptomatteAt` (#473): the Cryptomatte eyedropper reads the +//! aux channels the effect renders from (footage at its source time, a 3D precomp's generated +//! pass) and edits the Selection text in one undo step. + +use std::sync::Arc; + +use effectcraft_project::{Footage, FootageKind, ItemId}; +use effectcraft_raster::channels3d::{crypto_float, crypto_hash}; +use effectcraft_raster::{AuxChannels, Image}; +use effectcraft_time::{FrameRate, Tick}; +use serde_json::{Value, json}; + +use crate::Session; +use crate::render::FootageSource; + +const COMMA: &str = "Green, \"Glossy\""; + +/// 8 × 4 footage with a 16 × 8 Cryptomatte (two aux pixels per layer pixel). Layer pixels in x: +/// 0-1 `left` (0.6) over `right` (0.4); 2-3 `left` (0.3) under the comma name (0.7), ranks out of +/// order; 4-5 `star*`; 6 an ID missing from the manifest; 7 a 0.5 / 0.5 tie of `left` and +/// `right`. Rows 3+ are empty. From 1 s of source time `late` replaces columns 0-1. +struct Crypto; + +fn aux_at(t: Tick) -> AuxChannels { + let (w, h) = (16usize, 8usize); + let mut planes = vec![vec![0.0f32; w * h]; 4]; + for (i, x) in (0..w * h).map(|i| (i, i % w)).filter(|(i, _)| i / w < 6) { + let late = if t >= Tick::from_seconds_f64(1.0) { "late" } else { "left" }; + let (a, ca, b, cb) = match x { + 0..4 => (late, 0.6, "right", 0.4), + 4..8 => ("left", 0.3, COMMA, 0.7), + 8..12 => ("star*", 1.0, "", 0.0), + 12..14 => ("ghost", 1.0, "", 0.0), + _ => ("left", 0.5, "right", 0.5), + }; + for (k, v) in [crypto_float(crypto_hash(a)), ca, crypto_float(crypto_hash(b)), cb].into_iter().enumerate() { + planes[k][i] = v; + } + } + let mut aux = AuxChannels::new(w as u32, h as u32, 1.0); + for (k, c) in ["R", "G", "B", "A"].iter().enumerate() { + aux.channels.push((format!("CryptoObject00.{c}"), planes[k].clone())); + } + aux.manifests = vec![("CryptoObject".into(), ["left", "right", "late", COMMA, "star*"].iter().map(|n| (n.to_string(), crypto_hash(n))).collect())]; + aux +} + +impl FootageSource for Crypto { + fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option> { + Some(Arc::new(Image::new(8, 4))) + } + fn aux(&self, _: ItemId, _: &Footage, t: Tick) -> Option> { + Some(Arc::new(aux_at(t))) + } +} + +struct Probe; +impl crate::Importer for Probe { + fn probe(&self, path: &str) -> Result { + let duration = Tick::from_seconds_f64(2.0); + Ok(Footage { + path: path.into(), + kind: FootageKind::Video, + width: 8, + height: 4, + has_video: true, + frame_rate: FrameRate::from_f64(24.0), + duration, + ..Default::default() + }) + } +} + +fn session() -> Session { + let mut s = Session { footage: Arc::new(Crypto), importer: Some(Arc::new(Probe)), ..Default::default() }; + let item = s.execute("file.import", json!({"paths": ["/renders/crypto.exr"]})).unwrap()["items"][0].clone(); + s.execute("comp.new", json!({"name": "C", "width": 8, "height": 4, "frameRate": 24, "duration": 2})).unwrap(); + s.execute("layer.addItem", json!({"item": item})).unwrap(); + s.execute_checked("effect.apply", json!({"effect": "Cryptomatte"})).unwrap(); + s +} + +fn pick(s: &mut Session, x: f64, mode: &str) -> Value { + s.execute_checked("effect.pickCryptomatte", json!({"effect": 1, "x": x, "y": 1.5, "mode": mode})).unwrap() +} + +fn selection(s: &Session) -> String { + let l = &s.active_comp().unwrap().layers[0]; + let g = l.effects().unwrap().groups().next().unwrap(); + match &g.prop("selection").unwrap().value { + effectcraft_keyframe::Value::Str(t) => t.clone(), + v => panic!("{v:?}"), + } +} + +#[test] +fn picks_replace_add_and_remove_in_one_undo_step_each() { + let mut s = session(); + let steps = s.history.undo.len(); + let r = pick(&mut s, 0.5, "replace"); + assert_eq!((r["name"].as_str(), r["selection"].as_str(), r["changed"].as_bool()), (Some("left"), Some("left"), Some(true)), "{r}"); + assert_eq!(r["hash"], format!("{:08x}", crypto_hash("left"))); + assert_eq!(s.history.undo.len(), steps + 1); + // The highest coverage wins even when it is not rank 0; the name is quoted. + assert_eq!(pick(&mut s, 2.5, "add")["selection"], r#"left, "Green, \"Glossy\"""#); + // Adding it again changes nothing and leaves no undo step. + let again = pick(&mut s, 3.5, "add"); + assert_eq!((again["changed"].as_bool(), s.history.undo.len()), (Some(false), steps + 2)); + // A `*` name and an unnamed ID go in as hashes; a tie goes to rank 0. + pick(&mut s, 4.5, "add"); + assert_eq!(pick(&mut s, 6.5, "add")["selection"], format!(r#"left, "Green, \"Glossy\"", <{:08x}>, <{:08x}>"#, crypto_hash("star*"), crypto_hash("ghost"))); + assert_eq!(s.execute("effect.cryptomatteAt", json!({"effect": 1, "x": 7.5, "y": 0.5})).unwrap()["name"], "left"); + let r = pick(&mut s, 0.0, "remove"); + assert_eq!(r["selection"], format!(r#""Green, \"Glossy\"", <{:08x}>, <{:08x}>"#, crypto_hash("star*"), crypto_hash("ghost"))); + assert_eq!(r["selected"], false); + // The text parses back to exactly the picked IDs. + let manifest = aux_at(Tick::ZERO).manifests[0].1.clone(); + let got = effectcraft_effects::selection_hashes(&selection(&s), Some(&manifest)); + assert_eq!(got, [COMMA, "star*", "ghost"].map(crypto_hash)); + assert_eq!(s.history.undo.len(), steps + 5); + s.execute("edit.undo", json!({})).unwrap(); + assert!(selection(&s).starts_with("left, "), "{}", selection(&s)); + pick(&mut s, 2.0, "replace"); + assert_eq!(selection(&s), r#""Green, \"Glossy\"""#); + // The same selection with other spacing: no change, no undo step. + s.execute_checked("prop.set", json!({"path": "effects/#1/selection", "value": " \"Green, \\\"Glossy\\\"\" "})).unwrap(); + let n = s.history.undo.len(); + assert_eq!((pick(&mut s, 2.0, "replace")["changed"].as_bool(), s.history.undo.len()), (Some(false), n)); +} + +#[test] +fn bad_points_layers_and_modes_are_errors_and_change_nothing() { + let mut s = session(); + let at = |s: &mut Session, x: f64, y: f64| s.execute_checked("effect.pickCryptomatte", json!({"effect": 1, "x": x, "y": y})); + for (x, y) in [(-0.1, 1.0), (8.0, 1.0), (1.0, 4.0), (1.0, 3.5), (1e300, -1e300)] { + assert!(at(&mut s, x, y).is_err(), "{x},{y}"); + assert_eq!(s.execute("effect.cryptomatteAt", json!({"effect": 1, "x": x, "y": y})).unwrap(), Value::Null); + } + assert!(s.execute_checked("effect.pickCryptomatte", json!({"effect": 1, "x": 1, "y": 1, "mode": "toggle"})).is_err()); + assert!(s.execute_checked("effect.pickCryptomatte", json!({"effect": 1, "x": 1})).is_err()); + for scale in [0.0, -1.0, 2.0] { + assert!(s.execute_checked("effect.pickCryptomatte", json!({"effect": 1, "x": 1, "y": 1, "scale": scale})).is_err(), "{scale}"); + } + s.execute_checked("effect.apply", json!({"effect": "Gaussian Blur"})).unwrap(); + assert!(s.execute_checked("effect.pickCryptomatte", json!({"effect": 2, "x": 1, "y": 1})).is_err(), "not a Cryptomatte effect"); + assert_eq!(selection(&s), ""); + // A layer without aux channels. + s.execute("layer.newSolid", json!({"name": "S"})).unwrap(); + s.execute_checked("effect.apply", json!({"effect": "Cryptomatte"})).unwrap(); + let e = s.execute_checked("effect.pickCryptomatte", json!({"effect": 1, "x": 1, "y": 1})).unwrap_err().to_string(); + assert!(e.contains("no Cryptomatte channels"), "{e}"); + // An expression-driven Selection would ignore the edit. + let mut s = session(); + s.execute_checked("prop.setExpression", json!({"path": "effects/#1/selection", "expression": "\"right\""})).unwrap(); + assert!(s.execute_checked("effect.pickCryptomatte", json!({"effect": 1, "x": 1, "y": 1})).is_err()); + assert_eq!(s.execute("effect.cryptomatteAt", json!({"effect": 1, "x": 1, "y": 1})).unwrap()["selected"], false); +} + +/// The pick reads the frame the render shows: Time Remap's source time, not the layer time. +#[test] +fn time_remap_picks_the_shown_source_frame() { + let mut s = session(); + assert_eq!(pick(&mut s, 0.5, "replace")["name"], "left"); + s.execute_checked("layer.enableTimeRemap", json!({})).unwrap(); + s.execute_checked("prop.set", json!({"path": "timeRemap", "value": 1.5})).unwrap(); + assert_eq!(pick(&mut s, 0.5, "replace")["name"], "late"); +} + +/// A precomp of a 3D comp: the generated CryptoObject pass names its layers. +#[test] +fn picks_on_a_precomp_of_a_3d_comp() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Outer", "width": 40, "height": 20, "frameRate": 24, "duration": 1})).unwrap(); + let l = s.execute("layer.newSolid", json!({"name": "Red, Ball", "width": 40, "height": 20})).unwrap()["layer"].clone(); + s.execute("layer.setSwitch", json!({"layers": [l], "switch": "threeD", "value": true})).unwrap(); + s.execute("layer.precompose", json!({"layers": [l], "name": "Inner"})).unwrap(); + s.execute("layer.select", json!({"layers": [s.active_comp().unwrap().layers[0].id.0]})).ok(); + s.execute_checked("effect.apply", json!({"effect": "Cryptomatte"})).unwrap(); + let r = s.execute_checked("effect.pickCryptomatte", json!({"effect": 1, "x": 20, "y": 10})).unwrap(); + assert_eq!((r["name"].as_str(), r["selection"].as_str()), (Some("Red, Ball"), Some("\"Red, Ball\"")), "{r}"); +} diff --git a/crates/host/tests/cryptomatte_pick.rs b/crates/host/tests/cryptomatte_pick.rs new file mode 100644 index 000000000..69092ce95 --- /dev/null +++ b/crates/host/tests/cryptomatte_pick.rs @@ -0,0 +1,127 @@ +//! The Cryptomatte eyedropper on real OpenEXR footage (#473): `effect.pickCryptomatte` reads the +//! file's Cryptomatte ranks and manifest, edits the Selection, and the rendered matte follows, +//! including undo. + +use effectcraft_engine::Session; +use effectcraft_engine::time::Tick; +use effectcraft_raster::channels3d::{crypto_float, crypto_hash}; +use serde_json::json; + +const RIGHT: &str = "Right, \"Box\""; + +/// An 8 × 4 EXR: white RGBA, and a CryptoObject layer with `Left` in columns before `split` and +/// the comma-and-quotes name from there, both named in its manifest. +fn crypto_exr(tag: &str, split: usize) -> String { + use exr::prelude::*; + let (w, h) = (8usize, 4usize); + let ids: Vec = (0..w * h).map(|i| crypto_float(crypto_hash(if i % w < split { "Left" } else { RIGHT }))).collect(); + let ch = |n: &str, v: Vec| AnyChannel::new(n, FlatSamples::F32(v)); + let one = || vec![1.0f32; w * h]; + let channels = AnyChannels::sort( + vec![ch("R", one()), ch("G", one()), ch("B", one()), ch("A", one()), ch("CryptoObject00.R", ids), ch("CryptoObject00.G", one())].into(), + ); + let mut layer = Layer::new((w, h), LayerAttributes::default(), Encoding::FAST_LOSSLESS, channels); + let manifest = json!({"Left": format!("{:08x}", crypto_hash("Left")), RIGHT: format!("{:08x}", crypto_hash(RIGHT))}).to_string(); + for (k, v) in [("name", "CryptoObject".to_string()), ("manifest", manifest)] { + layer.attributes.other.insert(Text::from(format!("cryptomatte/f834d0a/{k}").as_str()), AttributeValue::Text(Text::from(v.as_str()))); + } + // A folder of its own: numbered siblings would import as an image sequence. + let dir = std::env::temp_dir().join(format!("ec-crypto-pick-{tag}-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("crypto.exr"); + Image::from_layer(layer).write().to_file(&path).unwrap(); + path.to_string_lossy().to_string() +} + +/// A session with the EXR as a layer of a 2 s comp, under a Cryptomatte effect. +fn footage(tag: &str, split: usize) -> (Session, String) { + let path = crypto_exr(tag, split); + let mut s = effectcraft_host::session(); + let r = s.execute("file.import", json!({"paths": [path]})).unwrap(); + assert_eq!(r["errors"], json!([]), "{r}"); + s.execute("comp.new", json!({"name": "Crypto", "width": 8, "height": 4, "frameRate": 24, "duration": 2})).unwrap(); + s.execute("layer.addItem", json!({"item": r["items"][0]})).unwrap(); + s.execute_checked("effect.apply", json!({"effect": "Cryptomatte"})).unwrap(); + (s, path) +} + +/// The matte's alpha over the left and right halves. +fn matte(s: &Session) -> [f32; 2] { + let img = s.render(s.active_comp_id().unwrap(), Tick::ZERO, Default::default()); + [img.get(1, 1)[3], img.get(6, 2)[3]] +} + +#[test] +fn picking_on_a_cryptomatte_exr_edits_the_selection_and_the_matte() { + let (mut s, path) = footage("edit", 4); + s.execute_checked("prop.set", json!({"path": "effects/#1/display", "value": 1})).unwrap(); + assert_eq!(matte(&s), [0.0, 0.0], "nothing selected"); + let pick = |s: &mut Session, x: f64, mode: &str| s.execute_checked("effect.pickCryptomatte", json!({"effect": 1, "x": x, "y": 1, "mode": mode})).unwrap(); + + assert_eq!(pick(&mut s, 1.0, "replace")["selection"], "Left"); + assert_eq!(matte(&s), [1.0, 0.0]); + let r = pick(&mut s, 6.0, "add"); + assert_eq!((r["name"].as_str(), r["layer"].as_str()), (Some(RIGHT), Some("CryptoObject")), "{r}"); + assert_eq!(r["selection"], r#"Left, "Right, \"Box\"""#); + assert_eq!(matte(&s), [1.0, 1.0], "the quoted name selects the right half"); + pick(&mut s, 2.0, "remove"); + assert_eq!(matte(&s), [0.0, 1.0]); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(matte(&s), [1.0, 1.0]); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(matte(&s), [1.0, 0.0]); + let _ = std::fs::remove_dir_all(std::path::Path::new(&path).parent().unwrap()); +} + +/// At half resolution a preview pixel shows the aux texel at its centre (layer x 3 for preview +/// pixel 1): `scale` makes the pick read that one, so what is clicked is what lights up. +#[test] +fn a_pick_at_preview_resolution_reads_the_pixel_the_preview_shows() { + let (mut s, path) = footage("half", 3); + s.execute_checked("prop.set", json!({"path": "effects/#1/matteOnly", "value": true})).unwrap(); + let r = s.execute_checked("effect.pickCryptomatte", json!({"effect": 1, "x": 2.5, "y": 1.5, "scale": 0.5})).unwrap(); + assert_eq!(r["name"], RIGHT, "{r}"); + let opts = effectcraft_engine::render::RenderOpts { scale: 0.5, ..Default::default() }; + assert_eq!(s.render(s.active_comp_id().unwrap(), Tick::ZERO, opts).get(1, 0)[3], 1.0); + // At full resolution the same point is Left's pixel. + let r = s.execute_checked("effect.cryptomatteAt", json!({"effect": 1, "x": 2.5, "y": 1.5})).unwrap(); + assert_eq!(r["name"], "Left"); + let _ = std::fs::remove_dir_all(std::path::Path::new(&path).parent().unwrap()); +} + +/// A keyframed Selection picked at `time` is edited at that time, not at the current time. +#[test] +fn a_keyframed_selection_is_edited_where_it_was_read() { + let (mut s, path) = footage("key", 4); + for t in [0.0, 1.0] { + s.execute_checked("prop.addKey", json!({"path": "effects/#1/selection", "time": t, "value": format!("<{:08x}>", crypto_hash(RIGHT))})).unwrap(); + } + let r = s.execute_checked("effect.pickCryptomatte", json!({"effect": 1, "x": 0.5, "y": 1.5, "mode": "add", "time": 1.0})).unwrap(); + assert_eq!((r["changed"].as_bool(), r["selected"].as_bool()), (Some(true), Some(true)), "{r}"); + let sel = |s: &mut Session, t: f64| { + let r = s.execute("effect.cryptomatteAt", json!({"effect": 1, "x": 0.5, "y": 1.5, "time": t})).unwrap(); + r["selected"].as_bool() + }; + assert_eq!((sel(&mut s, 0.0), sel(&mut s, 1.0)), (Some(false), Some(true)), "only the key at 1 s gains Left"); + let _ = std::fs::remove_dir_all(std::path::Path::new(&path).parent().unwrap()); +} + +/// A precomp whose 3D layer an expression moves: the pick finds it where the render keys it +/// (the pick's aux pass runs the session's expressions). +#[test] +fn a_pick_on_a_precomp_follows_expressions() { + let mut s = effectcraft_host::session(); + let inner = s.execute("comp.new", json!({"name": "Inner", "width": 40, "height": 20, "frameRate": 24, "duration": 1})).unwrap()["comp"].clone(); + let l = s.execute("layer.newSolid", json!({"name": "Ball", "width": 6, "height": 6})).unwrap()["layer"].clone(); + s.execute("layer.setSwitch", json!({"layers": [l], "switch": "threeD", "value": true})).unwrap(); + s.execute_checked("prop.setExpression", json!({"layer": l, "path": "transform/position", "expression": "[33, 10, 0]"})).unwrap(); + s.execute("comp.new", json!({"name": "Outer", "width": 40, "height": 20, "frameRate": 24, "duration": 1})).unwrap(); + s.execute("layer.addItem", json!({"item": inner})).unwrap(); + s.execute_checked("effect.apply", json!({"effect": "Cryptomatte"})).unwrap(); + s.execute_checked("prop.set", json!({"path": "effects/#1/matteOnly", "value": true})).unwrap(); + let r = s.execute_checked("effect.pickCryptomatte", json!({"effect": 1, "x": 33.5, "y": 10.5})).unwrap(); + assert_eq!(r["selection"], "Ball", "{r}"); + let img = s.render(s.active_comp_id().unwrap(), Tick::ZERO, Default::default()); + assert_eq!((img.get(33, 10)[3], img.get(20, 10)[3]), (1.0, 0.0)); + assert_eq!(s.execute("effect.cryptomatteAt", json!({"effect": 1, "x": 20.5, "y": 10.5})).unwrap(), serde_json::Value::Null); +} diff --git a/crates/raster/src/channels3d.rs b/crates/raster/src/channels3d.rs index b84b64b79..034fb50fd 100644 --- a/crates/raster/src/channels3d.rs +++ b/crates/raster/src/channels3d.rs @@ -148,6 +148,31 @@ impl AuxChannels { self.crypto_rank_planes(layer).iter().map(|(id, c)| (id[i], c[i])).collect() } + /// The Cryptomatte layer an effect asking for `want` (`CryptoObject`, …) reads: an exact + /// match, else one ending in it (`ViewLayer.CryptoObject`), else the first layer. + pub fn resolve_crypto_layer(&self, want: &str) -> Option { + let layers = self.crypto_layers(); + layers.iter().find(|l| l.as_str() == want || l.ends_with(want)).or(layers.first()).cloned() + } + + /// The ID (its hash, `f32::to_bits`) with the most coverage at aux pixel `i` of Cryptomatte + /// layer `layer`, summed over the ranks it appears in. Ties go to the lower rank; IDs with + /// no positive coverage don't count. `None` when nothing covers the pixel. + pub fn crypto_id_at(&self, layer: &str, i: usize) -> Option { + let mut ids: Vec<(u32, f32)> = vec![]; + for (id, cov) in self.crypto_rank_planes(layer) { + let (Some(id), Some(&c)) = (id.get(i), cov.get(i)) else { continue }; + if c > 0.0 { + match ids.iter_mut().find(|(h, _)| *h == id.to_bits()) { + Some((_, sum)) => *sum += c, + None => ids.push((id.to_bits(), c)), + } + } + } + // `min_by` keeps the first of equal elements: a tie goes to the lower rank. + ids.into_iter().min_by(|a, b| b.1.total_cmp(&a.1)).map(|(h, _)| h) + } + /// Aux pixel index of a layer-space point (nearest; `None` outside). #[inline] pub fn index_at(&self, lx: f64, ly: f64) -> Option { @@ -211,6 +236,28 @@ pub fn crypto_float(hash: u32) -> f32 { mod tests { use super::*; + /// The picked ID: most coverage (summed over ranks), ties to the lower rank, nothing when + /// no rank covers the pixel; layers resolve as the Cryptomatte effect does. + #[test] + fn crypto_id_at_takes_the_top_coverage_and_layers_resolve_by_suffix() { + let mut a = AuxChannels::new(4, 1, 1.0); + let f = |n: &str| crypto_float(crypto_hash(n)); + let (x, y, z) = (f("x"), f("y"), f("z")); + for (c, v) in [("R", [x, x, x, x]), ("G", [0.3, 0.5, 0.0, 0.2]), ("B", [y, y, y, y]), ("A", [0.6, 0.5, 0.0, 0.3])] { + a.channels.push((format!("ViewLayer.CryptoObject00.{c}"), v.to_vec())); + } + for (c, v) in [("R", [z, z, z, x]), ("G", [0.1, 0.0, -1.0, 0.2])] { + a.channels.push((format!("ViewLayer.CryptoObject01.{c}"), v.to_vec())); + } + a.channels.push(("Other00.R".into(), vec![0.0; 4])); + let l = a.resolve_crypto_layer("CryptoObject").unwrap(); + assert_eq!(l, "ViewLayer.CryptoObject"); + assert_eq!(a.resolve_crypto_layer("CryptoMaterial").as_deref(), Some("Other"), "falls back to the first"); + let at = |i| a.crypto_id_at(&l, i); + assert_eq!([at(0), at(1), at(2), at(3), at(9)], [Some(crypto_hash("y")), Some(crypto_hash("x")), None, Some(crypto_hash("x")), None]); + assert_eq!(AuxChannels::new(1, 1, 1.0).resolve_crypto_layer("CryptoObject"), None); + } + #[test] fn murmur3_reference_values() { // Published MurmurHash3_x86_32 test vectors. diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index eccaed2f4..d1e160d7a 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -314,26 +314,7 @@ impl EffectHost for FxHost<'_, '_, '_> { } fn aux(&self) -> Option> { - match &self.layer.source { - LayerSource::Footage { item } => { - let ItemKind::Footage(f) = &self.r.project.item(*item)?.kind else { return None }; - let a = self.r.footage.aux(*item, f, self.ctx.source_time(self.layer))?; - // Aux pixels per layer pixel (the layer is the footage at its nominal size). - if f.width > 0 && a.width > 0 { - let mut a = (*a).clone(); - a.scale = a.width as f64 / f.width as f64; - return Some(Arc::new(a)); - } - Some(a) - } - LayerSource::Comp { item } => { - if self.r.depth > MAX_FX_DEPTH || self.r.project.comp_contains(*item, self.ctx.comp_id) { - return None; - } - self.r.nested().comp_aux(*item, self.ctx.nested_time(self.layer)).map(Arc::new) - } - _ => None, - } + self.r.layer_aux(self.ctx, self.layer) } fn params_at(&self, layer_time: f64) -> Option { @@ -2103,6 +2084,34 @@ impl<'a> Renderer<'a> { Some(three_d::compose::aux_pass(self, &ctx)) } + /// The auxiliary channels effects on `layer` read ([`EffectHost::aux`]): its multi-layer + /// OpenEXR footage's at the layer's source time (Time Remap included), or a precomp's + /// generated 3D channels at its nested time. `None` for other layers. + pub fn layer_aux(&self, ctx: &EvalCtx, layer: &Layer) -> Option> { + match &layer.source { + LayerSource::Footage { item } => { + let ItemKind::Footage(f) = &self.project.item(*item)?.kind else { return None }; + let a = self.footage.aux(*item, f, ctx.source_time(layer))?; + // Aux pixels per layer pixel (the layer is the footage at its nominal size). The + // planes are only copied when that changes the scale. + let scale = a.width as f64 / f.width as f64; + if f.width > 0 && a.width > 0 && a.scale != scale { + let mut a = (*a).clone(); + a.scale = scale; + return Some(Arc::new(a)); + } + Some(a) + } + LayerSource::Comp { item } => { + if self.depth > MAX_FX_DEPTH || self.project.comp_contains(*item, ctx.comp_id) { + return None; + } + self.nested().comp_aux(*item, ctx.nested_time(layer)).map(Arc::new) + } + _ => None, + } + } + /// Evaluation context of comp `comp_id` at comp time `t`. pub fn eval_ctx(&self, comp_id: ItemId, t: Tick) -> Option> { Some(self.ctx(comp_id, self.project.comp(comp_id)?, t)) diff --git a/docs/agents.md b/docs/agents.md index c47bfb603..01708926b 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -180,6 +180,7 @@ Short recipes; every step is one tool call. | Animate | `add_keyframe {"layer":2,"path":"transform/scale","keys":[{"time":0,"value":[0,0]},{"time":0.5,"value":[100,100]}],"interpolation":"easyEase"}` | | Effect with settings | `list_effects {"filter":"glow"}` → `add_effect {"layer":2,"effect":"Glow","values":{"threshold":40,"radius":30}}` → `set_property {"layer":2,"path":"effects/#1/intensity","value":2}` | | Key a green screen | `add_effect {"layer":2,"effect":"Keylight (1.2)"}` → `execute_command effect.pickColor {"layer":2,"effect":1,"param":"screenColour","x":20,"y":20,"average":true}` (a screen pixel, layer space; samples the effect's input) → `set_property {"layer":2,"path":"effects/#1/screenMatte/clipBlack","value":10}` → `render_frame {"transparent":true}` | +| Key objects by Cryptomatte | `add_effect {"layer":2,"effect":"Cryptomatte"}` → `execute_command effect.pickCryptomatte {"layer":2,"effect":1,"x":120,"y":80}` (the ID under that layer pixel replaces the Selection; `"mode":"add"` / `"remove"` edit it; `effect.cryptomatteAt` with the same point only reports the name) → `render_frame` | | Drive with an expression | `set_property {"layer":2,"path":"transform/rotation","expression":"time*90"}` | | Inspect a layer | `get_layer {"layer":2,"flat":true}` (every property with its `path`, value, key count) | | Check the result | `render_frame {"time":1.5,"max_side":640}`; in bridge mode `screenshot {"panel":"Timeline"}` | @@ -323,8 +324,8 @@ build real projects through these interfaces; they are worked examples of everyt ### Effect positions Effect points, Puppet pins (`puppet.addPin`, `puppet.recordPin`), Paint, Liquify and Roto Brush -strokes and `effect.pickColor` are in **effect space**: layer pixels from the top-left of the -layer's effect bounds. For footage, solids and precomps that is the layer's own pixel grid. Shape +strokes, `effect.pickColor` and `effect.pickCryptomatte` are in **effect space**: layer pixels +from the top-left of the layer's effect bounds. For footage, solids and precomps that is the layer's own pixel grid. Shape and text layers have comp-sized bounds centred on their content's origin, so in a `w` × `h` comp the point `[w / 2, h / 2]` is that origin (a new effect's default point). `prop.get` / `prop.set` read and write these values as they are. From d74cb70938a2096bd200bc00b186db484aadc909 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Kozlovsk=C3=BD?= Date: Mon, 19 Oct 2026 08:40:16 +0200 Subject: [PATCH 05/46] Cryptomatte pick: snap to the effect's input buffer grid, not the preview scale At preview scales from 0.75 to 1 footage keeps native pixels, a proxy sets its own buffer scale at any render scale, a Preserve Resolution precomp is drawn at full size, and earlier effects can pad or offset the buffer, so snapping to the preview grid could pick a different ID than the rendered matte. Use the scale and offset of the buffer the Cryptomatte effect receives (layer_input, rendered once and then cached). The pixel centre goes to an aux point through effectcraft_effects::aux_point, which index_map_at now calls too, so the two round identically (a division vs the reciprocal picked the other ID on a 104 px proxy of 1920 px footage). An unknown effect uid errors "no such effect" and a layer without pixels "this layer has no pixels", as effect.pickColor does. --- crates/effects/src/channel3d.rs | 14 ++- crates/effects/src/lib.rs | 2 +- crates/engine/src/commands/effect.rs | 22 +++- crates/engine/src/tests_crypto_pick.rs | 163 ++++++++++++++++++++++++- 4 files changed, 187 insertions(+), 14 deletions(-) diff --git a/crates/effects/src/channel3d.rs b/crates/effects/src/channel3d.rs index 7c4f0e1df..9eb8f4dd9 100644 --- a/crates/effects/src/channel3d.rs +++ b/crates/effects/src/channel3d.rs @@ -37,16 +37,24 @@ fn index_map(b: &Buf, a: &AuxChannels) -> Vec> { /// [`index_map`] sampling each pixel at sub-pixel position `at` (0..1). fn index_map_at(b: &Buf, a: &AuxChannels, at: [f64; 2]) -> Vec> { let (w, h) = (b.img.width as usize, b.img.height as usize); - let inv = 1.0 / b.scale.max(1e-9); (0..w * h) .into_par_iter() .map(|i| { - let (x, y) = ((i % w) as f64 + at[0], (i / w) as f64 + at[1]); - a.index_at((x - b.offset[0]) * inv, (y - b.offset[1]) * inv) + let [x, y] = aux_point(b.scale, b.offset, [(i % w) as f64 + at[0], (i / w) as f64 + at[1]]); + a.index_at(x, y) }) .collect() } +/// The layer point where 3D Channel effects look up the aux for buffer position `p` (pixel +/// `i` + sub-pixel offset) of a buffer with `scale` and `offset`. The Cryptomatte pick calls +/// it too, so the two can't pick different aux pixels. +#[inline] +pub fn aux_point(scale: f64, offset: [f64; 2], p: [f64; 2]) -> [f64; 2] { + let inv = 1.0 / scale.max(1e-9); + [(p[0] - offset[0]) * inv, (p[1] - offset[1]) * inv] +} + /// A channel sampled into the buffer grid (`fill` outside). fn plane_of(b: &Buf, idx: &[Option], ch: &[f32], fill: f32) -> Plane { Plane { w: b.img.width as usize, h: b.img.height as usize, data: idx.par_iter().map(|i| i.map_or(fill, |i| ch[i])).collect() } diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 773bef07c..3295cdc99 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -111,7 +111,7 @@ pub use time_fx::{PixelMotion, TimewarpPlan, TwStep, pmb_plan, posterized_time, // effectcraft-gpu fx_tone. pub use ocio::color_stabilizer_maps; // GPU effects, part B (3D Channel, Immersive Video, colour management, simulation render passes). -pub use channel3d::{CRYPTO_LAYERS, crypto_aux_image, id_color, selection_hashes}; +pub use channel3d::{CRYPTO_LAYERS, aux_point, crypto_aux_image, id_color, selection_hashes}; pub use ocio::{ColorOp, Straight, Tf, color_program}; pub use sim::{Acc, Blob, Bubble, Post, Shape, Sprite, SpritePlan, Tint, bubble_list, drizzle_drops, mercury_blobs, sprite_plan}; pub use sim2::{ diff --git a/crates/engine/src/commands/effect.rs b/crates/engine/src/commands/effect.rs index fca4baa5b..4f263b6fb 100644 --- a/crates/engine/src/commands/effect.rs +++ b/crates/engine/src/commands/effect.rs @@ -212,7 +212,8 @@ impl CryptoHit { /// The ID under `{x, y}` (effect space, which is layer space on footage and precomp layers) for /// the Cryptomatte effect `{layer?, effect?}`, from the aux channels its render reads. The point -/// snaps to the centre of the pixel a render at `scale` (the viewer's resolution) samples. +/// snaps to the centre of the pixel the effect's input buffer samples in a render at `scale` +/// (the viewer's resolution). fn crypto_hit(s: &Session, p: &Value, cmd: &str) -> Result> { let (cid, lid, fx_uid) = find_fx(s, p, cmd)?; let (Some(x), Some(y)) = (f_p(p, "x"), f_p(p, "y")) else { return Err(bad(cmd, "missing `x` / `y` (layer pixels)")) }; @@ -222,23 +223,32 @@ fn crypto_hit(s: &Session, p: &Value, cmd: &str) -> Result> { } let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; let layer = comp.layer(lid).ok_or(EngineError::NoComp)?; - let g = layer.effects().and_then(|fx| fx.groups().find(|g| g.uid == fx_uid)).ok_or_else(|| bad(cmd, "no such effect"))?; + let fx = layer.effects().ok_or_else(|| bad(cmd, "no effects"))?; + let pos = fx.groups().position(|g| g.uid == fx_uid).ok_or_else(|| bad(cmd, "no such effect"))?; + let g = fx.groups().nth(pos).ok_or_else(|| bad(cmd, "no such effect"))?; let prop = g.prop("selection").filter(|_| g.match_id == "ec.3d.cryptomatte").ok_or_else(|| bad(cmd, "not a Cryptomatte effect"))?; let t = super::time_p(s, p, Some(comp)); let ctx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None }; let params = effectcraft_effects::flatten_params(g, &mut |pr| ctx.value(layer, pr)); - let aux = s.renderer(effectcraft_render::RenderOpts { scale, ..Default::default() }).layer_aux(&ctx, layer); + let r = s.renderer(effectcraft_render::RenderOpts { scale, ..Default::default() }); + let aux = r.layer_aux(&ctx, layer); let want = effectcraft_effects::CRYPTO_LAYERS.get(params.e("layer") as usize).copied().unwrap_or("CryptoAsset"); let Some((aux, layer_name)) = aux.and_then(|a| a.resolve_crypto_layer(want).map(|l| (a, l))) else { return Err(bad(cmd, "this layer has no Cryptomatte channels (a multi-layer OpenEXR with Cryptomatte, or a 3D precomp)")); }; - let snap = |v: f64| ((v * scale).floor() + 0.5) / scale; - let Some(hash) = aux.index_at(snap(x), snap(y)).and_then(|i| aux.crypto_id_at(&layer_name, i)) else { return Ok(None) }; + // The grid of the effect's input buffer (footage keeps native pixels from 3/4 scale up, + // proxies have their own size, earlier effects can pad it), rendered and cached. + let b = r.layer_input(&ctx, layer, pos).ok_or_else(|| bad(cmd, "this layer has no pixels"))?; + let (bs, o) = (b.scale, b.offset); + // The centre of its pixel under the point, at the aux point the effect reads there. + let [ax, ay] = effectcraft_effects::aux_point(bs, o, [(x * bs + o[0]).floor() + 0.5, (y * bs + o[1]).floor() + 0.5]); + let Some(hash) = aux.index_at(ax, ay).and_then(|i| aux.crypto_id_at(&layer_name, i)) else { return Ok(None) }; let (selection, driven) = (params.s("selection").to_string(), prop.expr.as_ref().is_some_and(|e| e.enabled)); Ok(Some(CryptoHit { at: (cid, lid, t), aux, layer: layer_name, hash, selection, prop: prop.uid, driven })) } -/// `effect.cryptomatteAt`: the ID under a point, for the viewer's hover hint. +/// `effect.cryptomatteAt`: the ID under a point, for the viewer's hover hint. The first call per +/// frame renders the effect's input to learn its pixel grid (cached afterwards). fn crypto_at(s: &mut Session, p: &Value) -> Result { Ok(crypto_hit(s, p, "effect.cryptomatteAt")?.map_or(Value::Null, |h| h.reply(&h.selection))) } diff --git a/crates/engine/src/tests_crypto_pick.rs b/crates/engine/src/tests_crypto_pick.rs index 4fdbcbf41..799027c7e 100644 --- a/crates/engine/src/tests_crypto_pick.rs +++ b/crates/engine/src/tests_crypto_pick.rs @@ -46,8 +46,8 @@ fn aux_at(t: Tick) -> AuxChannels { } impl FootageSource for Crypto { - fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option> { - Some(Arc::new(Image::new(8, 4))) + fn frame(&self, _: ItemId, f: &Footage, _: Tick) -> Option> { + Some(Arc::new(Image::new(f.width, f.height))) } fn aux(&self, _: ItemId, _: &Footage, t: Tick) -> Option> { Some(Arc::new(aux_at(t))) @@ -58,11 +58,13 @@ struct Probe; impl crate::Importer for Probe { fn probe(&self, path: &str) -> Result { let duration = Tick::from_seconds_f64(2.0); + // A half-size proxy of the EXR. + let (width, height) = if path.contains("proxy") { (4, 2) } else { (8, 4) }; Ok(Footage { path: path.into(), kind: FootageKind::Video, - width: 8, - height: 4, + width, + height, has_video: true, frame_rate: FrameRate::from_f64(24.0), duration, @@ -179,3 +181,156 @@ fn picks_on_a_precomp_of_a_3d_comp() { let r = s.execute_checked("effect.pickCryptomatte", json!({"effect": 1, "x": 20, "y": 10})).unwrap(); assert_eq!((r["name"].as_str(), r["selection"].as_str()), (Some("Red, Ball"), Some("\"Red, Ball\"")), "{r}"); } + +/// The matte the rendered Cryptomatte effect gives the picked ID at the buffer pixel under +/// `{x, y}`: the pick has to name the ID the effect's own input buffer samples there. +fn rendered_matte_of_pick(s: &mut Session, x: f64, y: f64, scale: f64) -> (String, f32) { + let r = s.execute_checked("effect.pickCryptomatte", json!({"effect": "Cryptomatte", "x": x, "y": y, "scale": scale, "mode": "replace"})).unwrap(); + let fx = s.active_comp().unwrap().layers[0].effects().unwrap().groups().position(|g| g.match_id == "ec.3d.cryptomatte").unwrap() + 1; + s.execute_checked("prop.set", json!({"path": format!("effects/#{fx}/matteOnly"), "value": true})).unwrap(); + let (cid, comp) = (s.active_comp_id().unwrap(), s.active_comp().unwrap()); + let layer = &comp.layers[0]; + let ctx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: Tick::ZERO, expr: None, footage: None }; + let buf = s.renderer(effectcraft_render::RenderOpts { scale, ..Default::default() }).content_buf(&ctx, layer).unwrap(); + let (px, py) = buf.to_px([x, y]); + let m = buf.img.get(px.floor() as i64, py.floor() as i64)[0]; + (r["name"].as_str().unwrap_or("?").to_string(), m) +} + +/// Between 3/4 and full resolution footage keeps its native pixels: at 0.8 the effect samples +/// layer pixel 2 (the comma name, 0.7) at x = 2.2, not the 0.8-grid pixel snapped back to 1. +#[test] +fn pick_at_scale_0_8_matches_the_native_buffer_the_effect_renders() { + let mut s = session(); + let (name, m) = rendered_matte_of_pick(&mut s, 2.2, 1.5, 0.8); + assert!(m >= 0.5, "picked {name:?}, but the render gives it matte {m} there"); + assert_eq!(name, COMMA); +} + +/// A half-size proxy renders a buffer at 0.5 layer pixels per pixel even at full resolution: +/// x = 6.5 lies in buffer pixel 3, which samples layer x = 7 (the `left` / `right` tie). +#[test] +fn pick_on_a_proxy_matches_the_proxy_sized_buffer() { + let mut s = session(); + let item = *s.project.items.iter().find(|(_, i)| matches!(i.kind, effectcraft_project::ItemKind::Footage(_))).unwrap().0; + s.execute("file.setProxy", json!({"item": item.0, "path": "/renders/crypto_proxy.exr"})).unwrap(); + let (name, m) = rendered_matte_of_pick(&mut s, 6.5, 1.5, 1.0); + assert!(m >= 0.5, "picked {name:?}, but the render gives it matte {m} there"); + assert_eq!(name, "left"); +} + +/// 1920-px footage with `left` up to x = 120 and `right` after it, and a 104-px proxy. +struct Edge; +impl FootageSource for Edge { + fn frame(&self, _: ItemId, f: &Footage, _: Tick) -> Option> { + Some(Arc::new(Image::new(f.width, f.height))) + } + fn aux(&self, _: ItemId, _: &Footage, _: Tick) -> Option> { + let (w, h) = (1920usize, 40usize); + let mut aux = AuxChannels::new(w as u32, h as u32, 1.0); + let ids = (0..w * h).map(|i| crypto_float(crypto_hash(if i % w < 120 { "left" } else { "right" }))).collect(); + aux.channels = vec![("CryptoObject00.R".into(), ids), ("CryptoObject00.G".into(), vec![1.0; w * h])]; + aux.manifests = vec![("CryptoObject".into(), ["left", "right"].iter().map(|n| (n.to_string(), crypto_hash(n))).collect())]; + Some(Arc::new(aux)) + } +} + +/// Proxy buffer pixel 6's centre maps to layer x = 6.5 / (104 / 1920): 120.0 by division, but +/// 119.99999999999999 by the reciprocal the effect multiplies with. The pick must agree. +#[test] +fn pick_maps_pixel_centres_bit_exactly_as_the_effect() { + let mut s = Session { footage: Arc::new(Edge), ..Default::default() }; + let at = |path: &str, width| Footage { + path: path.into(), + kind: FootageKind::Video, + width, + height: 40, + has_video: true, + frame_rate: FrameRate::from_f64(24.0), + duration: Tick::from_seconds_f64(2.0), + ..Default::default() + }; + s.importer = Some(Arc::new(EdgeProbe(at("/renders/edge.exr", 1920), at("/renders/edge_proxy.exr", 104)))); + let item = s.execute("file.import", json!({"paths": ["/renders/edge.exr"]})).unwrap()["items"][0].clone(); + s.execute("comp.new", json!({"name": "C", "width": 1920, "height": 40, "frameRate": 24, "duration": 2})).unwrap(); + s.execute("layer.addItem", json!({"item": item})).unwrap(); + s.execute_checked("effect.apply", json!({"effect": "Cryptomatte"})).unwrap(); + s.execute("file.setProxy", json!({"item": item, "path": "/renders/edge_proxy.exr"})).unwrap(); + let (name, m) = rendered_matte_of_pick(&mut s, 118.0, 5.0, 1.0); + assert_eq!((name.as_str(), m), ("left", 1.0)); +} + +struct EdgeProbe(Footage, Footage); +impl crate::Importer for EdgeProbe { + fn probe(&self, path: &str) -> Result { + Ok(if path.contains("proxy") { self.1.clone() } else { self.0.clone() }) + } +} + +/// Footage whose decoded frames are `num / den` of the probed size (a file re-rendered at another +/// resolution, a relinked file): the render stretches them over the layer. +struct OffNominal(u32, u32); +impl FootageSource for OffNominal { + fn frame(&self, _: ItemId, f: &Footage, _: Tick) -> Option> { + Some(Arc::new(Image::new(f.width * self.0 / self.1, f.height * self.0 / self.1))) + } + fn aux(&self, _: ItemId, _: &Footage, t: Tick) -> Option> { + Some(Arc::new(aux_at(t))) + } +} + +/// Points along rows `ys` where the picked ID isn't lit in the rendered matte under the point. +fn mismatches(s: &mut Session, scales: &[f64], xs: impl Iterator + Clone, ys: &[f64]) -> Vec { + let mut bad = vec![]; + for &scale in scales { + for (x, &y) in ys.iter().flat_map(|y| xs.clone().map(move |x| (x, y))) { + if s.execute("effect.cryptomatteAt", json!({"effect": "Cryptomatte", "x": x, "y": y, "scale": scale})).unwrap().is_null() { + continue; + } + let (name, m) = rendered_matte_of_pick(s, x, y, scale); + if m < 0.5 { + bad.push(format!("scale {scale} ({x:.2}, {y}): picked {name:?}, matte {m}")); + } + } + } + bad +} + +const PREVIEW_SCALES: [f64; 7] = [1.0, 0.8, 0.75, 0.74, 0.5, 0.33, 0.25]; + +/// The pick follows the decoded frame's real size, not the footage's nominal one. +#[test] +fn pick_matches_the_render_when_frames_decode_off_nominal_size() { + for (num, den) in [(2, 1), (1, 2), (3, 4)] { + let mut s = Session { footage: Arc::new(OffNominal(num, den)), importer: Some(Arc::new(Probe)), ..Default::default() }; + let item = s.execute("file.import", json!({"paths": ["/renders/crypto.exr"]})).unwrap()["items"][0].clone(); + s.execute("comp.new", json!({"name": "C", "width": 8, "height": 4, "frameRate": 24, "duration": 2})).unwrap(); + s.execute("layer.addItem", json!({"item": item})).unwrap(); + s.execute_checked("effect.apply", json!({"effect": "Cryptomatte"})).unwrap(); + let bad = mismatches(&mut s, &PREVIEW_SCALES, (0..80).map(|i| i as f64 * 0.1 + 0.05), &[0.3, 1.7, 2.6]); + assert!(bad.is_empty(), "decode {num}/{den}: {} mismatches:\n{}", bad.len(), bad.join("\n")); + } +} + +/// A precomp of a 3D comp, with and without Preserve Resolution (drawn at full size below full +/// resolution), behind a disabled effect: the pick names the ID the rendered matte shows. +#[test] +fn pick_matches_the_render_on_precomps_with_and_without_preserve_resolution() { + for preserve in [false, true] { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Outer", "width": 40, "height": 20, "frameRate": 24, "duration": 2})).unwrap(); + let a = s.execute("layer.newSolid", json!({"name": "A", "width": 40, "height": 20})).unwrap()["layer"].clone(); + let b = s.execute("layer.newSolid", json!({"name": "B", "width": 7, "height": 20})).unwrap()["layer"].clone(); + s.execute_checked("prop.set", json!({"layer": b, "path": "transform/position", "value": [3.5, 10, -10]})).unwrap(); + s.execute("layer.setSwitch", json!({"layers": [a, b], "switch": "threeD", "value": true})).unwrap(); + s.execute("layer.precompose", json!({"layers": [a, b], "name": "Inner"})).unwrap(); + let inner = *s.project.items.iter().find(|(_, i)| i.name == "Inner").unwrap().0; + s.execute_checked("comp.settings", json!({"comp": inner.0, "preserveResolution": preserve})).unwrap(); + s.execute("layer.select", json!({"layers": [s.active_comp().unwrap().layers[0].id.0]})).ok(); + s.execute_checked("effect.apply", json!({"effect": "Gaussian Blur"})).unwrap(); + s.execute_checked("effect.toggle", json!({"effect": 1, "value": false})).unwrap(); + s.execute_checked("effect.apply", json!({"effect": "Cryptomatte"})).unwrap(); + let bad = mismatches(&mut s, &PREVIEW_SCALES, (0..93).map(|i| i as f64 * 0.15 + 0.05), &[10.0]); + assert!(bad.is_empty(), "preserve {preserve}: {} mismatches:\n{}", bad.len(), bad.join("\n")); + } +} From 26636bfb18418ed31daea8b1cb61f36d2d50f19d Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Mon, 19 Oct 2026 07:00:02 +0000 Subject: [PATCH 06/46] Make Fractal Noise evolution progress linearly Fixes #1816 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/effects/src/noise3.rs | 20 +++++++++++++++++--- crates/gpu/src/shaders/fx_noise.wgsl | 2 +- crates/gpu/src/shaders/kernels.wgsl | 2 +- 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/crates/effects/src/noise3.rs b/crates/effects/src/noise3.rs index 88a5c01d5..39980dd80 100644 --- a/crates/effects/src/noise3.rs +++ b/crates/effects/src/noise3.rs @@ -17,7 +17,7 @@ use effectcraft_keyframe::Value; use effectcraft_project::ParamUi; use rayon::prelude::*; -use crate::generate::{fade, lattice}; +use crate::generate::lattice; use crate::{Buf, EffectCtx, EffectSpec, ParamSpec, num, p, popup, slider}; /// Fractal Type options (shared by both effects). @@ -139,7 +139,7 @@ fn bspline(t: f32) -> [f32; 3] { } /// 3D value noise in about 0..1 with the given [`NOISE_TYPES`] interpolation in x and y (the -/// evolution axis z always fades smoothly). +/// evolution axis z always interpolates linearly, so evolution moves at a constant rate). /// /// Soft Linear is linear interpolation softened by a one-cell box filter, which is the quadratic /// B-spline: smooth like Spline from 9 lattice values instead of 16. A fade between two values @@ -149,7 +149,7 @@ pub fn typed_noise(x: f32, y: f32, z: f32, seed: u32, noise_type: u32) -> f32 { // Soft Linear's taps are centred on the nearest lattice point, the others start at the cell's. let h = if noise_type == NOISE_SOFT_LINEAR { 0.5 } else { 0.0 }; let (x0, y0, z0) = ((x + h).floor(), (y + h).floor(), z.floor()); - let (tx, ty, fz) = (x + h - x0, y + h - y0, fade(z - z0)); + let (tx, ty, fz) = (x + h - x0, y + h - y0, z - z0); let (ix, iy, iz) = (x0 as i32, y0 as i32, z0 as i32); let l = |a: f32, b: f32, t: f32| a + (b - a) * t; let slice = |dz: i32| -> f32 { @@ -476,6 +476,20 @@ mod tests { run_fx(id, vals, Image::filled(40, 24, [0.2, 0.4, 0.6, 1.0]), 0.0, EffectEnv::default()).img } + /// Evolution moves the noise at a constant rate: no ease in or out within a revolution. + #[test] + fn evolution_is_linear() { + for noise_type in [0, 1, 2, 3] { + let a = typed_noise(0.3, 0.7, 0.0, 5, noise_type); + let b = typed_noise(0.3, 0.7, 1.0, 5, noise_type); + for z in [0.25, 0.5, 0.75] { + let want = a + (b - a) * z; + let got = typed_noise(0.3, 0.7, z, 5, noise_type); + assert!((got - want).abs() < 1e-4, "type {noise_type} z {z}: {got} vs {want}"); + } + } + } + /// A Fractal Noise saved before the twirl-downs existed: flat ids move into their groups and /// the new controls take values that keep the old look. #[test] diff --git a/crates/gpu/src/shaders/fx_noise.wgsl b/crates/gpu/src/shaders/fx_noise.wgsl index 1f62d4429..5e68e10c1 100644 --- a/crates/gpu/src/shaders/fx_noise.wgsl +++ b/crates/gpu/src/shaders/fx_noise.wgsl @@ -538,7 +538,7 @@ fn fxn_typed_noise(x: f32, y: f32, z: f32, seed: u32, nt: u32) -> f32 { let z0 = floor(z); let tx = x - x0; let ty = y - y0; - let fz = fade(z - z0); + let fz = z - z0; let a = fxn_slice(i32(x0), i32(y0), i32(z0), tx, ty, seed, nt); let b = fxn_slice(i32(x0), i32(y0), i32(z0) + 1, tx, ty, seed, nt); return a + (b - a) * fz; diff --git a/crates/gpu/src/shaders/kernels.wgsl b/crates/gpu/src/shaders/kernels.wgsl index defbb5bf4..85ae25476 100644 --- a/crates/gpu/src/shaders/kernels.wgsl +++ b/crates/gpu/src/shaders/kernels.wgsl @@ -561,7 +561,7 @@ fn soft_linear_noise(x: f32, y: f32, z: f32, seed: u32) -> f32 { let wy = bspline(y + 0.5 - y0); let a = soft_linear_slice(i32(x0), i32(y0), i32(z0), wx, wy, seed); let b = soft_linear_slice(i32(x0), i32(y0), i32(z0) + 1, wx, wy, seed); - let n = a + (b - a) * fade(z - z0); + let n = a + (b - a) * (z - z0); return 0.5 + (n - 0.5) * SOFT_LINEAR_GAIN; } From fb16d5573e87b150730d94f8794c5fd5e37a4bd8 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 18 Oct 2026 04:59:38 +0200 Subject: [PATCH 07/46] Tint: Rec. 601 luminance weights, as After Effects After Effects 2026 (sRGB working space, not linearised) maps red, green, blue and 50% grey solids at Tint's defaults to 76, 150, 29 and 128/255: Rec. 601 weights on the working-space values, not Rec. 709. CPU and GPU kernel changed; tests pin the four greys within 1/255. Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/color_fx.rs | 20 +++++++++++++++++++- crates/gpu/src/shaders/kernels.wgsl | 3 ++- crates/gpu/src/tests.rs | 19 +++++++++++++++++++ crates/render/src/tests.rs | 16 ++++++++++++++++ docs/effects-parity.md | 5 +++-- 5 files changed, 59 insertions(+), 4 deletions(-) diff --git a/crates/effects/src/color_fx.rs b/crates/effects/src/color_fx.rs index cf6698fee..b9ec5f5f3 100644 --- a/crates/effects/src/color_fx.rs +++ b/crates/effects/src/color_fx.rs @@ -14,12 +14,19 @@ fn mix(a: [f32; 3], b: [f32; 3], t: f32) -> [f32; 3] { [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t] } +/// Tint's luminance: Rec. 601 weights on the working-space values, as After Effects 2026 +/// renders it (sRGB working space, not linearised: red 76/255, green 150/255, blue 29/255). +/// The GPU kernel (`pointwise` op 1) mirrors it. +fn tint_luma(c: [f32; 3]) -> f32 { + 0.299 * c[0] + 0.587 * c[1] + 0.114 * c[2] +} + fn tint(ctx: &EffectCtx, mut b: Buf) -> Buf { let black = ctx.params.color("black"); let white = ctx.params.color("white"); let amt = ctx.params.f("amount") as f32 / 100.0; b.img.map_straight(|c| { - let l = luminance(c[0], c[1], c[2]).clamp(0.0, 1.0); + let l = tint_luma(c).clamp(0.0, 1.0); let t = [black[0] + (white[0] - black[0]) * l, black[1] + (white[1] - black[1]) * l, black[2] + (white[2] - black[2]) * l]; mix(c, t, amt) }); @@ -1173,6 +1180,17 @@ mod tests { } } + /// After Effects 2026 renders of full-frame solids with Tint at its defaults (sRGB working + /// space, not linearised, 32 bpc, 8-bit capture): Rec. 601 weights on the encoded values. + #[test] + fn tint_defaults_match_after_effects_greys() { + for (c, want) in [([1.0, 0.0, 0.0], 76.0), ([0.0, 1.0, 0.0], 150.0), ([0.0, 0.0, 1.0], 29.0), ([0.5, 0.5, 0.5], 128.0)] { + let o = px("ec.color.tint", &[], [c[0], c[1], c[2], 1.0]); + let want = want / 255.0; + assert!(close(o, [want, want, want, 1.0], 1.0 / 255.0), "{c:?}: {o:?} vs {want}"); + } + } + #[test] fn colorama_reads_get_phase_from() { // Same intensity, different red: Intensity gives one colour, Red gives two. diff --git a/crates/gpu/src/shaders/kernels.wgsl b/crates/gpu/src/shaders/kernels.wgsl index defbb5bf4..ce23927af 100644 --- a/crates/gpu/src/shaders/kernels.wgsl +++ b/crates/gpu/src/shaders/kernels.wgsl @@ -605,7 +605,8 @@ fn pointwise(@builtin(global_invocation_id) gid: vec3) { case 1u: { if (a > 0.0) { let c = straight(px); - let l = clamp(luminance(c), 0.0, 1.0); + // Rec. 601 weights, as After Effects (effects crate tint_luma). + let l = clamp(0.299 * c.x + 0.587 * c.y + 0.114 * c.z, 0.0, 1.0); let blk = P.f[0].xyz; let wht = P.f[1].xyz; let t = blk + (wht - blk) * l; diff --git a/crates/gpu/src/tests.rs b/crates/gpu/src/tests.rs index 24f8c9614..a0abcd22c 100644 --- a/crates/gpu/src/tests.rs +++ b/crates/gpu/src/tests.rs @@ -851,6 +851,25 @@ fn effects_whose_kernels_failed_render_on_the_cpu() { } } +/// The GPU Tint gives After Effects 2026's greys for full-frame solids at its defaults (sRGB +/// working space, not linearised): Rec. 601 weights, as the CPU (`color_fx::tint_luma`). +#[test] +fn gpu_tint_defaults_match_after_effects_greys() { + let Some(g) = gpu() else { return }; + for (col, want) in [([1.0, 0.0, 0.0], 76.0), ([0.0, 1.0, 0.0], 150.0), ([0.0, 0.0, 1.0], 29.0), ([0.5, 0.5, 0.5], 128.0)] { + let mut s = Scene::new(BitDepth::Bpc32); + s.p.settings.working_space = Some(ColorSpace::Srgb); + let mut l = s.solid(col, 97, 61); + s.effect(&mut l, "ec.color.tint", &[]); + s.push(l); + let mut r = Renderer::new(&s.p, &Pattern, RenderOpts { backend: Backend::Gpu, ..opts() }); + r.accel = Some(g); + let o = g.render(&r, s.cid, Tick::ZERO).expect("the GPU renders the frame").get(48, 30); + let want = want / 255.0; + assert!(o[..3].iter().all(|v| (v - want).abs() <= 1.0 / 255.0), "{col:?}: {o:?} vs {want}"); + } +} + /// A layer whose bounds change every frame reads back at a new size each time: the staging /// buffers kept for reuse stay within budget instead of growing for the whole render (#2081). #[test] diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 72dd26507..14c110bf8 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -254,6 +254,22 @@ fn effects_run_in_pipeline() { assert!(c[1] > 0.99 && c[0] < 0.01, "{c:?}"); } +/// After Effects 2026, sRGB working space (not linearised), Tint at its defaults on full-frame +/// solids: the effect sees the encoded solid colour and weights it Rec. 601. +#[test] +fn tint_in_an_srgb_working_space_matches_after_effects_greys() { + for (col, want) in [([1.0, 0.0, 0.0], 76.0), ([0.0, 1.0, 0.0], 150.0), ([0.0, 0.0, 1.0], 29.0), ([0.5, 0.5, 0.5], 128.0)] { + let (mut p, cid, comp) = setup(); + p.settings.working_space = Some(effectcraft_color::ColorSpace::Srgb); + let mut l = solid(&mut p, &comp, col, 200, 100); + add_effect(&mut p, &mut l, "ec.color.tint", [200.0, 100.0], &[]); + p.comp_mut(cid).unwrap().layers.push(l); + let o = render_frame(&p, cid, Tick::ZERO, 1.0).get(100, 50); + let want = want / 255.0; + assert!(o[..3].iter().all(|v| (v - want).abs() <= 1.0 / 255.0), "{col:?}: {o:?} vs {want}"); + } +} + /// Card Dance's Comp Camera through the default comp camera reproduces a 2D layer in place. #[test] fn card_dance_comp_camera_matches_the_default_view() { diff --git a/docs/effects-parity.md b/docs/effects-parity.md index c5fbc5ba8..cf2c6bb7b 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -15,7 +15,7 @@ After Effects', and does the preset library exist. The per-effect catalogue is t | Parameter names, order, popups, units, defaults | aligned in M9.12 | Agent-checked against After Effects' documentation, not against a live dump | | On the GPU | **280** (92%) | Measured: registry flags; GPU vs CPU agreement tested per effect | | 32-bit float, no clamping | 306 | Measured: registry flags | -| **Rendered output matches After Effects** | **unmeasured** | No effect is compared with an After Effects render yet. `effectcraft_effects::fidelity::compare_frames` exists for the corpus ([fidelity/README.md](fidelity/README.md)); simulation effects are pinned by golden hashes of our own output (M13.27) | +| **Rendered output matches After Effects** | **unmeasured** | Only Tint is checked against After Effects renders (four solids, within 1/255). `effectcraft_effects::fidelity::compare_frames` exists for the corpus ([fidelity/README.md](fidelity/README.md)); simulation effects are pinned by golden hashes of our own output (M13.27) | | Animation presets | **54** original presets (8 text-animator, 23 Transitions, 23 Backgrounds) against After Effects' **621** `.ffx` presets | Measured: `crates/engine/presets/text_animators.json`, `crates/engine/presets/transitions.json`, `crates/engine/presets/backgrounds.json`; the After Effects `Presets` folder | | 26.x additions | Curl Noise (26.3) present (#463 open); effect colour labels (26.5) present in Effect Controls only, not in the Timeline; the rebuilt Effect Controls (26.5) missing | Release notes | @@ -30,7 +30,7 @@ corpus compares renders, each effect is assumed to differ somewhere, and users k | Audio | 13 | 0 | CPU by design (applied in the mixdown) | | Blur & Sharpen | 16 | 15 | Gaussian Blur interaction and output to be measured first ([fidelity/ae-2026-acceptance.md](fidelity/ae-2026-acceptance.md)) | | Channel | 13 | 13 | — | -| Color Correction | 40 | 40 | Tint has Swap Colors (#963); ACES 2.0 / OCIO 2.5 (26.5); Apply Color LUT (#514) and 32-bpc blending (#494) are closed | +| Color Correction | 40 | 40 | Tint has Swap Colors (#963) and matches After Effects 2026 renders of primary and grey solids (Rec. 601 luminance); ACES 2.0 / OCIO 2.5 (26.5); Apply Color LUT (#514) and 32-bpc blending (#494) are closed | | Distort | 39 | 35 | Liquify has no viewer brush; #303 (Mesh Warp drag) and #534 (CC Power Pin Unstretch) are closed | | Expression Controls | 9 | 9 | — | | Generate | 26 | 25 | Fractal on the CPU (needs f64); #526 (Reset) and #536 (Write-on marks) are closed | @@ -64,6 +64,7 @@ the GPU is performance, not parity. | Date | Change | Summary | |---|---|---| +| 2026-10-18 | minor | Tint uses Rec. 601 luminance weights, measured against After Effects 2026 renders of red, green, blue and grey solids (CPU and GPU) | | 2026-10-12 | minor | Parameters 3,014 to 3,017 (effects.md); #303, #514, #526, #533–#536 marked closed instead of pending | | 2026-10-12 | minor | Effect colour labels (26.5) added in Effect Controls | | 2026-10-12 | minor | Tint's Swap Colors button and agent-drivable command (#963); undo/redo, instance targeting, animated layer-time edits and invalid-input tests | From 960f41d50b30447ee786df2e0b4b09fe811ca0b7 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 19 Oct 2026 02:33:09 +0200 Subject: [PATCH 08/46] Tritone: Rec. 601 luminance, as After Effects After Effects' Tritone weighs the working-space values with Rec. 601 (0.299, 0.587, 0.114), as its Tint does; EffectCraft used Rec. 709. Case 718 (red solid, Highlights [1,1,0.8], Midtones [0.6,0.2,0.2], Shadows [0,0,0.3], Blend 25): AE (132, 23, 46), EffectCraft was (113, 16, 49), now (132, 23, 46). On a grey ramp (case 815) the shadows / midtones / highlights mapping already matched and still does within 1/255. CPU and GPU (fx_color op 4). Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/color_fx.rs | 38 +++++++++++++++++++++++----- crates/gpu/src/shaders/fx_color.wgsl | 3 ++- crates/gpu/src/shaders/kernels.wgsl | 2 +- crates/gpu/src/tests.rs | 2 +- docs/effects-parity.md | 3 ++- 5 files changed, 38 insertions(+), 10 deletions(-) diff --git a/crates/effects/src/color_fx.rs b/crates/effects/src/color_fx.rs index b9ec5f5f3..0040293e0 100644 --- a/crates/effects/src/color_fx.rs +++ b/crates/effects/src/color_fx.rs @@ -14,10 +14,13 @@ fn mix(a: [f32; 3], b: [f32; 3], t: f32) -> [f32; 3] { [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t] } -/// Tint's luminance: Rec. 601 weights on the working-space values, as After Effects 2026 -/// renders it (sRGB working space, not linearised: red 76/255, green 150/255, blue 29/255). -/// The GPU kernel (`pointwise` op 1) mirrors it. -fn tint_luma(c: [f32; 3]) -> f32 { +/// Rec. 601 luminance on the working-space values, as After Effects 2026 renders Tint, Tritone +/// and the other Rec. 601 effects (sRGB working space, not linearised: red 76/255, green +/// 150/255, blue 29/255). Tint and Tritone use it (Tritone, case 718: a red solid with +/// Blend With Original 25 gives (132, 23, 46), from luminance 0.299; Rec. 709's 0.213 gave +/// (113, 16, 49); on a grey ramp both weightings agree, case 815). The GPU kernels +/// (`pointwise` op 1, `fx_color` op 4) mirror it. +fn luma_601(c: [f32; 3]) -> f32 { 0.299 * c[0] + 0.587 * c[1] + 0.114 * c[2] } @@ -26,7 +29,7 @@ fn tint(ctx: &EffectCtx, mut b: Buf) -> Buf { let white = ctx.params.color("white"); let amt = ctx.params.f("amount") as f32 / 100.0; b.img.map_straight(|c| { - let l = tint_luma(c).clamp(0.0, 1.0); + let l = luma_601(c).clamp(0.0, 1.0); let t = [black[0] + (white[0] - black[0]) * l, black[1] + (white[1] - black[1]) * l, black[2] + (white[2] - black[2]) * l]; mix(c, t, amt) }); @@ -39,7 +42,7 @@ fn tritone(ctx: &EffectCtx, mut b: Buf) -> Buf { let sh = ctx.params.color("shadows"); let blend = 1.0 - ctx.params.f("blend") as f32 / 100.0; b.img.map_straight(|c| { - let l = luminance(c[0], c[1], c[2]).clamp(0.0, 1.0); + let l = luma_601(c).clamp(0.0, 1.0); let t = if l < 0.5 { mix([sh[0], sh[1], sh[2]], [mid[0], mid[1], mid[2]], l * 2.0) } else { @@ -1248,6 +1251,29 @@ mod tests { /// #1463: Black & White's sliders set the gray of each pure hue and blend between /// neighbouring hues; neutral pixels keep their value, overbright ones too, and an overbright /// colour stays finite (it used to divide by zero). + /// After Effects 2026: Tritone Highlights [1, 1, 0.8], Midtones [0.6, 0.2, 0.2], Shadows + /// [0, 0, 0.3]. On a red solid with Blend With Original 25 (case 718) AE gives (132, 23, 46); + /// on a grey ramp (case 815) levels 0, 63.9, 127.6, 191.4 give (0, 0, 77), (77, 26, 64), + /// (153, 51, 51), (204, 153, 128). + #[test] + fn tritone_matches_after_effects() { + let colours = [ + ("highlights", Value::Color([1.0, 1.0, 0.8, 1.0])), + ("midtones", Value::Color([0.6, 0.2, 0.2, 1.0])), + ("shadows", Value::Color([0.0, 0.0, 0.3, 1.0])), + ]; + let near = |o: [f32; 4], ae: [f32; 3]| (0..3).all(|i| (o[i] * 255.0 - ae[i]).abs() <= 1.0); + let mut red = colours.to_vec(); + red.push(("blend", num(25.0))); + let o = px("ec.color.tritone", &red, [1.0, 0.0, 0.0, 1.0]); + assert!(near(o, [132.0, 23.0, 46.0]), "{:?}", o.map(|v| v * 255.0)); + for (level, ae) in [(0.1f32, [0.0, 0.0, 77.0]), (63.9, [77.0, 26.0, 64.0]), (127.6, [153.0, 51.0, 51.0]), (191.4, [204.0, 153.0, 128.0])] { + let v = level / 255.0; + let o = px("ec.color.tritone", &colours, [v, v, v, 1.0]); + assert!(near(o, ae), "level {level}: {:?} vs {ae:?}", o.map(|v| v * 255.0)); + } + } + #[test] fn black_white_sliders_set_the_gray_of_each_hue() { let bw = |vals: &[(&str, Value)], c: [f32; 3]| px("ec.color.blackwhite", vals, [c[0], c[1], c[2], 1.0]); diff --git a/crates/gpu/src/shaders/fx_color.wgsl b/crates/gpu/src/shaders/fx_color.wgsl index 6b8641dd9..38325a944 100644 --- a/crates/gpu/src/shaders/fx_color.wgsl +++ b/crates/gpu/src/shaders/fx_color.wgsl @@ -167,7 +167,8 @@ fn fxc_point(@builtin(global_invocation_id) gid: vec3) { case 4u: { if (a > 0.0) { let c = px.xyz / a; - let l = clamp(luminance(c), 0.0, 1.0); + // Rec. 601 weights, as effects::color_fx::tritone. + let l = clamp(dot(c, vec3(0.299, 0.587, 0.114)), 0.0, 1.0); var t: vec3; if (l < 0.5) { t = fxc_mix(P.f[2].xyz, P.f[1].xyz, l * 2.0); diff --git a/crates/gpu/src/shaders/kernels.wgsl b/crates/gpu/src/shaders/kernels.wgsl index ce23927af..00d0e8bf2 100644 --- a/crates/gpu/src/shaders/kernels.wgsl +++ b/crates/gpu/src/shaders/kernels.wgsl @@ -605,7 +605,7 @@ fn pointwise(@builtin(global_invocation_id) gid: vec3) { case 1u: { if (a > 0.0) { let c = straight(px); - // Rec. 601 weights, as After Effects (effects crate tint_luma). + // Rec. 601 weights, as After Effects (effects crate luma_601). let l = clamp(0.299 * c.x + 0.587 * c.y + 0.114 * c.z, 0.0, 1.0); let blk = P.f[0].xyz; let wht = P.f[1].xyz; diff --git a/crates/gpu/src/tests.rs b/crates/gpu/src/tests.rs index a0abcd22c..11dc81a93 100644 --- a/crates/gpu/src/tests.rs +++ b/crates/gpu/src/tests.rs @@ -852,7 +852,7 @@ fn effects_whose_kernels_failed_render_on_the_cpu() { } /// The GPU Tint gives After Effects 2026's greys for full-frame solids at its defaults (sRGB -/// working space, not linearised): Rec. 601 weights, as the CPU (`color_fx::tint_luma`). +/// working space, not linearised): Rec. 601 weights, as the CPU (`color_fx::luma_601`). #[test] fn gpu_tint_defaults_match_after_effects_greys() { let Some(g) = gpu() else { return }; diff --git a/docs/effects-parity.md b/docs/effects-parity.md index cf2c6bb7b..9fce10665 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -30,7 +30,7 @@ corpus compares renders, each effect is assumed to differ somewhere, and users k | Audio | 13 | 0 | CPU by design (applied in the mixdown) | | Blur & Sharpen | 16 | 15 | Gaussian Blur interaction and output to be measured first ([fidelity/ae-2026-acceptance.md](fidelity/ae-2026-acceptance.md)) | | Channel | 13 | 13 | — | -| Color Correction | 40 | 40 | Tint has Swap Colors (#963) and matches After Effects 2026 renders of primary and grey solids (Rec. 601 luminance); ACES 2.0 / OCIO 2.5 (26.5); Apply Color LUT (#514) and 32-bpc blending (#494) are closed | +| Color Correction | 40 | 40 | Tint and Tritone match After Effects 2026 renders (Rec. 601 luminance); Tint has Swap Colors (#963); ACES 2.0 / OCIO 2.5 (26.5); Apply Color LUT (#514) and 32-bpc blending (#494) are closed | | Distort | 39 | 35 | Liquify has no viewer brush; #303 (Mesh Warp drag) and #534 (CC Power Pin Unstretch) are closed | | Expression Controls | 9 | 9 | — | | Generate | 26 | 25 | Fractal on the CPU (needs f64); #526 (Reset) and #536 (Write-on marks) are closed | @@ -64,6 +64,7 @@ the GPU is performance, not parity. | Date | Change | Summary | |---|---|---| +| 2026-10-19 | minor | Tritone uses Rec. 601 luminance, measured against After Effects 2026 renders of a red solid and a grey ramp (CPU and GPU) | | 2026-10-18 | minor | Tint uses Rec. 601 luminance weights, measured against After Effects 2026 renders of red, green, blue and grey solids (CPU and GPU) | | 2026-10-12 | minor | Parameters 3,014 to 3,017 (effects.md); #303, #514, #526, #533–#536 marked closed instead of pending | | 2026-10-12 | minor | Effect colour labels (26.5) added in Effect Controls | From 70c1349cbcc36a4bada8d0f37c1b9bae794ba086 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 19 Oct 2026 02:40:14 +0200 Subject: [PATCH 09/46] Black & White: Tint keeps the gray's luminosity, as After Effects After Effects tints Black & White's gray like a Color blend: the tint colour's hue and saturation at the gray's luminosity (weights 0.3 / 0.59 / 0.11, channels outside 0..1 pulled back toward the gray). EffectCraft used the gray as HSL lightness. Measured in AE 2026, Tint Color [1, 0.5, 0]: a red solid at Reds 10 (case 717) AE (43, 21, 0), EffectCraft was (51, 26, 0), now (43, 21, 0); a yellow solid at the default sliders (gray 0.6, case 825) AE (255, 129, 2), now (255, 129, 3); white stays white. The six sliders already matched AE on eight colours (cases 816, 817). CPU and GPU. Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/color_fx.rs | 61 +++++++++++++++++++++++----- crates/gpu/src/fx_color.rs | 3 +- crates/gpu/src/shaders/fx_color.wgsl | 28 ++++++++++++- docs/effects-parity.md | 3 +- 4 files changed, 80 insertions(+), 15 deletions(-) diff --git a/crates/effects/src/color_fx.rs b/crates/effects/src/color_fx.rs index 0040293e0..e93026df0 100644 --- a/crates/effects/src/color_fx.rs +++ b/crates/effects/src/color_fx.rs @@ -343,6 +343,33 @@ pub fn mirror_pow(v: f32, p: f32) -> f32 { /// It used to offset the luminance by the HSL saturation, which is near 1 for any tinted /// near-black pixel: the faint colour noise of compressed footage's shadows came out as bright /// and black blocks (#1463), and 32-bpc overbrights divided by zero. +/// Black & White's Tint: the tint colour's hue and saturation at the gray's luminosity, as a +/// Color blend does (luminosity with weights 0.3 / 0.59 / 0.11, out-of-range channels pulled +/// back toward the gray). After Effects 2026 with Tint Color [1, 0.5, 0]: gray 0.1 (a red solid +/// at Reds 10, case 717) → (43, 21, 0), gray 0.6 (yellow at defaults, case 825) → (255, 129, 2), +/// white → white; the old hue / saturation / lightness mix gave (51, 26, 0) for the first. +pub fn bw_tint(tint: [f32; 3], gray: f32) -> [f32; 3] { + let lum = |c: [f32; 3]| 0.3 * c[0] + 0.59 * c[1] + 0.11 * c[2]; + if !gray.is_finite() || gray <= 0.0 { + return [0.0; 3]; + } + if gray >= 1.0 { + return [gray; 3]; + } + let d = gray - lum(tint); + let c = tint.map(|v| v + d); + let l = lum(c); + let (n, x) = (c[0].min(c[1]).min(c[2]), c[0].max(c[1]).max(c[2])); + let mut o = c; + if n < 0.0 && l - n > 1e-9 { + o = o.map(|v| l + (v - l) * l / (l - n)); + } + if x > 1.0 && x - l > 1e-9 { + o = o.map(|v| l + (v - l) * (1.0 - l) / (x - l)); + } + o +} + fn black_white(ctx: &EffectCtx, mut b: Buf) -> Buf { let w = [ctx.params.f("reds"), ctx.params.f("yellows"), ctx.params.f("greens"), ctx.params.f("cyans"), ctx.params.f("blues"), ctx.params.f("magentas")] .map(|v| v as f32 / 100.0); @@ -357,13 +384,7 @@ fn black_white(ctx: &EffectCtx, mut b: Buf) -> Buf { let wt = w[i] + (w[(i + 1) % 6] - w[i]) * f; let (max, min) = (c[0].max(c[1]).max(c[2]), c[0].min(c[1]).min(c[2])); let gray = (min + (max - min) * wt).max(0.0); - if tint_on { - let (th, ts, _) = rgb_to_hsl(tc[0], tc[1], tc[2]); - let (r, g, bl) = hsl_to_rgb(th, ts, gray.min(1.0)); - [r, g, bl] - } else { - [gray, gray, gray] - } + if tint_on { bw_tint([tc[0], tc[1], tc[2]], gray) } else { [gray, gray, gray] } }); b } @@ -1248,9 +1269,6 @@ mod tests { assert!(go(0.0, 100.0, 64) >= 30); } - /// #1463: Black & White's sliders set the gray of each pure hue and blend between - /// neighbouring hues; neutral pixels keep their value, overbright ones too, and an overbright - /// colour stays finite (it used to divide by zero). /// After Effects 2026: Tritone Highlights [1, 1, 0.8], Midtones [0.6, 0.2, 0.2], Shadows /// [0, 0, 0.3]. On a red solid with Blend With Original 25 (case 718) AE gives (132, 23, 46); /// on a grey ramp (case 815) levels 0, 63.9, 127.6, 191.4 give (0, 0, 77), (77, 26, 64), @@ -1274,6 +1292,29 @@ mod tests { } } + /// After Effects 2026, Tint Color [1, 0.5, 0]: a red solid at Reds 10 (case 717) and yellow + /// and white solids at the default sliders (case 825). + #[test] + fn black_white_tint_matches_after_effects() { + let tint = [("tint", Value::Bool(true)), ("tintColor", Value::Color([1.0, 0.5, 0.0, 1.0]))]; + let near = |o: [f32; 4], ae: [f32; 3]| (0..3).all(|i| (o[i] * 255.0 - ae[i]).abs() <= 1.5); + let mut reds = tint.to_vec(); + reds.push(("reds", num(10.0))); + let o = px("ec.color.blackwhite", &reds, [1.0, 0.0, 0.0, 1.0]); + assert!(near(o, [43.0, 21.0, 0.0]), "{:?}", o.map(|v| v * 255.0)); + let o = px("ec.color.blackwhite", &tint, [1.0, 1.0, 0.0, 1.0]); + assert!(near(o, [255.0, 129.0, 2.0]), "{:?}", o.map(|v| v * 255.0)); + let o = px("ec.color.blackwhite", &tint, [1.0, 1.0, 1.0, 1.0]); + assert!(near(o, [255.0, 255.0, 255.0]), "{:?}", o.map(|v| v * 255.0)); + // Hostile grays stay finite. + for g in [f32::NAN, -1.0, 0.0, 4.0] { + assert!(bw_tint([1.0, 0.5, 0.0], g).iter().all(|v| v.is_finite())); + } + } + + /// #1463: Black & White's sliders set the gray of each pure hue and blend between + /// neighbouring hues; neutral pixels keep their value, overbright ones too, and an overbright + /// colour stays finite (it used to divide by zero). #[test] fn black_white_sliders_set_the_gray_of_each_hue() { let bw = |vals: &[(&str, Value)], c: [f32; 3]| px("ec.color.blackwhite", vals, [c[0], c[1], c[2], 1.0]); diff --git a/crates/gpu/src/fx_color.rs b/crates/gpu/src/fx_color.rs index b9ae5282c..18bd8409f 100644 --- a/crates/gpu/src/fx_color.rs +++ b/crates/gpu/src/fx_color.rs @@ -90,11 +90,10 @@ fn point(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option { "ec.color.blackwhite" => { let w = ["reds", "yellows", "greens", "cyans", "blues", "magentas"].map(|k| f(k) / 100.0); let tc = pr.color("tintColor"); - let (th, ts, _) = rgb_to_hsl(tc[0], tc[1], tc[2]); p.u[0] = [3, pr.b("tint") as u32, 0, 0]; p.f[0] = [w[0], w[1], w[2], w[3]]; p.f[1] = [w[4], w[5], 0.0, 0.0]; - p.f[2] = [th, ts, 0.0, 0.0]; + p.f[2] = [tc[0], tc[1], tc[2], 0.0]; } "ec.color.tritone" => { p.u[0][0] = 4; diff --git a/crates/gpu/src/shaders/fx_color.wgsl b/crates/gpu/src/shaders/fx_color.wgsl index 38325a944..daac9fb5a 100644 --- a/crates/gpu/src/shaders/fx_color.wgsl +++ b/crates/gpu/src/shaders/fx_color.wgsl @@ -100,11 +100,35 @@ fn fxc_sc_weights(c0: vec3) -> array { return w; } +// Black & White's Tint (effects::color_fx::bw_tint): the tint's hue and saturation at the +// gray's luminosity (Color blend, weights 0.3 / 0.59 / 0.11, channels clipped toward the gray). +fn fxc_bw_tint(tint: vec3, gray: f32) -> vec3 { + if (!(gray > 0.0)) { + return vec3(0.0); + } + if (gray >= 1.0) { + return vec3(gray); + } + let w = vec3(0.3, 0.59, 0.11); + let c = tint + vec3(gray - dot(tint, w)); + let l = dot(c, w); + let n = min(min(c.x, c.y), c.z); + let x = max(max(c.x, c.y), c.z); + var o = c; + if (n < 0.0 && l - n > 1e-9) { + o = vec3(l) + (o - vec3(l)) * l / (l - n); + } + if (x > 1.0 && x - l > 1e-9) { + o = vec3(l) + (o - vec3(l)) * (1.0 - l) / (x - l); + } + return o; +} + // u[0].x = op: // 1 Color Balance f[0] = shadows, f[1] = midtones, f[2] = highlights; u[0].y = preserve luminosity // 2 Vibrance f[0] = (vibrance, saturation) // 3 Black & White f[0] = (reds, yellows, greens, cyans), f[1] = (blues, magentas); -// u[0].y = tint; f[2] = (tint hue, tint saturation) +// u[0].y = tint; f[2] = tint colour // 4 Tritone f[0] = highlights, f[1] = midtones, f[2] = shadows; f[3].x = 1 − blend // 5 Colorama u[0].y = phase mode, u[0].z = interpolate; f[0] = (cycles, shift, 1 − blend) // 6 Channel Mixer f[0..3] = output rows (r, g, b, const); u[0].y = monochrome @@ -159,7 +183,7 @@ fn fxc_point(@builtin(global_invocation_id) gid: vec3) { let gray = max(mn + (mx - mn) * wt, 0.0); var o = vec3(gray); if (P.u[0].y != 0u) { - o = hsl_to_rgb(P.f[2].x, P.f[2].y, min(gray, 1.0)); + o = fxc_bw_tint(P.f[2].xyz, gray); } px = vec4(o * a, a); } diff --git a/docs/effects-parity.md b/docs/effects-parity.md index 9fce10665..2b8b72e14 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -30,7 +30,7 @@ corpus compares renders, each effect is assumed to differ somewhere, and users k | Audio | 13 | 0 | CPU by design (applied in the mixdown) | | Blur & Sharpen | 16 | 15 | Gaussian Blur interaction and output to be measured first ([fidelity/ae-2026-acceptance.md](fidelity/ae-2026-acceptance.md)) | | Channel | 13 | 13 | — | -| Color Correction | 40 | 40 | Tint and Tritone match After Effects 2026 renders (Rec. 601 luminance); Tint has Swap Colors (#963); ACES 2.0 / OCIO 2.5 (26.5); Apply Color LUT (#514) and 32-bpc blending (#494) are closed | +| Color Correction | 40 | 40 | Tint, Tritone and Black & White (sliders and Tint) match After Effects 2026 renders (Rec. 601 luminance); Tint has Swap Colors (#963); ACES 2.0 / OCIO 2.5 (26.5); Apply Color LUT (#514) and 32-bpc blending (#494) are closed | | Distort | 39 | 35 | Liquify has no viewer brush; #303 (Mesh Warp drag) and #534 (CC Power Pin Unstretch) are closed | | Expression Controls | 9 | 9 | — | | Generate | 26 | 25 | Fractal on the CPU (needs f64); #526 (Reset) and #536 (Write-on marks) are closed | @@ -65,6 +65,7 @@ the GPU is performance, not parity. | Date | Change | Summary | |---|---|---| | 2026-10-19 | minor | Tritone uses Rec. 601 luminance, measured against After Effects 2026 renders of a red solid and a grey ramp (CPU and GPU) | +| 2026-10-19 | minor | Black & White's Tint keeps the gray's luminosity (Color blend), measured against After Effects 2026 renders (CPU and GPU) | | 2026-10-18 | minor | Tint uses Rec. 601 luminance weights, measured against After Effects 2026 renders of red, green, blue and grey solids (CPU and GPU) | | 2026-10-12 | minor | Parameters 3,014 to 3,017 (effects.md); #303, #514, #526, #533–#536 marked closed instead of pending | | 2026-10-12 | minor | Effect colour labels (26.5) added in Effect Controls | From c7060e2d759cdab8436d1e2806472f292a61b0ad Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 18 Oct 2026 06:15:06 +0200 Subject: [PATCH 10/46] Exposure: gamma 2.2 linear-light conversion, as After Effects With Bypass Linear Light Conversion off, Exposure decoded and re-encoded with the piecewise sRGB curve. After Effects 2026 renders (sRGB working space, 32 bpc) of Offset 0.0005 / 0.002 / 0.02 / 0.1 on black measure 8 / 15 / 43 / 90, which a pure 2.2 power predicts (8.06 / 15.13 / 43.08 / 89.54) and the sRGB curve does not (1.65 / 6.59 / 38.68 / 89.04). The effect now uses its own 2.2 power curve on the CPU and in the GPU kernel; the shared colour functions and other effects are unchanged. As before, the conversion applies whatever the working space, also a linearised one. Negative values go through both curves mirrored (input was clamped at 0 before), so defaults are an identity for 32-bpc values and a negative Offset darkens past black without NaN. New test against the AE values; existing Exposure tests still hold. Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/color_fx.rs | 43 +++++++++++++++++++++++++++-- crates/gpu/src/shaders/kernels.wgsl | 7 +++-- docs/effects-parity.md | 1 + 3 files changed, 46 insertions(+), 5 deletions(-) diff --git a/crates/effects/src/color_fx.rs b/crates/effects/src/color_fx.rs index e93026df0..3556fbe59 100644 --- a/crates/effects/src/color_fx.rs +++ b/crates/effects/src/color_fx.rs @@ -314,6 +314,17 @@ pub fn exposure_settings(ctx: &EffectCtx) -> [(f32, f32, f32); 3] { } } +/// The power curve Exposure's own linear-light conversion uses (Bypass Linear Light Conversion +/// off). After Effects decodes with a pure 2.2 power, not the piecewise sRGB curve: Offset 0.0005 +/// on black measures 8/255 there, which 2.2 predicts (8.06) and sRGB does not (1.65). +pub const EXPOSURE_LINEAR_GAMMA: f32 = 2.2; + +/// Exposure: per channel, `(value · 2^exposure + offset)^(1 / gamma)`, in linear light unless +/// Bypass Linear Light Conversion is on. The conversion is the effect's own pure 2.2 power curve +/// (see [`EXPOSURE_LINEAR_GAMMA`]), applied whatever the project's working space, also when it +/// is already linear (Linearize Working Space), as before. Negative values (32 bpc, or a +/// negative Offset) go through the curves mirrored, so a negative Offset darkens smoothly past +/// black and the default settings leave every value unchanged. fn exposure(ctx: &EffectCtx, mut b: Buf) -> Buf { let s = exposure_settings(ctx); let bypass = ctx.params.b("bypassLinearLight"); @@ -323,9 +334,9 @@ fn exposure(ctx: &EffectCtx, mut b: Buf) -> Buf { if bypass { return mirror_pow(c[i] * e + off, 1.0 / g); } - let lin = effectcraft_color::srgb_to_linear(c[i].max(0.0)); + let lin = mirror_pow(c[i], EXPOSURE_LINEAR_GAMMA); let o = mirror_pow(lin * e + off, 1.0 / g); - effectcraft_color::linear_to_srgb(o.abs()).copysign(o) + mirror_pow(o, 1.0 / EXPOSURE_LINEAR_GAMMA) }) }); b @@ -1406,6 +1417,34 @@ mod tests { assert!((neg[0] + 0.5).abs() < 1e-4, "{neg:?}"); } + /// Exposure on a red [1, 0, 0] solid against After Effects 2026 renders (sRGB working space, + /// not linearised, 32 bpc, 8-bit captures): the linear-light conversion is a pure 2.2 power. + /// The piecewise sRGB curve gave 2 / 7 / 39 for the three smallest Offsets. + #[test] + fn exposure_matches_after_effects_gamma_2_2() { + let red = [1.0, 0.0, 0.0, 1.0]; + let code = |o: [f32; 4], i: usize| o[i] * 255.0; + // Master Offset only: G and B carry the curve; R goes over 1 (255 in the 8-bit capture). + for (offset, ae) in [(0.0005, 8.0), (0.002, 15.0), (0.02, 43.0), (0.1, 90.0)] { + let o = px("ec.color.exposure", &[("master/offset", num(offset))], red); + for i in [1, 2] { + assert!((code(o, i) - ae).abs() <= 1.0, "offset {offset}: {} vs AE {ae}", code(o, i)); + } + assert!(o[0] >= 1.0, "{o:?}"); + } + // Exposure −2.25: AE 126; with Bypass Linear Light Conversion (gain on encoded values) 54. + let o = px("ec.color.exposure", &[("master/exposure", num(-2.25))], red); + assert!((code(o, 0) - 126.0).abs() <= 1.0 && o[1] == 0.0, "{o:?}"); + let o = px("ec.color.exposure", &[("master/exposure", num(-2.25)), ("bypassLinearLight", Value::Bool(true))], red); + assert!((code(o, 0) - 54.0).abs() <= 1.0 && o[1] == 0.0, "{o:?}"); + // Default settings leave 32-bpc values alone, negative and over-range ones included. + let hdr = [-0.25, 0.5, 2.0, 1.0]; + assert!(close(px("ec.color.exposure", &[], hdr), hdr, 1e-5)); + // A negative Offset pushes black below zero, mirrored, rather than clipping or NaN. + let o = px("ec.color.exposure", &[("master/offset", num(-0.02))], [0.0, 0.0, 0.0, 1.0]); + assert!((o[0] + 0.02f32.powf(1.0 / 2.2)).abs() < 1e-5, "{o:?}"); + } + #[test] fn change_to_color_change_modes_tolerances_and_matte() { let red = [0.8, 0.1, 0.1, 1.0]; diff --git a/crates/gpu/src/shaders/kernels.wgsl b/crates/gpu/src/shaders/kernels.wgsl index 00d0e8bf2..870430a5b 100644 --- a/crates/gpu/src/shaders/kernels.wgsl +++ b/crates/gpu/src/shaders/kernels.wgsl @@ -714,14 +714,15 @@ fn pointwise(@builtin(global_invocation_id) gid: vec3) { let c = straight(px); var o = vec3(0.0); for (var i = 0; i < 3; i++) { - // Per-channel gain, offset, gamma; negative values are mirrored. + // Per-channel gain, offset, gamma; negative values are mirrored. The + // linear-light conversion is a pure 2.2 power (effects crate exposure). if (P.u[0].y != 0u) { let x = c[i] * P.f[0][i] + P.f[1][i]; o[i] = sign(x) * powz(abs(x), 1.0 / P.f[2][i]); } else { - let lin = srgb_to_linear(max(c[i], 0.0)); + let lin = sign(c[i]) * powz(abs(c[i]), 2.2); let x = lin * P.f[0][i] + P.f[1][i]; - o[i] = sign(x) * linear_to_srgb(powz(abs(x), 1.0 / P.f[2][i])); + o[i] = sign(x) * powz(powz(abs(x), 1.0 / P.f[2][i]), 1.0 / 2.2); } } px = vec4(o * a, a); diff --git a/docs/effects-parity.md b/docs/effects-parity.md index 2b8b72e14..88b42d627 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -67,6 +67,7 @@ the GPU is performance, not parity. | 2026-10-19 | minor | Tritone uses Rec. 601 luminance, measured against After Effects 2026 renders of a red solid and a grey ramp (CPU and GPU) | | 2026-10-19 | minor | Black & White's Tint keeps the gray's luminosity (Color blend), measured against After Effects 2026 renders (CPU and GPU) | | 2026-10-18 | minor | Tint uses Rec. 601 luminance weights, measured against After Effects 2026 renders of red, green, blue and grey solids (CPU and GPU) | +| 2026-10-18 | minor | Exposure converts to linear light with a pure 2.2 power curve, as measured in After Effects (Offset 0.0005 / 0.002 / 0.02 / 0.1 on black: 8 / 15 / 43 / 90), not the piecewise sRGB curve | | 2026-10-12 | minor | Parameters 3,014 to 3,017 (effects.md); #303, #514, #526, #533–#536 marked closed instead of pending | | 2026-10-12 | minor | Effect colour labels (26.5) added in Effect Controls | | 2026-10-12 | minor | Tint's Swap Colors button and agent-drivable command (#963); undo/redo, instance targeting, animated layer-time edits and invalid-input tests | From 617b0a578d9b16333483cf82c834d9294352404e Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 18 Oct 2026 06:22:38 +0200 Subject: [PATCH 11/46] Exposure: After Effects parameter ranges All twelve sliders (Master, Red, Green, Blue) take After Effects' ranges: Exposure valid -100..100, slider -4..4, 2 decimals (was -20..20, slider -5..5); Offset -2..2, slider -0.5..0.5, 4 decimals (unchanged); Gamma Correction valid and slider 0.1..10, 2 decimals (was 0.01..9.99, slider 0.1..3). Every value inside the old Exposure and Offset ranges stays valid. Saved values are not clamped on load, so a gamma below 0.1 from an older project or a Photoshop import (0.01..9.99) is kept and still renders (the renderer's 0.01 floor is unchanged); only new edits clamp to 0.1. Test of the ranges and of an old gamma surviving the load refresh. Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/color_fx.rs | 38 ++++++++++++++++++++++++++++++++-- docs/effects-parity.md | 1 + 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/crates/effects/src/color_fx.rs b/crates/effects/src/color_fx.rs index 3556fbe59..5a00114fc 100644 --- a/crates/effects/src/color_fx.rs +++ b/crates/effects/src/color_fx.rs @@ -1011,11 +1011,13 @@ pub fn specs() -> Vec { "Exposure", { let mut v = vec![p("channels", "Channels", Value::Enum(0), popup(&["Master", "Individual Channels"]))]; + // After Effects' ranges (valid, slider, decimals). Gamma values below 0.1 from + // older projects or Photoshop imports (0.01..9.99) are kept and still render. let group = |ids: [&'static str; 3]| { [ - p(ids[0], "Exposure", num(0.0), slider(-20.0, 20.0, -5.0, 5.0, 2)), + p(ids[0], "Exposure", num(0.0), slider(-100.0, 100.0, -4.0, 4.0, 2)), p(ids[1], "Offset", num(0.0), slider(-2.0, 2.0, -0.5, 0.5, 4)), - p(ids[2], "Gamma Correction", num(1.0), slider(0.01, 9.99, 0.1, 3.0, 2)), + p(ids[2], "Gamma Correction", num(1.0), slider(0.1, 10.0, 0.1, 10.0, 2)), ] }; v.extend(group(["master/exposure", "master/offset", "master/gamma"])); @@ -1417,6 +1419,38 @@ mod tests { assert!((neg[0] + 0.5).abs() < 1e-4, "{neg:?}"); } + /// After Effects' Exposure ranges for all twelve sliders; a saved value outside them (a gamma + /// of 0.05, valid before, as in Photoshop) survives loading and still renders. + #[test] + fn exposure_has_after_effects_ranges_and_keeps_old_values() { + use effectcraft_project::build::Ids; + let spec = crate::find("ec.color.exposure").unwrap(); + let want = [(-100.0, 100.0, -4.0, 4.0, 2), (-2.0, 2.0, -0.5, 0.5, 4), (0.1, 10.0, 0.1, 10.0, 2)]; + let sliders: Vec<_> = spec + .params + .iter() + .filter_map(|p| match p.ui { + ParamUi::Slider { min, max, slider_min, slider_max, decimals } => Some((p.id, (min, max, slider_min, slider_max, decimals))), + _ => None, + }) + .collect(); + assert_eq!(sliders.len(), 12); + for (i, (id, r)) in sliders.iter().enumerate() { + assert_eq!(*r, want[i % 3], "{id}"); + } + let mut next = 1; + let mut g = crate::instantiate(spec, &mut Ids(&mut next), spec.name, [10.0, 10.0]); + let gamma = g.prop_mut("master/gamma").unwrap(); + gamma.value = num(0.05); + gamma.ui = slider(0.01, 9.99, 0.1, 3.0, 2); + crate::migrate::upgrade_instance(spec, &mut g, &mut Ids(&mut next), [10.0, 10.0]); + let gamma = g.prop_mut("master/gamma").unwrap(); + assert_eq!(gamma.value, num(0.05)); + assert!(matches!(gamma.ui, ParamUi::Slider { min, .. } if min == 0.1)); + let o = px("ec.color.exposure", &[("master/gamma", num(0.05)), ("bypassLinearLight", Value::Bool(true))], [0.5, 0.5, 0.5, 1.0]); + assert!((o[0] - 0.5f32.powf(20.0)).abs() < 1e-9, "{o:?}"); + } + /// Exposure on a red [1, 0, 0] solid against After Effects 2026 renders (sRGB working space, /// not linearised, 32 bpc, 8-bit captures): the linear-light conversion is a pure 2.2 power. /// The piecewise sRGB curve gave 2 / 7 / 39 for the three smallest Offsets. diff --git a/docs/effects-parity.md b/docs/effects-parity.md index 88b42d627..f83258759 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -68,6 +68,7 @@ the GPU is performance, not parity. | 2026-10-19 | minor | Black & White's Tint keeps the gray's luminosity (Color blend), measured against After Effects 2026 renders (CPU and GPU) | | 2026-10-18 | minor | Tint uses Rec. 601 luminance weights, measured against After Effects 2026 renders of red, green, blue and grey solids (CPU and GPU) | | 2026-10-18 | minor | Exposure converts to linear light with a pure 2.2 power curve, as measured in After Effects (Offset 0.0005 / 0.002 / 0.02 / 0.1 on black: 8 / 15 / 43 / 90), not the piecewise sRGB curve | +| 2026-10-18 | minor | Exposure converts to linear light with a pure 2.2 power curve, as measured in After Effects (Offset 0.0005 / 0.002 / 0.02 / 0.1 on black: 8 / 15 / 43 / 90), not the piecewise sRGB curve; its sliders take After Effects' ranges (Exposure −100..100, slider −4..4; Offset −2..2, slider −0.5..0.5; Gamma Correction 0.1..10) | | 2026-10-12 | minor | Parameters 3,014 to 3,017 (effects.md); #303, #514, #526, #533–#536 marked closed instead of pending | | 2026-10-12 | minor | Effect colour labels (26.5) added in Effect Controls | | 2026-10-12 | minor | Tint's Swap Colors button and agent-drivable command (#963); undo/redo, instance targeting, animated layer-time edits and invalid-input tests | From 515cc3d5f711805982e4fd9dbe1586cd1dfc42eb Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 19 Oct 2026 11:26:09 +0200 Subject: [PATCH 12/46] Levels: channel controls before RGB, "Off for 32 bpc Color" unclipped in 32 bpc, as After Effects After Effects 2026 in a 32 bpc project, Levels (Individual Controls) with RGB (0.1, 0.9, gamma 1.5, 0.05, 0.95), Red Gamma 0.8, Green Input White 0.8 and Blue Output Black 0.2 on grey steps 0..255 (case 904 E07): every step matches "each channel's controls, then the RGB controls" with nothing clipped (values below Input Black end below Output Black, above Input White above Output White). EffectCraft ran RGB first and clipped "Off for 32 bpc Color" (it could not see the bit depth): up to 27/255 off on 95 % of the grid; now 0 grid points off, max 1/255, CPU and GPU. EffectEnv gains float_depth (the renderer sets it for 32 bpc projects), so "Off for 32 bpc Color" clips in 8 / 16 bpc projects as before. Unclipped values below 0 take the gamma mirrored through 0. Plain Levels keeps its order (its channel controls were not measured); its clipping follows the same rule. EffectCraft's own projects default to 8 bpc and are unchanged. Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/color2.rs | 64 ++++++++++++++++++++++++++++- crates/effects/src/color_fx.rs | 27 +++++++++--- crates/effects/src/lib.rs | 2 + crates/gpu/src/deferred.rs | 1 + crates/gpu/src/shaders/fx_tone.wgsl | 15 +++++-- crates/gpu/src/shaders/kernels.wgsl | 7 +++- crates/render/src/lib.rs | 1 + docs/effects-parity.md | 3 +- 8 files changed, 106 insertions(+), 14 deletions(-) diff --git a/crates/effects/src/color2.rs b/crates/effects/src/color2.rs index 71dee87b4..d78b0fb08 100644 --- a/crates/effects/src/color2.rs +++ b/crates/effects/src/color2.rs @@ -201,7 +201,7 @@ impl Lv { if clip_w { t = t.min(1.0); } - self.ob + (self.ow - self.ob) * t.max(0.0).powf(1.0 / self.g.max(0.01)) + self.ob + (self.ow - self.ob) * crate::color_fx::levels_gamma(t, self.g.max(0.01)) } } @@ -217,7 +217,10 @@ fn levels_ic(ctx: &EffectCtx, mut b: Buf) -> Buf { return b; } let clip = crate::color_fx::levels_clip(ctx); - map_ca(&mut b.img, |c, a| ([0, 1, 2].map(|i| lv[i + 1].apply(lv[0].apply(c[i], clip), clip)), lv[4].apply(a, clip).clamp(0.0, 1.0))); + // Each channel's own controls first, then the RGB controls: After Effects 2026 with RGB + // (0.1, 0.9, gamma 1.5, 0.05, 0.95) and Green Input White 0.8 maps grey 32/255 to green + // 52/255 (RGB first would give 45). + map_ca(&mut b.img, |c, a| ([0, 1, 2].map(|i| lv[0].apply(lv[i + 1].apply(c[i], clip), clip)), lv[4].apply(a, clip).clamp(0.0, 1.0))); b } @@ -1139,6 +1142,63 @@ mod tests { assert!((red.data[0][0] - 1.0).abs() < 1e-4 && (red.data[0][1] - 0.5).abs() < 1e-6); } + /// After Effects 2026, 32 bpc project, Levels (Individual Controls) with RGB Input Black 0.1, + /// Input White 0.9, Gamma 1.5, Output Black 0.05, Output White 0.95, Red Gamma 0.8, Green + /// Input White 0.8, Blue Output Black 0.2, both clips "Off for 32 bpc Color", on grey steps: + /// the channel controls run first, nothing clips (8-bit captures clamp the results). + #[test] + fn levels_ic_matches_after_effects_in_32_bpc() { + let steps = [0, 16, 32, 64, 96, 128, 160, 192, 224, 255]; + let ae = [ + [0, 0, 70], + [0, 0, 88], + [0, 52, 104], + [61, 108, 132], + [102, 150, 158], + [138, 187, 181], + [171, 220, 203], + [203, 250, 223], + [233, 255, 243], + [255, 255, 255], + ]; + let mut img = Image::new(10, 1); + for (x, v) in steps.iter().enumerate() { + let v = *v as f32 / 255.0; + img.set(x as u32, 0, [v, v, v, 1.0]); + } + let vals = [ + ("rgb/rgbInBlack", Value::Scalar(0.1)), + ("rgb/rgbInWhite", Value::Scalar(0.9)), + ("rgb/rgbGamma", Value::Scalar(1.5)), + ("rgb/rgbOutBlack", Value::Scalar(0.05)), + ("rgb/rgbOutWhite", Value::Scalar(0.95)), + ("red/redGamma", Value::Scalar(0.8)), + ("green/greenInWhite", Value::Scalar(0.8)), + ("blue/blueOutBlack", Value::Scalar(0.2)), + ]; + let s = find("ec.color.levelsic").unwrap(); + let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }; + for (k, v) in &vals { + params.values.insert(k.to_string(), v.clone()); + } + let render = |float_depth: bool| { + let env = crate::EffectEnv { float_depth, ..Default::default() }; + let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [10.0, 1.0], seed: 1, adjustment: false, env }; + (s.render)(&ctx, Buf { img: img.clone(), offset: [0.0, 0.0], scale: 1.0 }).img + }; + let out = render(true); + for (x, want) in ae.iter().enumerate() { + let p = out.get(x as i64, 0); + for c in 0..3 { + let got = p[c].clamp(0.0, 1.0) * 255.0; + assert!((got - want[c] as f32).abs() <= 2.0, "step {} channel {c}: {got} vs AE {}", steps[x], want[c]); + } + } + // In 8 / 16 bpc "Off for 32 bpc Color" clips: black stops at Output Black. + let clipped = render(false); + assert!((clipped.get(0, 0)[0] - 0.05).abs() < 1e-5, "{:?}", clipped.get(0, 0)); + } + #[test] fn levels_ic_identity_and_red_only() { let img = ramp(); diff --git a/crates/effects/src/color_fx.rs b/crates/effects/src/color_fx.rs index 5a00114fc..705cc3c7c 100644 --- a/crates/effects/src/color_fx.rs +++ b/crates/effects/src/color_fx.rs @@ -207,16 +207,32 @@ fn hue_saturation(ctx: &EffectCtx, mut b: Buf) -> Buf { pub const LEVELS_CLIP: [&str; 3] = ["Off", "On", "Off for 32 bpc Color"]; /// Whether Levels clips below Input Black / above Input White. "Off for 32 bpc Color" clips -/// (effects can't see the project's bit depth; 8/16 bpc behaviour). The hidden `noClip` switch -/// of projects saved before the two popups existed turns both off. +/// in 8 and 16 bpc projects and not in 32 bpc ones (After Effects 2026, Levels (Individual +/// Controls) in a 32 bpc project: values below Input Black end below Output Black). The hidden +/// `noClip` switch of projects saved before the two popups existed turns both off. pub fn levels_clip(ctx: &EffectCtx) -> (bool, bool) { if ctx.params.b("noClip") { return (false, false); } - let on = |id: &str| ctx.params.get(id).is_none_or(|v| v.as_enum() != 0); + let on = |id: &str| levels_clip_on(ctx, id); (on("clipToOutputBlack"), on("clipToOutputWhite")) } +/// One Clip To Output popup: 0 Off, 1 On, 2 Off for 32 bpc Color (missing: clip). +pub fn levels_clip_on(ctx: &EffectCtx, id: &str) -> bool { + match ctx.params.get(id).map(|v| v.as_enum()) { + Some(0) => false, + Some(2) => !ctx.env.float_depth, + _ => true, + } +} + +/// Levels' gamma on the normalised input `t`: unclipped values below Input Black extrapolate +/// (the curve mirrored through 0) instead of stopping at Output Black. +pub fn levels_gamma(t: f32, g: f32) -> f32 { + if t >= 0.0 { t.powf(1.0 / g) } else { -(-t).powf(1.0 / g) } +} + /// Levels' Channel popup. pub const LEVELS_CHANNELS: [&str; 5] = ["RGB", "Red", "Green", "Blue", "Alpha"]; @@ -242,8 +258,7 @@ fn level(v: f32, [ib, iw, g, ob, ow]: [f32; 5], (clip_b, clip_w): (bool, bool)) if clip_w { t = t.min(1.0); } - let t = t.max(0.0).powf(1.0 / g); - ob + (ow - ob) * t + ob + (ow - ob) * levels_gamma(t, g) } const LEVELS_IDENTITY: [f32; 5] = [0.0, 1.0, 1.0, 0.0, 1.0]; @@ -286,7 +301,7 @@ pub fn levels_channel_settings(ctx: &EffectCtx) -> [([f32; 5], (bool, bool)); 4] let ids = levels_channel_ids(i + 1); let v: [f32; 5] = std::array::from_fn(|k| ctx.params.get(&ids[k]).map(|v| v.as_f64() as f32).unwrap_or(LEVELS_IDENTITY[k])); let pre = LEVELS_CHANNEL_PREFIX[i]; - let on = |id: String| ctx.params.get(&id).is_none_or(|v| v.as_enum() != 0); + let on = |id: String| levels_clip_on(ctx, &id); let clip = if ctx.params.b("noClip") { (false, false) } else { (on(format!("{pre}ClipToOutputBlack")), on(format!("{pre}ClipToOutputWhite"))) }; ([v[0], v[1], v[2].max(0.01), v[3], v[4]], clip) }) diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 40ed79a7d..f157e74c2 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -406,6 +406,8 @@ pub struct EffectEnv<'a> { pub working_space: Option, /// Working-space pixels are linear light (Linearize Working Space). pub working_linear: bool, + /// The project works in 32 bpc float (Levels' "Off for 32 bpc Color" clips only below it). + pub float_depth: bool, /// The composition's shutter (angle and phase in degrees, samples per frame) when both the /// comp's and the layer's motion blur switches are on (Transform's Use Composition's /// Shutter Angle). diff --git a/crates/gpu/src/deferred.rs b/crates/gpu/src/deferred.rs index 3ac2c67e6..e96b54a1d 100644 --- a/crates/gpu/src/deferred.rs +++ b/crates/gpu/src/deferred.rs @@ -252,6 +252,7 @@ pub(crate) fn chain_key(chain: &[FxStep], buf: &Buf, levels: Option) -> u12 k.u64(e.effect_index as u64); k.debug(&e.working_space); k.u64(e.working_linear as u64); + k.u64(e.float_depth as u64); k.debug(&e.shutter); } k.finish() diff --git a/crates/gpu/src/shaders/fx_tone.wgsl b/crates/gpu/src/shaders/fx_tone.wgsl index 39db34588..c99d199c5 100644 --- a/crates/gpu/src/shaders/fx_tone.wgsl +++ b/crates/gpu/src/shaders/fx_tone.wgsl @@ -20,6 +20,14 @@ fn fxt_rem360(x: f32) -> f32 { } // Levels (Individual Controls) channel `i` (data: 5 × (ib, iw, g, ob, ow, identity)). +// color_fx::levels_gamma: unclipped values below 0 mirror the curve. +fn fxt_gamma(t: f32, g: f32) -> f32 { + if (t >= 0.0) { + return powz(t, 1.0 / g); + } + return -powz(-t, 1.0 / g); +} + fn fxt_lv(i: u32, v: f32, clip_b: bool, clip_w: bool) -> f32 { let o = i * 6u; if (data[o + 5u] != 0.0) { @@ -32,7 +40,7 @@ fn fxt_lv(i: u32, v: f32, clip_b: bool, clip_w: bool) -> f32 { if (clip_w) { t = min(t, 1.0); } - return data[o + 3u] + (data[o + 4u] - data[o + 3u]) * powz(max(t, 0.0), 1.0 / max(data[o + 2u], 0.01)); + return data[o + 3u] + (data[o + 4u] - data[o + 3u]) * fxt_gamma(t, max(data[o + 2u], 0.01)); } // color_fx::level (data offset o: ib, iw, g, ob, ow, clip black, clip white). @@ -44,8 +52,7 @@ fn fxt_level(o: u32, v: f32) -> f32 { if (data[o + 6u] != 0.0) { t = min(t, 1.0); } - t = powz(max(t, 0.0), 1.0 / data[o + 2u]); - return data[o + 3u] + (data[o + 4u] - data[o + 3u]) * t; + return data[o + 3u] + (data[o + 4u] - data[o + 3u]) * fxt_gamma(t, data[o + 2u]); } fn fxt_level_ident(o: u32) -> bool { @@ -177,7 +184,7 @@ fn fxt_point(@builtin(global_invocation_id) gid: vec3) { let aa = max(a, 0.0); var nc: vec3; for (var i = 0u; i < 3u; i++) { - nc[i] = fxt_lv(i + 1u, fxt_lv(0u, c[i], cb, cw), cb, cw); + nc[i] = fxt_lv(0u, fxt_lv(i + 1u, c[i], cb, cw), cb, cw); } let na = clamp(clamp(fxt_lv(4u, aa, cb, cw), 0.0, 1.0), 0.0, 1.0); o = vec4(nc * na, na); diff --git a/crates/gpu/src/shaders/kernels.wgsl b/crates/gpu/src/shaders/kernels.wgsl index 870430a5b..00d75fe25 100644 --- a/crates/gpu/src/shaders/kernels.wgsl +++ b/crates/gpu/src/shaders/kernels.wgsl @@ -703,7 +703,12 @@ fn pointwise(@builtin(global_invocation_id) gid: vec3) { if (P.u[0].z != 0u) { t = min(t, 1.0); } - t = powz(max(t, 0.0), 1.0 / g); + // color_fx::levels_gamma: unclipped values below 0 mirror the curve. + if (t >= 0.0) { + t = powz(t, 1.0 / g); + } else { + t = -powz(-t, 1.0 / g); + } o[i] = ob + (ow - ob) * t; } px = vec4(o * a, a); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index eccaed2f4..dff28f22d 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -1184,6 +1184,7 @@ impl<'a> Renderer<'a> { effect_index: 0, working_space: self.pipe.space, working_linear: self.pipe.linear, + float_depth: self.pipe.levels.is_none(), shutter: self.mb_on(ctx, layer).then_some((ctx.comp.shutter_angle, ctx.comp.shutter_phase, ctx.comp.motion_blur_samples)), }; // Video effects in stack order (index, group, spec); disabled and audio effects skipped. diff --git a/docs/effects-parity.md b/docs/effects-parity.md index f83258759..2d59c6b41 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -30,7 +30,7 @@ corpus compares renders, each effect is assumed to differ somewhere, and users k | Audio | 13 | 0 | CPU by design (applied in the mixdown) | | Blur & Sharpen | 16 | 15 | Gaussian Blur interaction and output to be measured first ([fidelity/ae-2026-acceptance.md](fidelity/ae-2026-acceptance.md)) | | Channel | 13 | 13 | — | -| Color Correction | 40 | 40 | Tint, Tritone and Black & White (sliders and Tint) match After Effects 2026 renders (Rec. 601 luminance); Tint has Swap Colors (#963); ACES 2.0 / OCIO 2.5 (26.5); Apply Color LUT (#514) and 32-bpc blending (#494) are closed | +| Color Correction | 40 | 40 | Tint, Tritone and Black & White (sliders and Tint) match After Effects 2026 renders (Rec. 601 luminance); Levels (Individual Controls) matches After Effects 2026 in 32 bpc (channel controls before RGB, "Off for 32 bpc Color" unclipped; plain Levels' channel order not measured); Tint has Swap Colors (#963); ACES 2.0 / OCIO 2.5 (26.5); Apply Color LUT (#514) and 32-bpc blending (#494) are closed | | Distort | 39 | 35 | Liquify has no viewer brush; #303 (Mesh Warp drag) and #534 (CC Power Pin Unstretch) are closed | | Expression Controls | 9 | 9 | — | | Generate | 26 | 25 | Fractal on the CPU (needs f64); #526 (Reset) and #536 (Write-on marks) are closed | @@ -66,6 +66,7 @@ the GPU is performance, not parity. |---|---|---| | 2026-10-19 | minor | Tritone uses Rec. 601 luminance, measured against After Effects 2026 renders of a red solid and a grey ramp (CPU and GPU) | | 2026-10-19 | minor | Black & White's Tint keeps the gray's luminosity (Color blend), measured against After Effects 2026 renders (CPU and GPU) | +| 2026-10-19 | minor | Levels: "Off for 32 bpc Color" no longer clips in 32 bpc projects, unclipped values extrapolate below Output Black; Levels (Individual Controls) applies the channel controls before the RGB ones, measured against After Effects 2026 (CPU and GPU) | | 2026-10-18 | minor | Tint uses Rec. 601 luminance weights, measured against After Effects 2026 renders of red, green, blue and grey solids (CPU and GPU) | | 2026-10-18 | minor | Exposure converts to linear light with a pure 2.2 power curve, as measured in After Effects (Offset 0.0005 / 0.002 / 0.02 / 0.1 on black: 8 / 15 / 43 / 90), not the piecewise sRGB curve | | 2026-10-18 | minor | Exposure converts to linear light with a pure 2.2 power curve, as measured in After Effects (Offset 0.0005 / 0.002 / 0.02 / 0.1 on black: 8 / 15 / 43 / 90), not the piecewise sRGB curve; its sliders take After Effects' ranges (Exposure −100..100, slider −4..4; Offset −2..2, slider −0.5..0.5; Gamma Correction 0.1..10) | From dc9f15ada114bc3be659650db08cac4e584f44cb Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 19 Oct 2026 14:41:24 +0200 Subject: [PATCH 13/46] Brightness & Contrast: negative settings invert the positive curves, as After Effects In After Effects 2026 the non-legacy curve of Brightness -40 is the inverse of Brightness +40 (within 2.6/255 on a full black-to-white ramp) and Contrast -50 the inverse of Contrast +50. EffectCraft fitted the negative brightness with its own power (2^(1.32 |b|) on v), 20/255 off AE at -40; the negatives now use the reciprocal exponents of the positive curves: Brightness -40 max 6.2/255 (was 19.8), Contrast -50 4.1 (was 7.0); positives unchanged (40: 5.5, 80: 7.8, Contrast 50: 2.7, 90: 5.0; B20 C30: 6.6, which confirms brightness before contrast). The #681 oracle samples still pass. CPU and GPU. The positive curves' remaining 5-8/255 needs a better curve family (a two-point spline fits each capture within 1.6/255), left open. Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/color_fx.rs | 40 +++++++++++++++++++++++++---- crates/gpu/src/shaders/kernels.wgsl | 16 ++++++++---- docs/effects-parity.md | 4 +-- 3 files changed, 48 insertions(+), 12 deletions(-) diff --git a/crates/effects/src/color_fx.rs b/crates/effects/src/color_fx.rs index 705cc3c7c..4dd800fa8 100644 --- a/crates/effects/src/color_fx.rs +++ b/crates/effects/src/color_fx.rs @@ -59,18 +59,22 @@ fn tritone(ctx: &EffectCtx, mut b: Buf) -> Buf { /// power toward white (`1-(1-v)^e`) or black (`v^e`); contrast uses `g = 1 + 0.53 c` instead of /// the old `2^(2c)` (that crushed 0.25 to ~8/255 at Contrast 100; AE is ~40/255). Values above 1 /// pass through. The GPU kernel mirrors it. +/// +/// Negative Brightness and Contrast are the inverse curves of the positive ones (After Effects +/// 2026 ramps: Brightness −40 is Brightness +40 inverted within 2.6/255, Contrast −50 likewise), +/// so they use the reciprocal exponents: Brightness −40 was 20/255 off AE, now 6.2. pub fn bc_modern(v: f32, b: f32, c: f32) -> f32 { if v >= 1.0 { return v; } let mut v = v.max(0.0); - if b > 0.0 { - v = 1.0 - (1.0 - v).powf(2f32.powf(1.24 * b)); - } else if b < 0.0 { - v = v.powf(2f32.powf(-1.32 * b)); + if b != 0.0 { + let e = 2f32.powf(1.24 * b.abs()); + v = 1.0 - (1.0 - v).powf(if b > 0.0 { e } else { 1.0 / e }); } if c != 0.0 { - let g = (1.0 + 0.53 * c).max(0.05); + let g = (1.0 + 0.53 * c.abs()).max(0.05); + let g = if c > 0.0 { g } else { 1.0 / g }; v = if v < 0.5 { 0.5 * (2.0 * v).powf(g) } else { 1.0 - 0.5 * (2.0 - 2.0 * v).max(0.0).powf(g) }; } v @@ -1340,6 +1344,32 @@ mod tests { } } + /// After Effects 2026 ramps (RENDERER round 2, session R8): negative Brightness and Contrast + /// are the inverses of the positive curves. (brightness, contrast, input, AE 8-bit) + #[test] + fn negative_brightness_and_contrast_invert_the_positive_curves() { + let go = |b: f64, c: f64, x: u8| { + let v = x as f32 / 255.0; + (px("ec.color.brightnesscontrast", &[("brightness", num(b)), ("contrast", num(c))], [v, v, v, 1.0])[0] * 255.0).round() as i32 + }; + let samples: &[(f64, f64, u8, u8)] = &[ + (-40.0, 0.0, 64, 50), + (-40.0, 0.0, 128, 99), + (-40.0, 0.0, 191, 155), + (-40.0, 0.0, 234, 207), + (0.0, -50.0, 21, 28), + (0.0, -50.0, 64, 76), + (0.0, -50.0, 191, 179), + (0.0, -50.0, 234, 227), + (40.0, 0.0, 128, 162), + (0.0, 50.0, 64, 52), + ]; + for &(b, c, x, ae) in samples { + let got = go(b, c, x); + assert!((got - ae as i32).abs() <= 7, "B{b} C{c} in {x}: got {got} AE {ae}"); + } + } + /// #1463: Black & White's sliders set the gray of each pure hue and blend between /// neighbouring hues; neutral pixels keep their value, overbright ones too, and an overbright /// colour stays finite (it used to divide by zero). diff --git a/crates/gpu/src/shaders/kernels.wgsl b/crates/gpu/src/shaders/kernels.wgsl index 00d75fe25..45cb9efe2 100644 --- a/crates/gpu/src/shaders/kernels.wgsl +++ b/crates/gpu/src/shaders/kernels.wgsl @@ -624,14 +624,20 @@ fn pointwise(@builtin(global_invocation_id) gid: vec3) { } else if (P.u[0].y == 2u) { // Brightness & Contrast tone curves (effects crate bc_modern, AE-fitted #681). var o = c; - let g = max(1.0 + 0.53 * ct, 0.05); + // Negative settings invert the positive curves (reciprocal exponents). + var g = max(1.0 + 0.53 * abs(ct), 0.05); + if (ct < 0.0) { + g = 1.0 / g; + } + var e = exp2(1.24 * abs(br)); + if (br < 0.0) { + e = 1.0 / e; + } for (var i = 0; i < 3; i++) { if (c[i] < 1.0) { var v = max(c[i], 0.0); - if (br > 0.0) { - v = 1.0 - powz(1.0 - v, exp2(1.24 * br)); - } else if (br < 0.0) { - v = powz(v, exp2(-1.32 * br)); + if (br != 0.0) { + v = 1.0 - powz(1.0 - v, e); } if (ct != 0.0) { if (v < 0.5) { diff --git a/docs/effects-parity.md b/docs/effects-parity.md index 2d59c6b41..d072c49c1 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -30,7 +30,7 @@ corpus compares renders, each effect is assumed to differ somewhere, and users k | Audio | 13 | 0 | CPU by design (applied in the mixdown) | | Blur & Sharpen | 16 | 15 | Gaussian Blur interaction and output to be measured first ([fidelity/ae-2026-acceptance.md](fidelity/ae-2026-acceptance.md)) | | Channel | 13 | 13 | — | -| Color Correction | 40 | 40 | Tint, Tritone and Black & White (sliders and Tint) match After Effects 2026 renders (Rec. 601 luminance); Levels (Individual Controls) matches After Effects 2026 in 32 bpc (channel controls before RGB, "Off for 32 bpc Color" unclipped; plain Levels' channel order not measured); Tint has Swap Colors (#963); ACES 2.0 / OCIO 2.5 (26.5); Apply Color LUT (#514) and 32-bpc blending (#494) are closed | +| Color Correction | 40 | 40 | Tint, Tritone and Black & White (sliders and Tint) match After Effects 2026 renders (Rec. 601 luminance); Levels (Individual Controls) matches After Effects 2026 in 32 bpc (channel controls before RGB, "Off for 32 bpc Color" unclipped; plain Levels' channel order not measured); Brightness & Contrast (non-legacy) within about 6/255 of After Effects 2026 ramps (negative settings invert the positive curves); Tint has Swap Colors (#963); ACES 2.0 / OCIO 2.5 (26.5); Apply Color LUT (#514) and 32-bpc blending (#494) are closed | | Distort | 39 | 35 | Liquify has no viewer brush; #303 (Mesh Warp drag) and #534 (CC Power Pin Unstretch) are closed | | Expression Controls | 9 | 9 | — | | Generate | 26 | 25 | Fractal on the CPU (needs f64); #526 (Reset) and #536 (Write-on marks) are closed | @@ -67,8 +67,8 @@ the GPU is performance, not parity. | 2026-10-19 | minor | Tritone uses Rec. 601 luminance, measured against After Effects 2026 renders of a red solid and a grey ramp (CPU and GPU) | | 2026-10-19 | minor | Black & White's Tint keeps the gray's luminosity (Color blend), measured against After Effects 2026 renders (CPU and GPU) | | 2026-10-19 | minor | Levels: "Off for 32 bpc Color" no longer clips in 32 bpc projects, unclipped values extrapolate below Output Black; Levels (Individual Controls) applies the channel controls before the RGB ones, measured against After Effects 2026 (CPU and GPU) | +| 2026-10-19 | minor | Brightness & Contrast: negative Brightness / Contrast are the inverse curves of the positive ones (Brightness −40: 20/255 → 6/255 off After Effects 2026), CPU and GPU | | 2026-10-18 | minor | Tint uses Rec. 601 luminance weights, measured against After Effects 2026 renders of red, green, blue and grey solids (CPU and GPU) | -| 2026-10-18 | minor | Exposure converts to linear light with a pure 2.2 power curve, as measured in After Effects (Offset 0.0005 / 0.002 / 0.02 / 0.1 on black: 8 / 15 / 43 / 90), not the piecewise sRGB curve | | 2026-10-18 | minor | Exposure converts to linear light with a pure 2.2 power curve, as measured in After Effects (Offset 0.0005 / 0.002 / 0.02 / 0.1 on black: 8 / 15 / 43 / 90), not the piecewise sRGB curve; its sliders take After Effects' ranges (Exposure −100..100, slider −4..4; Offset −2..2, slider −0.5..0.5; Gamma Correction 0.1..10) | | 2026-10-12 | minor | Parameters 3,014 to 3,017 (effects.md); #303, #514, #526, #533–#536 marked closed instead of pending | | 2026-10-12 | minor | Effect colour labels (26.5) added in Effect Controls | From 39bd0efac29b5e656c59ca969b5abcddb6538b5e Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 19 Oct 2026 02:26:04 +0200 Subject: [PATCH 14/46] Luma Key: Key Out Brighter and Darker cut hard at Threshold, as After Effects After Effects' Luma Key removes everything darker (or brighter) than Threshold and ignores Tolerance for those two key types; Tolerance only sets the band of Key Out Similar / Dissimilar. EffectCraft ramped the matte over Tolerance, so case 719 (a 50 % grey, Key Out Darker, Threshold 140, Tolerance 20) kept about 80 % of the layer where AE keys it out completely. Measured in AE 2026 on a vertical grey ramp (cases 812-814): Darker keys up to level 139.9 and keeps 140.1 (EffectCraft now the same row); Similar keys levels 120.3-160.0; with Edge Thin 1 / Edge Feather 2.5 the matte rises over rows 591-596 (EffectCraft within 0.2 of AE's alpha there). CPU and GPU. Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/keying.rs | 93 +++++++++++++++++++----------- crates/gpu/src/shaders/fx_key.wgsl | 18 ++---- docs/effects-parity.md | 3 +- 3 files changed, 65 insertions(+), 49 deletions(-) diff --git a/crates/effects/src/keying.rs b/crates/effects/src/keying.rs index 9d61ff8c8..c4f216aa2 100644 --- a/crates/effects/src/keying.rs +++ b/crates/effects/src/keying.rs @@ -135,46 +135,23 @@ fn color_key(ctx: &EffectCtx, mut b: Buf) -> Buf { // ---- Luma Key ---- +/// Luma Key as After Effects 2026 keys a grey ramp (cases 812-814, 719): Key Out Brighter and +/// Darker cut hard at Threshold and ignore Tolerance; Key Out Similar removes, and Dissimilar +/// keeps, a hard band of Threshold ± Tolerance (levels of 255). Edge Thin and Edge Feather +/// then soften the cut. fn luma_key(ctx: &EffectCtx, mut b: Buf) -> Buf { let kind = ctx.params.e("keyType"); let thr = ctx.params.f("threshold") as f32 / 255.0; let tol = ctx.params.f("tolerance") as f32 / 255.0; let m = matte_of(&b.img, |c, _| { let l = luminance(c[0], c[1], c[2]); - match kind { - 0 => { - if tol > 0.0 { - ((thr + tol - l) / tol).clamp(0.0, 1.0) - } else if l > thr { - 0.0 - } else { - 1.0 - } - } - 2 => { - if (l - thr).abs() <= tol { - 0.0 - } else { - 1.0 - } - } - 3 => { - if (l - thr).abs() <= tol { - 1.0 - } else { - 0.0 - } - } - _ => { - if tol > 0.0 { - ((l - (thr - tol)) / tol).clamp(0.0, 1.0) - } else if l < thr { - 0.0 - } else { - 1.0 - } - } - } + let keep = match kind { + 0 => l <= thr, + 2 => (l - thr).abs() > tol, + 3 => (l - thr).abs() <= tol, + _ => l >= thr, + }; + if keep { 1.0 } else { 0.0 } }); let m = thin_feather(m, ctx.params.f("edgeThin") * b.scale, ctx.params.f("edgeFeather") * b.scale); apply_matte(&mut b.img, &m, 0); @@ -802,6 +779,54 @@ mod tests { assert_eq!(out.get(1, 0)[3], 1.0); } + /// After Effects 2026 (cases 812-814): Luma Key on a vertical black-to-white ramp (row y + /// has level (y + 0.5) / 1080 × 255), Threshold 140, Tolerance 20. EffectCraft ramped the + /// matte over the tolerance (case 719: a 50 % grey kept 80 % of its alpha where AE keys it + /// out). + #[test] + fn luma_key_matches_after_effects() { + let ramp = || { + let mut img = Image::new(1, 1080); + for y in 0..1080 { + let v = (y as f32 + 0.5) / 1080.0; + img.set(0, y, [v, v, v, 1.0]); + } + img + }; + let key = |kind: u32, thin: f64, feather: f64| { + let set = + [("keyType", Value::Enum(kind)), ("threshold", num(140.0)), ("tolerance", num(20.0)), ("edgeThin", num(thin)), ("edgeFeather", num(feather))]; + let o = run("ec.key.luma", &set, ramp()); + (0..1080).map(|y| o.get(0, y)[3]).collect::>() + }; + // Key Out Darker (AE 2 = EffectCraft 1): keyed up to row 592, kept from 593. + let d = key(1, 0.0, 0.0); + assert!(d[..593].iter().all(|a| *a == 0.0) && d[593..].iter().all(|a| *a == 1.0), "{:?}", &d[588..598]); + // Key Out Similar: AE keys rows 509..=677 (levels 120.3..160.0) and keeps the rest; the + // rows at the band's ends sit within 0.1 level of 120 / 160, so they may go either way. + let s = key(2, 0.0, 0.0); + assert!( + (0..1080).filter(|y| !(507..=510).contains(y) && !(676..=679).contains(y)).all(|y| (s[y] == 0.0) == (509..=677).contains(&y)), + "{:?} {:?}", + &s[505..512], + &s[674..681] + ); + // Key Out Brighter and Dissimilar are the complements. + let b = key(0, 0.0, 0.0); + assert!(b[595] == 0.0 && b[590] == 1.0); + let ds = key(3, 0.0, 0.0); + assert!(ds[600] == 1.0 && ds[400] == 0.0 && ds[700] == 0.0); + // A 50 % grey at Darker 140 / 20 is keyed out entirely (case 719). + let o = + run("ec.key.luma", &[("keyType", Value::Enum(1)), ("threshold", num(140.0)), ("tolerance", num(20.0))], Image::filled(2, 2, [0.5, 0.5, 0.5, 1.0])); + assert_eq!(o.get(0, 0)[3], 0.0); + // Edge Thin 1, Edge Feather 2.5 (case 814): AE's alpha rises over rows 591..596. + let f = key(1, 1.0, 2.5); + for (y, ae) in [(590, 0.0), (591, 0.026), (592, 0.139), (593, 0.357), (594, 0.635), (595, 0.868), (596, 0.97), (597, 1.0)] { + assert!((f[y] - ae).abs() < 0.2, "row {y}: {} vs AE {ae}", f[y]); + } + } + #[test] fn extract_keeps_range() { let mut img = Image::new(3, 1); diff --git a/crates/gpu/src/shaders/fx_key.wgsl b/crates/gpu/src/shaders/fx_key.wgsl index a28ecc65c..b38654729 100644 --- a/crates/gpu/src/shaders/fx_key.wgsl +++ b/crates/gpu/src/shaders/fx_key.wgsl @@ -199,23 +199,13 @@ fn fxk_point(@builtin(global_invocation_id) gid: vec3) { let thr = f0.x; let tol = f0.y; var m: f32; + // As effects::keying::luma_key: Brighter / Darker cut hard at the threshold + // (Tolerance only shapes Similar / Dissimilar), as After Effects. switch u0.y { - case 0u: { - if (tol > 0.0) { - m = clamp((thr + tol - l) / tol, 0.0, 1.0); - } else { - m = select(1.0, 0.0, l > thr); - } - } + case 0u: { m = select(1.0, 0.0, l > thr); } case 2u: { m = select(1.0, 0.0, abs(l - thr) <= tol); } case 3u: { m = select(0.0, 1.0, abs(l - thr) <= tol); } - default: { - if (tol > 0.0) { - m = clamp((l - (thr - tol)) / tol, 0.0, 1.0); - } else { - m = select(1.0, 0.0, l < thr); - } - } + default: { m = select(1.0, 0.0, l < thr); } } o = vec4(m); } diff --git a/docs/effects-parity.md b/docs/effects-parity.md index d072c49c1..1fcbc4a63 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -38,7 +38,7 @@ corpus compares renders, each effect is assumed to differ somewhere, and users k | Keying | 12 | 12 | Key Cleaner approximated; #533 (wire-removal Frame Offset) is closed | | Matte | 6 | 4 | Mocha shape reads our documented JSON, not Mocha's format | | Noise & Grain | 13 | 12 | Curl Noise (#463); Match Grain on the CPU | -| Obsolete | 9 | 9 | — | +| Obsolete | 9 | 9 | Luma Key matches After Effects 2026 on a grey ramp (hard cut, Tolerance only for Similar / Dissimilar) | | Paint | 1 | 0 | Tablet pressure not read ([hardware-parity.md](hardware-parity.md)) | | Perspective | 10 | 9 | Chromatic Aberrations pan/tilt crash (#580) | | Simulation | 18 | 18 | Particle Playground lacks Particle Exploder, text particles, ephemeral mapper; CC Particle World camera fixed (#397) | @@ -68,6 +68,7 @@ the GPU is performance, not parity. | 2026-10-19 | minor | Black & White's Tint keeps the gray's luminosity (Color blend), measured against After Effects 2026 renders (CPU and GPU) | | 2026-10-19 | minor | Levels: "Off for 32 bpc Color" no longer clips in 32 bpc projects, unclipped values extrapolate below Output Black; Levels (Individual Controls) applies the channel controls before the RGB ones, measured against After Effects 2026 (CPU and GPU) | | 2026-10-19 | minor | Brightness & Contrast: negative Brightness / Contrast are the inverse curves of the positive ones (Brightness −40: 20/255 → 6/255 off After Effects 2026), CPU and GPU | +| 2026-10-19 | minor | Luma Key: Key Out Brighter / Darker cut hard at Threshold, measured against After Effects 2026 renders of a ramp (CPU and GPU) | | 2026-10-18 | minor | Tint uses Rec. 601 luminance weights, measured against After Effects 2026 renders of red, green, blue and grey solids (CPU and GPU) | | 2026-10-18 | minor | Exposure converts to linear light with a pure 2.2 power curve, as measured in After Effects (Offset 0.0005 / 0.002 / 0.02 / 0.1 on black: 8 / 15 / 43 / 90), not the piecewise sRGB curve; its sliders take After Effects' ranges (Exposure −100..100, slider −4..4; Offset −2..2, slider −0.5..0.5; Gamma Correction 0.1..10) | | 2026-10-12 | minor | Parameters 3,014 to 3,017 (effects.md); #303, #514, #526, #533–#536 marked closed instead of pending | From 75271bfe6c9fb21b44de1cf0c4c66974639be8bb Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 19 Oct 2026 12:20:28 +0200 Subject: [PATCH 15/46] Luma Key and Extract: Rec. 601 luminance, as After Effects After Effects 2026 keys Luma Key and Extract (Channel Luminance) on Rec. 601 weights of the working-space values: Key Out Darker / Extract Black Point at 65, 166 and 24 on pure red, green and blue keep red (601: 76, 709: 54) and blue (29 / 18) and key out green (150 / 182) - Rec. 709, which EffectCraft used, decides all three the other way. Case 904 E18 (Extract 30 / 220 / softness 10 on grey and colour steps): 513 of 5184 grid points off (pure blue kept at 231 in AE, keyed out in EC), now 0, max 0. CPU and GPU. Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/color_fx.rs | 15 ++++++++------- crates/effects/src/keying.rs | 21 ++++++++++++++++++--- crates/gpu/src/shaders/fx_key.wgsl | 9 +++++++-- docs/effects-parity.md | 3 ++- 4 files changed, 35 insertions(+), 13 deletions(-) diff --git a/crates/effects/src/color_fx.rs b/crates/effects/src/color_fx.rs index 4dd800fa8..ddbb61b29 100644 --- a/crates/effects/src/color_fx.rs +++ b/crates/effects/src/color_fx.rs @@ -14,13 +14,14 @@ fn mix(a: [f32; 3], b: [f32; 3], t: f32) -> [f32; 3] { [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t] } -/// Rec. 601 luminance on the working-space values, as After Effects 2026 renders Tint, Tritone -/// and the other Rec. 601 effects (sRGB working space, not linearised: red 76/255, green -/// 150/255, blue 29/255). Tint and Tritone use it (Tritone, case 718: a red solid with -/// Blend With Original 25 gives (132, 23, 46), from luminance 0.299; Rec. 709's 0.213 gave -/// (113, 16, 49); on a grey ramp both weightings agree, case 815). The GPU kernels -/// (`pointwise` op 1, `fx_color` op 4) mirror it. -fn luma_601(c: [f32; 3]) -> f32 { +/// Rec. 601 luminance on the working-space values (sRGB working space, not linearised: red +/// 76/255, green 150/255, blue 29/255), as After Effects 2026 renders Tint, Tritone, Luma Key and +/// Extract. Tritone, case 718: a red solid with Blend With Original 25 gives (132, 23, 46), from +/// luminance 0.299; Rec. 709's 0.213 gave (113, 16, 49); on a grey ramp both weightings agree +/// (case 815). Luma Key and Extract: Key Out Darker / Extract Black Point at 65, 166 and 24 on +/// pure red, green and blue keep red (601: 76, 709: 54) and blue (29 / 18) and key out green +/// (150 / 182). The GPU kernels (`pointwise` op 1, `fx_color` op 4, `fxk_key_luma`) mirror it. +pub(crate) fn luma_601(c: [f32; 3]) -> f32 { 0.299 * c[0] + 0.587 * c[1] + 0.114 * c[2] } diff --git a/crates/effects/src/keying.rs b/crates/effects/src/keying.rs index c4f216aa2..4953d482c 100644 --- a/crates/effects/src/keying.rs +++ b/crates/effects/src/keying.rs @@ -2,12 +2,13 @@ //! matte clean-up. Written from public behaviour descriptions and the standard colour-difference / //! blue-screen matting literature (Vlahos; Smith & Blinn, "Blue Screen Matting"). -use effectcraft_color::{luminance, rgb_to_hsl, srgb_to_linear}; +use effectcraft_color::{rgb_to_hsl, srgb_to_linear}; use effectcraft_keyframe::Value; use effectcraft_project::ParamUi; use effectcraft_raster::{Image, gaussian_blur}; use rayon::prelude::*; +use crate::color_fx::luma_601; use crate::util::{Plane, gauss_plane, morph_frac, premul, unpremul}; use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider}; @@ -144,7 +145,7 @@ fn luma_key(ctx: &EffectCtx, mut b: Buf) -> Buf { let thr = ctx.params.f("threshold") as f32 / 255.0; let tol = ctx.params.f("tolerance") as f32 / 255.0; let m = matte_of(&b.img, |c, _| { - let l = luminance(c[0], c[1], c[2]); + let l = luma_601([c[0], c[1], c[2]]); let keep = match kind { 0 => l <= thr, 2 => (l - thr).abs() > tol, @@ -311,7 +312,7 @@ fn extract(ctx: &EffectCtx, mut b: Buf) -> Buf { 2 => c[1], 3 => c[2], 4 => a, - _ => luminance(c[0], c[1], c[2]), + _ => luma_601([c[0], c[1], c[2]]), }; let mut k = 1.0f32; if v < bp { @@ -827,6 +828,20 @@ mod tests { } } + /// After Effects 2026: Key Out Darker and Extract's Black Point at 65 / 166 / 24 on pure + /// red / green / blue keep red and blue and key out green: Rec. 601 luminance (Rec. 709 + /// would key out red and blue and keep green). + #[test] + fn luma_key_and_extract_use_rec601_as_after_effects() { + for (c, thr, kept) in [([1.0, 0.0, 0.0], 65.0, true), ([0.0, 1.0, 0.0], 166.0, false), ([0.0, 0.0, 1.0], 24.0, true)] { + let img = Image::filled(2, 1, [c[0], c[1], c[2], 1.0]); + let key = run("ec.key.luma", &[("keyType", Value::Enum(1)), ("threshold", num(thr))], img.clone()); + let ext = run("ec.key.extract", &[("blackPoint", num(thr))], img); + let want = if kept { 1.0 } else { 0.0 }; + assert_eq!((key.get(0, 0)[3], ext.get(0, 0)[3]), (want, want), "{c:?} at {thr}"); + } + } + #[test] fn extract_keeps_range() { let mut img = Image::new(3, 1); diff --git a/crates/gpu/src/shaders/fx_key.wgsl b/crates/gpu/src/shaders/fx_key.wgsl index b38654729..37146c329 100644 --- a/crates/gpu/src/shaders/fx_key.wgsl +++ b/crates/gpu/src/shaders/fx_key.wgsl @@ -62,6 +62,11 @@ fn fxk_range_components(space: u32, c: vec3) -> vec3 { } // keying::matte_levels. +// Luma Key / Extract luminance: Rec. 601 (effects::color_fx::luma_601). +fn fxk_key_luma(c: vec3) -> f32 { + return dot(c, vec3(0.299, 0.587, 0.114)); +} + fn fxk_matte_levels(v: f32, in_b: f32, in_w: f32, gamma: f32, out_b: f32, out_w: f32) -> f32 { var t = clamp((v - in_b / 255.0) / max((in_w - in_b) / 255.0, 1e-4), 0.0, 1.0); t = powz(t, 1.0 / max(gamma, 0.01)); @@ -195,7 +200,7 @@ fn fxk_point(@builtin(global_invocation_id) gid: vec3) { o = vec4(select(1.0, 0.0, d <= f0.w)); } case 2u: { - let l = luminance(c); + let l = fxk_key_luma(c); let thr = f0.x; let tol = f0.y; var m: f32; @@ -231,7 +236,7 @@ fn fxk_point(@builtin(global_invocation_id) gid: vec3) { case 2u: { v = c.y; } case 3u: { v = c.z; } case 4u: { v = a; } - default: { v = luminance(c); } + default: { v = fxk_key_luma(c); } } v = 255.0 * v; let bp = f0.x; diff --git a/docs/effects-parity.md b/docs/effects-parity.md index 1fcbc4a63..92f4d419d 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -35,7 +35,7 @@ corpus compares renders, each effect is assumed to differ somewhere, and users k | Expression Controls | 9 | 9 | — | | Generate | 26 | 25 | Fractal on the CPU (needs f64); #526 (Reset) and #536 (Write-on marks) are closed | | Immersive Video | 12 | 12 | — | -| Keying | 12 | 12 | Key Cleaner approximated; #533 (wire-removal Frame Offset) is closed | +| Keying | 12 | 12 | Luma Key and Extract key on Rec. 601 luminance, as After Effects 2026; Key Cleaner approximated; #533 (wire-removal Frame Offset) is closed | | Matte | 6 | 4 | Mocha shape reads our documented JSON, not Mocha's format | | Noise & Grain | 13 | 12 | Curl Noise (#463); Match Grain on the CPU | | Obsolete | 9 | 9 | Luma Key matches After Effects 2026 on a grey ramp (hard cut, Tolerance only for Similar / Dissimilar) | @@ -69,6 +69,7 @@ the GPU is performance, not parity. | 2026-10-19 | minor | Levels: "Off for 32 bpc Color" no longer clips in 32 bpc projects, unclipped values extrapolate below Output Black; Levels (Individual Controls) applies the channel controls before the RGB ones, measured against After Effects 2026 (CPU and GPU) | | 2026-10-19 | minor | Brightness & Contrast: negative Brightness / Contrast are the inverse curves of the positive ones (Brightness −40: 20/255 → 6/255 off After Effects 2026), CPU and GPU | | 2026-10-19 | minor | Luma Key: Key Out Brighter / Darker cut hard at Threshold, measured against After Effects 2026 renders of a ramp (CPU and GPU) | +| 2026-10-19 | minor | Luma Key and Extract: Rec. 601 luminance (was Rec. 709), measured against After Effects 2026 (CPU and GPU) | | 2026-10-18 | minor | Tint uses Rec. 601 luminance weights, measured against After Effects 2026 renders of red, green, blue and grey solids (CPU and GPU) | | 2026-10-18 | minor | Exposure converts to linear light with a pure 2.2 power curve, as measured in After Effects (Offset 0.0005 / 0.002 / 0.02 / 0.1 on black: 8 / 15 / 43 / 90), not the piecewise sRGB curve; its sliders take After Effects' ranges (Exposure −100..100, slider −4..4; Offset −2..2, slider −0.5..0.5; Gamma Correction 0.1..10) | | 2026-10-12 | minor | Parameters 3,014 to 3,017 (effects.md); #303, #514, #526, #533–#536 marked closed instead of pending | From d7854b84a31ff3be6cd3f57291a68ac44f289695 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 19 Oct 2026 12:37:28 +0200 Subject: [PATCH 16/46] =?UTF-8?q?4-Color=20Gradient:=20weights=201=20/=20(?= =?UTF-8?q?d=C2=B2=20+=20Blend=C2=B2),=20as=20After=20Effects?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After Effects weighs each of the four points by its inverse squared distance softened by Blend squared, 1 / (d^2 + Blend^2) in pixels: fitted to AE 2026 renders of the default gradient at Blend 10, 100 and 300 and case 904 E16 at Blend 50 (power 2.00 at every Blend, softening 0 / 2367 / 9817 / 90127 = about Blend^2; mean error 0.27/255, max 0.9). EffectCraft used 1 / (d^2 / (100 Blend))^1.5, in which Blend cancels out: E16 was 34/255 off on 99 % of the grid, now 0 grid points off, max 1/255, CPU and GPU. Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/generate.rs | 66 ++++++++++++++++++++++++- crates/effects/src/lib.rs | 2 +- crates/gpu/src/fx_generate.rs | 4 +- crates/gpu/src/shaders/fx_generate.wgsl | 2 +- docs/effects-parity.md | 3 +- 5 files changed, 70 insertions(+), 7 deletions(-) diff --git a/crates/effects/src/generate.rs b/crates/effects/src/generate.rs index fd5a54123..637cc428e 100644 --- a/crates/effects/src/generate.rs +++ b/crates/effects/src/generate.rs @@ -112,10 +112,19 @@ pub(crate) fn gen_blend(o: Px, g: Px, mode: u32) -> Px { } } +/// 4-Color Gradient's weights are inverse squared distances softened by Blend²: each point +/// weighs 1 / (d² + Blend²) (pixels). Fitted to After Effects 2026 renders at Blend 10, 50, 100 +/// and 300 (power 2.00 at every Blend, softening 0 / 2367 / 9817 / 90127 ≈ Blend²; mean error +/// 0.27/255, max 0.9). Returns Blend² in buffer pixels, never 0. +pub fn four_color_softening(blend: f64, scale: f64) -> f64 { + let b = blend.max(0.0) * scale; + (b * b).max(1e-6) +} + fn four_color(ctx: &EffectCtx, mut b: Buf) -> Buf { let pts: Vec<((f64, f64), [f32; 4])> = (1..=4).map(|i| (b.to_px(ctx.params.v2(&format!("positionsColors/point{i}"))), ctx.params.color(&format!("positionsColors/color{i}")))).collect(); - let blend = ctx.params.f("blend").max(1.0); + let k = four_color_softening(ctx.params.f("blend"), b.scale); let jitter = (ctx.params.f("jitter") / 100.0).clamp(0.0, 1.0) as f32; let op = ctx.params.f("opacity") as f32 / 100.0; let mode = ctx.params.e("blendingMode"); @@ -126,7 +135,7 @@ fn four_color(ctx: &EffectCtx, mut b: Buf) -> Buf { let mut wsum = 0.0; for ((qx, qy), c) in &pts { let d2 = (x as f64 + 0.5 - qx).powi(2) + (y as f64 + 0.5 - qy).powi(2); - let w = 1.0 / (d2 / (blend * 100.0) + 1e-6).powf(1.5); + let w = 1.0 / (d2 + k); for i in 0..3 { acc[i] += c[i] as f64 * w; } @@ -423,6 +432,59 @@ mod generator_tests { } } + /// After Effects 2026, 4-Color Gradient defaults (yellow, green, magenta, blue at 10 % / 90 % + /// of a 1920 × 1080 layer) at Blend 100 and 300, here on a tenth-size layer with Blend 10 + /// and 30 (the weights scale with the layer): colours at AE pixels (10x + 5, 10y + 5). + #[test] + fn four_color_blend_matches_after_effects() { + let pts = [ + ("positionsColors/point1", [19.2, 10.8]), + ("positionsColors/point2", [172.8, 10.8]), + ("positionsColors/point3", [19.2, 97.2]), + ("positionsColors/point4", [172.8, 97.2]), + ]; + let cols = [ + ("positionsColors/color1", [1.0, 1.0, 0.0, 1.0]), + ("positionsColors/color2", [0.0, 1.0, 0.0, 1.0]), + ("positionsColors/color3", [1.0, 0.0, 1.0, 1.0]), + ("positionsColors/color4", [0.0, 0.0, 1.0, 1.0]), + ]; + let ae: [(f64, [(i64, i64, [f32; 3]); 5]); 2] = [ + ( + 10.0, + [ + (30, 20, [249.0, 240.0, 15.0]), + (150, 30, [20.0, 208.0, 47.0]), + (60, 90, [213.0, 56.0, 199.0]), + (100, 10, [115.0, 177.0, 78.0]), + (10, 50, [238.0, 145.0, 110.0]), + ], + ), + ( + 30.0, + [ + (30, 20, [236.0, 214.0, 41.0]), + (150, 30, [30.0, 189.0, 66.0]), + (60, 90, [203.0, 67.0, 188.0]), + (100, 10, [117.0, 173.0, 82.0]), + (10, 50, [233.0, 140.0, 115.0]), + ], + ), + ]; + for (blend, samples) in ae { + let mut vals: Vec<(&str, Value)> = pts.iter().map(|(k, v)| (*k, Value::Vec2(*v))).collect(); + vals.extend(cols.iter().map(|(k, v)| (*k, Value::Color(*v)))); + vals.push(("blend", num(blend))); + let o = run_fx("ec.generate.fourcolor", &vals, Image::filled(192, 108, [1.0, 0.0, 0.0, 1.0]), 0.0, EffectEnv::default()); + for (x, y, want) in samples { + let p = o.img.get(x, y); + for c in 0..3 { + assert!((p[c] * 255.0 - want[c]).abs() <= 3.0, "blend {blend} at ({x}, {y}) channel {c}: {} vs AE {}", p[c] * 255.0, want[c]); + } + } + } + } + #[test] fn four_color_none_stays_inside_the_layer_alpha() { // #1381: like the Gradient Ramp, the gradient fills a shape or text, not its bounding box. diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index f157e74c2..13a4130d0 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -91,7 +91,7 @@ pub use sim3::{PgBlit, PgPlan, playground_plan, playground_state}; pub use stylize2::strobe_on; // CPU helpers the GPU kernels share (effectcraft-gpu). pub use blur2::{camera_lens_plain, camera_lens_spans}; -pub use generate::gen_mode; +pub use generate::{four_color_softening, gen_mode}; pub use generate2::pattern_kind as cell_pattern_kind; pub use generate2::{BoltSeg, Contour, Ghost, RadioPlan, Wave, flare_ghosts, lightning_segments, radio_plan}; pub use generate3::{ diff --git a/crates/gpu/src/fx_generate.rs b/crates/gpu/src/fx_generate.rs index 3744ec4fa..0216d3907 100644 --- a/crates/gpu/src/fx_generate.rs +++ b/crates/gpu/src/fx_generate.rs @@ -353,10 +353,10 @@ fn four_color(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { p.f[i] = [q.0 as f32, q.1 as f32, 0.0, 0.0]; p.f[4 + i] = ctx.params.color(&format!("positionsColors/color{}", i + 1)); } - let blend = ctx.params.f("blend").max(1.0); + let k = effectcraft_effects::four_color_softening(ctx.params.f("blend"), b.scale); let jitter = (ctx.params.f("jitter") / 100.0).clamp(0.0, 1.0) as f32; p.u[0] = [gen_mode(ctx.params.e("blendingMode")), ctx.seed, 0, 0]; - p.f[8] = [(blend * 100.0) as f32, jitter, ctx.params.f("opacity") as f32 / 100.0, 0.0]; + p.f[8] = [k as f32, jitter, ctx.params.f("opacity") as f32 / 100.0, 0.0]; run(e, "gen_fourcolor", &p, b, None, None) } diff --git a/crates/gpu/src/shaders/fx_generate.wgsl b/crates/gpu/src/shaders/fx_generate.wgsl index f46b2f0ba..bca00ce1b 100644 --- a/crates/gpu/src/shaders/fx_generate.wgsl +++ b/crates/gpu/src/shaders/fx_generate.wgsl @@ -500,7 +500,7 @@ fn gen_fourcolor(@builtin(global_invocation_id) gid: vec3) { let dx = f32(p.x) + 0.5 - q.x; let dy = f32(p.y) + 0.5 - q.y; let d2 = dx * dx + dy * dy; - let w = 1.0 / pow(d2 / bl + 1e-6, 1.5); + let w = 1.0 / (d2 + bl); acc += P.f[4 + i].xyz * w; wsum += w; } diff --git a/docs/effects-parity.md b/docs/effects-parity.md index 92f4d419d..97c43bc43 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -33,7 +33,7 @@ corpus compares renders, each effect is assumed to differ somewhere, and users k | Color Correction | 40 | 40 | Tint, Tritone and Black & White (sliders and Tint) match After Effects 2026 renders (Rec. 601 luminance); Levels (Individual Controls) matches After Effects 2026 in 32 bpc (channel controls before RGB, "Off for 32 bpc Color" unclipped; plain Levels' channel order not measured); Brightness & Contrast (non-legacy) within about 6/255 of After Effects 2026 ramps (negative settings invert the positive curves); Tint has Swap Colors (#963); ACES 2.0 / OCIO 2.5 (26.5); Apply Color LUT (#514) and 32-bpc blending (#494) are closed | | Distort | 39 | 35 | Liquify has no viewer brush; #303 (Mesh Warp drag) and #534 (CC Power Pin Unstretch) are closed | | Expression Controls | 9 | 9 | — | -| Generate | 26 | 25 | Fractal on the CPU (needs f64); #526 (Reset) and #536 (Write-on marks) are closed | +| Generate | 26 | 25 | 4-Color Gradient's Blend matches After Effects 2026 (weights 1 / (d² + Blend²)); Fractal on the CPU (needs f64); #526 (Reset) and #536 (Write-on marks) are closed | | Immersive Video | 12 | 12 | — | | Keying | 12 | 12 | Luma Key and Extract key on Rec. 601 luminance, as After Effects 2026; Key Cleaner approximated; #533 (wire-removal Frame Offset) is closed | | Matte | 6 | 4 | Mocha shape reads our documented JSON, not Mocha's format | @@ -70,6 +70,7 @@ the GPU is performance, not parity. | 2026-10-19 | minor | Brightness & Contrast: negative Brightness / Contrast are the inverse curves of the positive ones (Brightness −40: 20/255 → 6/255 off After Effects 2026), CPU and GPU | | 2026-10-19 | minor | Luma Key: Key Out Brighter / Darker cut hard at Threshold, measured against After Effects 2026 renders of a ramp (CPU and GPU) | | 2026-10-19 | minor | Luma Key and Extract: Rec. 601 luminance (was Rec. 709), measured against After Effects 2026 (CPU and GPU) | +| 2026-10-19 | minor | 4-Color Gradient: each point weighs 1 / (d² + Blend²), measured against After Effects 2026 at Blend 10 / 50 / 100 / 300 (CPU and GPU) | | 2026-10-18 | minor | Tint uses Rec. 601 luminance weights, measured against After Effects 2026 renders of red, green, blue and grey solids (CPU and GPU) | | 2026-10-18 | minor | Exposure converts to linear light with a pure 2.2 power curve, as measured in After Effects (Offset 0.0005 / 0.002 / 0.02 / 0.1 on black: 8 / 15 / 43 / 90), not the piecewise sRGB curve; its sliders take After Effects' ranges (Exposure −100..100, slider −4..4; Offset −2..2, slider −0.5..0.5; Gamma Correction 0.1..10) | | 2026-10-12 | minor | Parameters 3,014 to 3,017 (effects.md); #303, #514, #526, #533–#536 marked closed instead of pending | From 84ffc8453eedbc509d226e483e63221efb9f7324 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 19 Oct 2026 10:42:33 +0200 Subject: [PATCH 17/46] Fill: Opacity is the filled layer's opacity, as After Effects Without a Fill Mask, After Effects turns the layer into the fill colour and applies Opacity to its alpha; nothing of the original shows through. AE 2026 renders Fill (0, 0.5, 1) at Opacity 50 % on a Gradient Ramp over a red solid as premultiplied (0, 0.251, 0.502) at alpha 0.502 at every pixel; EffectCraft mixed the fill with the ramp (mean difference 95.5/255), now within 1/255, CPU and GPU. Invert fills the transparent part the same way. With a Fill Mask the mix with the original is unchanged (not measured). Lottie's fill effect (colour, alpha x opacity) now plays the same in EffectCraft. Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/color_fx.rs | 37 ++++++++++++++++++++++------- crates/gpu/src/shaders/kernels.wgsl | 10 +++----- docs/effects-parity.md | 3 ++- 3 files changed, 34 insertions(+), 16 deletions(-) diff --git a/crates/effects/src/color_fx.rs b/crates/effects/src/color_fx.rs index ddbb61b29..8e451f4eb 100644 --- a/crates/effects/src/color_fx.rs +++ b/crates/effects/src/color_fx.rs @@ -480,15 +480,12 @@ fn fill(ctx: &EffectCtx, mut b: Buf) -> Buf { }); return b; } + // Without masks the layer becomes the fill colour, and Opacity is the layer's opacity: After + // Effects 2026 renders Fill (0, 0.5, 1) at Opacity 50 % as that colour at half alpha, with + // nothing of the original showing through (it is not a mix with the original). b.img.data.iter_mut().for_each(|px| { - let a = if invert { 1.0 - px[3] } else { px[3] }; - let f = [c[0] * a, c[1] * a, c[2] * a]; - for i in 0..3 { - px[i] += (f[i] - px[i]) * opacity; - } - if invert { - px[3] += (a - px[3]) * opacity; - } + let a = if invert { 1.0 - px[3] } else { px[3] } * opacity; + *px = [c[0] * a, c[1] * a, c[2] * a, a]; }); b } @@ -1666,6 +1663,30 @@ mod tests { assert_eq!(plain.get(10, 5), [1.0, 0.0, 0.0, 1.0]); } + /// After Effects 2026: Fill (0, 0.5, 1) at Opacity 50 % on an opaque layer (a Gradient Ramp + /// on a red solid) renders premultiplied (0, 0.251, 0.502) at alpha 0.502 everywhere: the + /// fill colour at half the layer's alpha, nothing of the ramp left. + #[test] + fn fill_opacity_is_the_layers_opacity_as_in_after_effects() { + let mut img = Image::new(16, 4); + for x in 0..16 { + let v = x as f32 / 15.0; + for y in 0..4 { + img.set(x, y, [v, 0.0, 0.0, 1.0]); + } + } + let vals = [("color", Value::Color([0.0, 0.5, 1.0, 1.0])), ("opacity", num(50.0))]; + let out = run_fx("ec.generate.fill", &vals, img, 0.0, EffectEnv::default()).img; + for x in [0, 7, 15] { + let p = out.get(x, 2); + let ae = [0.0, 0.251, 0.502, 0.502]; + assert!(p.iter().zip(ae).all(|(v, a)| (v - a).abs() < 1.0 / 255.0), "x {x}: {p:?} vs AE {ae:?}"); + } + // A half-transparent pixel ends at a quarter alpha, still the fill colour. + let half = run_fx("ec.generate.fill", &vals, Image::filled(2, 2, [0.2, 0.2, 0.2, 0.5]), 0.0, EffectEnv::default()).img; + assert_eq!(half.get(0, 0), [0.0, 0.125, 0.25, 0.25]); + } + #[test] fn colorama_palettes_modify_selection_masking_and_add_phase() { let grey = [0.5, 0.5, 0.5, 1.0]; diff --git a/crates/gpu/src/shaders/kernels.wgsl b/crates/gpu/src/shaders/kernels.wgsl index 45cb9efe2..2d00ccfa9 100644 --- a/crates/gpu/src/shaders/kernels.wgsl +++ b/crates/gpu/src/shaders/kernels.wgsl @@ -766,17 +766,13 @@ fn pointwise(@builtin(global_invocation_id) gid: vec3) { let c = P.f[0]; let invert = P.u[0].y != 0u; let opacity = P.f[1].x; + // The fill colour at the layer's (or inverted) alpha times Opacity (color_fx::fill). var fa = px.w; if (invert) { fa = 1.0 - px.w; } - let f = c.xyz * fa; - let rgb = px.xyz + (f - px.xyz) * opacity; - var alpha = px.w; - if (invert) { - alpha = px.w + (fa - px.w) * opacity; - } - px = vec4(rgb, alpha); + fa = fa * opacity; + px = vec4(c.xyz * fa, fa); } case 8u: { // effects::color2::map_ca: transparent pixels only change through the alpha curve. diff --git a/docs/effects-parity.md b/docs/effects-parity.md index 97c43bc43..4e9a2ddb7 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -33,7 +33,7 @@ corpus compares renders, each effect is assumed to differ somewhere, and users k | Color Correction | 40 | 40 | Tint, Tritone and Black & White (sliders and Tint) match After Effects 2026 renders (Rec. 601 luminance); Levels (Individual Controls) matches After Effects 2026 in 32 bpc (channel controls before RGB, "Off for 32 bpc Color" unclipped; plain Levels' channel order not measured); Brightness & Contrast (non-legacy) within about 6/255 of After Effects 2026 ramps (negative settings invert the positive curves); Tint has Swap Colors (#963); ACES 2.0 / OCIO 2.5 (26.5); Apply Color LUT (#514) and 32-bpc blending (#494) are closed | | Distort | 39 | 35 | Liquify has no viewer brush; #303 (Mesh Warp drag) and #534 (CC Power Pin Unstretch) are closed | | Expression Controls | 9 | 9 | — | -| Generate | 26 | 25 | 4-Color Gradient's Blend matches After Effects 2026 (weights 1 / (d² + Blend²)); Fractal on the CPU (needs f64); #526 (Reset) and #536 (Write-on marks) are closed | +| Generate | 26 | 25 | 4-Color Gradient's Blend matches After Effects 2026 (weights 1 / (d² + Blend²)); Fill's Opacity is the filled layer's opacity, as After Effects 2026 renders it (with a Fill Mask it still mixes with the original, not measured); Fractal on the CPU (needs f64); #526 (Reset) and #536 (Write-on marks) are closed | | Immersive Video | 12 | 12 | — | | Keying | 12 | 12 | Luma Key and Extract key on Rec. 601 luminance, as After Effects 2026; Key Cleaner approximated; #533 (wire-removal Frame Offset) is closed | | Matte | 6 | 4 | Mocha shape reads our documented JSON, not Mocha's format | @@ -71,6 +71,7 @@ the GPU is performance, not parity. | 2026-10-19 | minor | Luma Key: Key Out Brighter / Darker cut hard at Threshold, measured against After Effects 2026 renders of a ramp (CPU and GPU) | | 2026-10-19 | minor | Luma Key and Extract: Rec. 601 luminance (was Rec. 709), measured against After Effects 2026 (CPU and GPU) | | 2026-10-19 | minor | 4-Color Gradient: each point weighs 1 / (d² + Blend²), measured against After Effects 2026 at Blend 10 / 50 / 100 / 300 (CPU and GPU) | +| 2026-10-19 | minor | Fill without a mask: the layer becomes the fill colour at its alpha × Opacity (was a mix with the original), measured against After Effects 2026 (CPU and GPU) | | 2026-10-18 | minor | Tint uses Rec. 601 luminance weights, measured against After Effects 2026 renders of red, green, blue and grey solids (CPU and GPU) | | 2026-10-18 | minor | Exposure converts to linear light with a pure 2.2 power curve, as measured in After Effects (Offset 0.0005 / 0.002 / 0.02 / 0.1 on black: 8 / 15 / 43 / 90), not the piecewise sRGB curve; its sliders take After Effects' ranges (Exposure −100..100, slider −4..4; Offset −2..2, slider −0.5..0.5; Gamma Correction 0.1..10) | | 2026-10-12 | minor | Parameters 3,014 to 3,017 (effects.md); #303, #514, #526, #533–#536 marked closed instead of pending | From d9205fa5f499a4df02c0696db46bff7072523cb0 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 19 Oct 2026 15:36:29 +0200 Subject: [PATCH 18/46] Curves: a natural cubic spline through the points, as After Effects After Effects' Curves effect interpolates its points with a natural cubic spline (zero curvature at the ends), clamped to 0..1: AE 2026 renders of an RGB curve (0,0) (125,199) (255,255) and a Red curve (0,0) (128,44) (255,255) (cases 962 / 963, ROUND4) give exactly the natural spline's values on every grey step (0, 30, 59, 115, 164, 202, 227, 242, 250, 255 and 0, 0, 1, 6, 19, 44, 83, 135, 194, 255). EffectCraft used a monotone (Fritsch-Carlson) spline: 34 / 67 / 125 / 171 ... on the RGB curve, up to 10/255 off (ROUND4 measured up to 6 levels on real projects); now within 1/255, CPU and GPU (the same Hermite tables carry the spline's knot slopes). Lumetri's curves and Glow's Arbitrary Map keep the monotone spline (not measured). Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/color2.rs | 76 +++++++++++++++++++++++++++++++++++- crates/gpu/src/effects.rs | 2 +- docs/effects-parity.md | 3 +- 3 files changed, 78 insertions(+), 3 deletions(-) diff --git a/crates/effects/src/color2.rs b/crates/effects/src/color2.rs index d78b0fb08..9547fd775 100644 --- a/crates/effects/src/color2.rs +++ b/crates/effects/src/color2.rs @@ -55,6 +55,17 @@ impl Curve { /// Parse "x,y x,y …" (separators: whitespace or ';'). Returns None for an identity curve or /// fewer than two valid points. pub fn parse(s: &str) -> Option { + Self::parse_with(s, false) + } + + /// [`Curve::parse`] interpolated as After Effects' Curves effect does: a natural cubic spline + /// through the points, clamped to 0..1 (AE 2026: RGB points (0,0) (125,199) (255,255) and + /// Red (0,0) (128,44) (255,255) map every grey step exactly as the natural spline does). + pub fn parse_natural(s: &str) -> Option { + Self::parse_with(s, true) + } + + fn parse_with(s: &str, natural: bool) -> Option { let mut pts: Vec<(f32, f32)> = s .split(|c: char| c.is_whitespace() || c == ';') .filter(|t| !t.is_empty()) @@ -74,6 +85,12 @@ impl Curve { let ys: Vec = pts.iter().map(|p| p.1).collect(); let n = xs.len(); let d: Vec = (0..n - 1).map(|k| (ys[k + 1] - ys[k]) / (xs[k + 1] - xs[k])).collect(); + if natural { + let ms = natural_slopes(&xs, &d); + let mut c = Curve { xs, ys, ms, lut: Vec::new() }; + c.lut = (0..=LUT_N).map(|i| c.eval_exact(i as f32 / LUT_N as f32).clamp(0.0, 1.0)).collect(); + return Some(c); + } let mut ms = vec![0.0f32; n]; ms[0] = d[0]; ms[n - 1] = d[n - 2]; @@ -136,6 +153,40 @@ impl Curve { } } +/// Knot slopes of the natural cubic spline (zero curvature at both ends) through points with +/// abscissae `xs` and segment slopes `d`; as Hermite tangents they reproduce the spline exactly. +fn natural_slopes(xs: &[f32], d: &[f32]) -> Vec { + let n = xs.len(); + if n < 3 { + return vec![d.first().copied().unwrap_or(0.0); n]; + } + let h: Vec = (0..n - 1).map(|k| f64::from(xs[k + 1] - xs[k])).collect(); + let dd: Vec = d.iter().map(|&v| f64::from(v)).collect(); + // Second derivatives M[1..n-1] (M[0] = M[n-1] = 0): tridiagonal system, Thomas algorithm. + let m = n - 2; + let (mut a, mut b, mut c, mut r) = (vec![0.0; m], vec![0.0; m], vec![0.0; m], vec![0.0; m]); + for i in 0..m { + let k = i + 1; + a[i] = h[k - 1]; + b[i] = 2.0 * (h[k - 1] + h[k]); + c[i] = h[k]; + r[i] = 6.0 * (dd[k] - dd[k - 1]); + } + for i in 1..m { + let w = a[i] / b[i - 1]; + b[i] -= w * c[i - 1]; + r[i] -= w * r[i - 1]; + } + let mut sec = vec![0.0f64; n]; + for i in (0..m).rev() { + let next = if i + 1 < m { sec[i + 2] } else { 0.0 }; + sec[i + 1] = (r[i] - c[i] * next) / b[i]; + } + let mut ms: Vec = (0..n - 1).map(|k| (dd[k] - h[k] * (2.0 * sec[k] + sec[k + 1]) / 6.0) as f32).collect(); + ms.push((dd[n - 2] + h[n - 2] * (sec[n - 2] + 2.0 * sec[n - 1]) / 6.0) as f32); + ms +} + fn ev(c: &Option, v: f32) -> f32 { c.as_ref().map(|c| c.eval(v)).unwrap_or(v) } @@ -145,7 +196,7 @@ fn ev(c: &Option, v: f32) -> f32 { /// sorted on load); the default `"0,0 1,1"` is the identity. Colour channels go through the /// master RGB curve first, then their own curve. fn curves(ctx: &EffectCtx, mut b: Buf) -> Buf { - let [m, r, g, bl, a] = ["rgb", "red", "green", "blue", "alpha"].map(|id| Curve::parse(ctx.params.s(id))); + let [m, r, g, bl, a] = ["rgb", "red", "green", "blue", "alpha"].map(|id| Curve::parse_natural(ctx.params.s(id))); if m.is_none() && r.is_none() && g.is_none() && bl.is_none() && a.is_none() { return b; } @@ -1199,6 +1250,29 @@ mod tests { assert!((clipped.get(0, 0)[0] - 0.05).abs() < 1e-5, "{:?}", clipped.get(0, 0)); } + /// After Effects 2026 (cases 962 / 963): Curves through RGB (0,0) (125,199) + /// (255,255) and Red (0,0) (128,44) (255,255) on grey steps 0..255, 8-bit results. + #[test] + fn curves_follow_a_natural_spline_as_in_after_effects() { + let steps = [0u8, 16, 32, 64, 96, 128, 160, 192, 224, 255]; + let mut img = Image::new(10, 1); + for (x, v) in steps.iter().enumerate() { + let v = *v as f32 / 255.0; + img.set(x as u32, 0, [v, v, v, 1.0]); + } + let p = |pts: &[(f32, f32)]| Value::Str(pts.iter().map(|(x, y)| format!("{},{}", x / 255.0, y / 255.0)).collect::>().join(" ")); + let rgb = run("ec.color.curves", &img, &[("rgb", p(&[(0.0, 0.0), (125.0, 199.0), (255.0, 255.0)]))]); + let red = run("ec.color.curves", &img, &[("red", p(&[(0.0, 0.0), (128.0, 44.0), (255.0, 255.0)]))]); + let ae_rgb = [0, 30, 59, 115, 164, 202, 227, 242, 250, 255]; + let ae_red = [0, 0, 1, 6, 19, 44, 83, 135, 194, 255]; + for x in 0..10 { + let g = (rgb.get(x as i64, 0)[1] * 255.0).round() as i32; + let r = (red.get(x as i64, 0)[0] * 255.0).round() as i32; + assert!((g - ae_rgb[x]).abs() <= 1, "RGB step {}: {g} vs AE {}", steps[x], ae_rgb[x]); + assert!((r - ae_red[x]).abs() <= 1, "Red step {}: {r} vs AE {}", steps[x], ae_red[x]); + } + } + #[test] fn levels_ic_identity_and_red_only() { let img = ramp(); diff --git a/crates/gpu/src/effects.rs b/crates/gpu/src/effects.rs index 42d2bab71..33deb36a8 100644 --- a/crates/gpu/src/effects.rs +++ b/crates/gpu/src/effects.rs @@ -388,7 +388,7 @@ fn transform(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { // ---------------------------------------------------------------- per-pixel fn curves(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { - let parsed = ["rgb", "red", "green", "blue", "alpha"].map(|id| effectcraft_effects::Curve::parse(ctx.params.s(id))); + let parsed = ["rgb", "red", "green", "blue", "alpha"].map(|id| effectcraft_effects::Curve::parse_natural(ctx.params.s(id))); if parsed.iter().all(Option::is_none) { return Some(b); } diff --git a/docs/effects-parity.md b/docs/effects-parity.md index 4e9a2ddb7..099e783ec 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -30,7 +30,7 @@ corpus compares renders, each effect is assumed to differ somewhere, and users k | Audio | 13 | 0 | CPU by design (applied in the mixdown) | | Blur & Sharpen | 16 | 15 | Gaussian Blur interaction and output to be measured first ([fidelity/ae-2026-acceptance.md](fidelity/ae-2026-acceptance.md)) | | Channel | 13 | 13 | — | -| Color Correction | 40 | 40 | Tint, Tritone and Black & White (sliders and Tint) match After Effects 2026 renders (Rec. 601 luminance); Levels (Individual Controls) matches After Effects 2026 in 32 bpc (channel controls before RGB, "Off for 32 bpc Color" unclipped; plain Levels' channel order not measured); Brightness & Contrast (non-legacy) within about 6/255 of After Effects 2026 ramps (negative settings invert the positive curves); Tint has Swap Colors (#963); ACES 2.0 / OCIO 2.5 (26.5); Apply Color LUT (#514) and 32-bpc blending (#494) are closed | +| Color Correction | 40 | 40 | Tint, Tritone and Black & White (sliders and Tint) match After Effects 2026 renders (Rec. 601 luminance); Levels (Individual Controls) matches After Effects 2026 in 32 bpc (channel controls before RGB, "Off for 32 bpc Color" unclipped; plain Levels' channel order not measured); Brightness & Contrast (non-legacy) within about 6/255 of After Effects 2026 ramps (negative settings invert the positive curves); Curves interpolates a natural cubic spline through its points, as After Effects 2026 (exact on its grey steps); Tint has Swap Colors (#963); ACES 2.0 / OCIO 2.5 (26.5); Apply Color LUT (#514) and 32-bpc blending (#494) are closed | | Distort | 39 | 35 | Liquify has no viewer brush; #303 (Mesh Warp drag) and #534 (CC Power Pin Unstretch) are closed | | Expression Controls | 9 | 9 | — | | Generate | 26 | 25 | 4-Color Gradient's Blend matches After Effects 2026 (weights 1 / (d² + Blend²)); Fill's Opacity is the filled layer's opacity, as After Effects 2026 renders it (with a Fill Mask it still mixes with the original, not measured); Fractal on the CPU (needs f64); #526 (Reset) and #536 (Write-on marks) are closed | @@ -72,6 +72,7 @@ the GPU is performance, not parity. | 2026-10-19 | minor | Luma Key and Extract: Rec. 601 luminance (was Rec. 709), measured against After Effects 2026 (CPU and GPU) | | 2026-10-19 | minor | 4-Color Gradient: each point weighs 1 / (d² + Blend²), measured against After Effects 2026 at Blend 10 / 50 / 100 / 300 (CPU and GPU) | | 2026-10-19 | minor | Fill without a mask: the layer becomes the fill colour at its alpha × Opacity (was a mix with the original), measured against After Effects 2026 (CPU and GPU) | +| 2026-10-19 | minor | Curves: natural cubic spline through the points, clamped to 0..1 (was a monotone spline), measured against After Effects 2026 (CPU and GPU); Lumetri's curves and Glow's Arbitrary Map keep the monotone spline | | 2026-10-18 | minor | Tint uses Rec. 601 luminance weights, measured against After Effects 2026 renders of red, green, blue and grey solids (CPU and GPU) | | 2026-10-18 | minor | Exposure converts to linear light with a pure 2.2 power curve, as measured in After Effects (Offset 0.0005 / 0.002 / 0.02 / 0.1 on black: 8 / 15 / 43 / 90), not the piecewise sRGB curve; its sliders take After Effects' ranges (Exposure −100..100, slider −4..4; Offset −2..2, slider −0.5..0.5; Gamma Correction 0.1..10) | | 2026-10-12 | minor | Parameters 3,014 to 3,017 (effects.md); #303, #514, #526, #533–#536 marked closed instead of pending | From e559a7ce9ac93a35c655c65276ddd01684746610 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 19 Oct 2026 18:38:41 +0200 Subject: [PATCH 19/46] Review: tie the colour kernels' literals to the CPU constants; visible GPU skip; B&C wording WGSL cannot read Rust constants, so the Rec. 601 luma (Tint, Tritone, Luma Key / Extract), Black & White's tint weights and Exposure's 2.2 power stay literals in the kernels; they now name their Rust source (effects::LUMA_601, BW_TINT_LUMA, EXPOSURE_LINEAR_GAMMA, newly exported) and the test shader_constants_match_the_cpu (no GPU needed) fails if a literal drifts. gpu_tint_defaults_match_after_effects_greys says "skipped: no GPU adapter" when it skips (EFFECTCRAFT_REQUIRE_GPU=1 still makes it fail). docs/effects-parity.md: Brightness & Contrast is close to, not the same as, After Effects (up to 6.2/255), and the summary row lists what is measured. Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/color_fx.rs | 13 +++++++++++-- crates/effects/src/lib.rs | 6 +++--- crates/gpu/src/shaders/fx_color.wgsl | 4 +++- crates/gpu/src/shaders/fx_key.wgsl | 3 ++- crates/gpu/src/shaders/kernels.wgsl | 6 ++++-- crates/gpu/src/tests.rs | 29 +++++++++++++++++++++++++++- docs/effects-parity.md | 6 +++--- 7 files changed, 54 insertions(+), 13 deletions(-) diff --git a/crates/effects/src/color_fx.rs b/crates/effects/src/color_fx.rs index 8e451f4eb..402d0ebe2 100644 --- a/crates/effects/src/color_fx.rs +++ b/crates/effects/src/color_fx.rs @@ -22,9 +22,13 @@ fn mix(a: [f32; 3], b: [f32; 3], t: f32) -> [f32; 3] { /// pure red, green and blue keep red (601: 76, 709: 54) and blue (29 / 18) and key out green /// (150 / 182). The GPU kernels (`pointwise` op 1, `fx_color` op 4, `fxk_key_luma`) mirror it. pub(crate) fn luma_601(c: [f32; 3]) -> f32 { - 0.299 * c[0] + 0.587 * c[1] + 0.114 * c[2] + LUMA_601[0] * c[0] + LUMA_601[1] * c[1] + LUMA_601[2] * c[2] } +/// The Rec. 601 luma weights of [`luma_601`]. The WGSL kernels repeat them as literals; the GPU +/// test `shader_constants_match_the_cpu` checks that they are these. +pub const LUMA_601: [f32; 3] = [0.299, 0.587, 0.114]; + fn tint(ctx: &EffectCtx, mut b: Buf) -> Buf { let black = ctx.params.color("black"); let white = ctx.params.color("white"); @@ -337,6 +341,7 @@ pub fn exposure_settings(ctx: &EffectCtx) -> [(f32, f32, f32); 3] { /// The power curve Exposure's own linear-light conversion uses (Bypass Linear Light Conversion /// off). After Effects decodes with a pure 2.2 power, not the piecewise sRGB curve: Offset 0.0005 /// on black measures 8/255 there, which 2.2 predicts (8.06) and sRGB does not (1.65). +/// `kernels.wgsl` repeats it as a literal; the GPU test `shader_constants_match_the_cpu` checks it. pub const EXPOSURE_LINEAR_GAMMA: f32 = 2.2; /// Exposure: per channel, `(value · 2^exposure + offset)^(1 / gamma)`, in linear light unless @@ -379,8 +384,12 @@ pub fn mirror_pow(v: f32, p: f32) -> f32 { /// back toward the gray). After Effects 2026 with Tint Color [1, 0.5, 0]: gray 0.1 (a red solid /// at Reds 10, case 717) → (43, 21, 0), gray 0.6 (yellow at defaults, case 825) → (255, 129, 2), /// white → white; the old hue / saturation / lightness mix gave (51, 26, 0) for the first. +/// The luminosity weights of [`bw_tint`] (a Color blend's). `fx_color.wgsl` repeats them; the GPU +/// test `shader_constants_match_the_cpu` checks that they are these. +pub const BW_TINT_LUMA: [f32; 3] = [0.3, 0.59, 0.11]; + pub fn bw_tint(tint: [f32; 3], gray: f32) -> [f32; 3] { - let lum = |c: [f32; 3]| 0.3 * c[0] + 0.59 * c[1] + 0.11 * c[2]; + let lum = |c: [f32; 3]| BW_TINT_LUMA[0] * c[0] + BW_TINT_LUMA[1] * c[1] + BW_TINT_LUMA[2] * c[2]; if !gray.is_finite() || gray <= 0.0 { return [0.0; 3]; } diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 13a4130d0..a2497fcfb 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -70,11 +70,11 @@ use std::collections::HashMap; use std::sync::OnceLock; pub use card3d::{CompLight, CompScene}; -pub use color_fx::{COLORAMA_PRESETS, ColoramaPalette}; pub use color_fx::{ - HUESAT_CHANNELS, HUESAT_RANGES, LEVELS_CHANNELS, PHOTO_FILTER_CUSTOM, PHOTO_FILTERS, exposure_settings, fill_uses_masks, huesat_ranges_identity, - levels_channel_ids, levels_channel_settings, levels_channels_identity, levels_clip, + BW_TINT_LUMA, EXPOSURE_LINEAR_GAMMA, HUESAT_CHANNELS, HUESAT_RANGES, LEVELS_CHANNELS, LUMA_601, PHOTO_FILTER_CUSTOM, PHOTO_FILTERS, exposure_settings, + fill_uses_masks, huesat_ranges_identity, levels_channel_ids, levels_channel_settings, levels_channels_identity, levels_clip, }; +pub use color_fx::{COLORAMA_PRESETS, ColoramaPalette}; pub use color2::{Curve, auto_correct_settings, color_link_mode, color_link_sample, equalize_tables, luma_clip_points, shadow_highlight_amounts}; pub use color3::OffsetCurve; pub use distort::transform_shutter; diff --git a/crates/gpu/src/shaders/fx_color.wgsl b/crates/gpu/src/shaders/fx_color.wgsl index daac9fb5a..f616a2d9d 100644 --- a/crates/gpu/src/shaders/fx_color.wgsl +++ b/crates/gpu/src/shaders/fx_color.wgsl @@ -102,6 +102,7 @@ fn fxc_sc_weights(c0: vec3) -> array { // Black & White's Tint (effects::color_fx::bw_tint): the tint's hue and saturation at the // gray's luminosity (Color blend, weights 0.3 / 0.59 / 0.11, channels clipped toward the gray). +// The weights are effects::BW_TINT_LUMA (checked by the test shader_constants_match_the_cpu). fn fxc_bw_tint(tint: vec3, gray: f32) -> vec3 { if (!(gray > 0.0)) { return vec3(0.0); @@ -191,7 +192,8 @@ fn fxc_point(@builtin(global_invocation_id) gid: vec3) { case 4u: { if (a > 0.0) { let c = px.xyz / a; - // Rec. 601 weights, as effects::color_fx::tritone. + // Rec. 601 weights, as effects::color_fx::tritone: effects::LUMA_601 (checked by + // the test shader_constants_match_the_cpu). let l = clamp(dot(c, vec3(0.299, 0.587, 0.114)), 0.0, 1.0); var t: vec3; if (l < 0.5) { diff --git a/crates/gpu/src/shaders/fx_key.wgsl b/crates/gpu/src/shaders/fx_key.wgsl index 37146c329..bee93ea65 100644 --- a/crates/gpu/src/shaders/fx_key.wgsl +++ b/crates/gpu/src/shaders/fx_key.wgsl @@ -62,7 +62,8 @@ fn fxk_range_components(space: u32, c: vec3) -> vec3 { } // keying::matte_levels. -// Luma Key / Extract luminance: Rec. 601 (effects::color_fx::luma_601). +// Luma Key / Extract luminance: Rec. 601, effects::LUMA_601 (checked by the test +// shader_constants_match_the_cpu). fn fxk_key_luma(c: vec3) -> f32 { return dot(c, vec3(0.299, 0.587, 0.114)); } diff --git a/crates/gpu/src/shaders/kernels.wgsl b/crates/gpu/src/shaders/kernels.wgsl index 2d00ccfa9..6960da85c 100644 --- a/crates/gpu/src/shaders/kernels.wgsl +++ b/crates/gpu/src/shaders/kernels.wgsl @@ -605,7 +605,8 @@ fn pointwise(@builtin(global_invocation_id) gid: vec3) { case 1u: { if (a > 0.0) { let c = straight(px); - // Rec. 601 weights, as After Effects (effects crate luma_601). + // Rec. 601 weights, as After Effects: effects::LUMA_601 (checked by the test + // shader_constants_match_the_cpu). let l = clamp(0.299 * c.x + 0.587 * c.y + 0.114 * c.z, 0.0, 1.0); let blk = P.f[0].xyz; let wht = P.f[1].xyz; @@ -726,7 +727,8 @@ fn pointwise(@builtin(global_invocation_id) gid: vec3) { var o = vec3(0.0); for (var i = 0; i < 3; i++) { // Per-channel gain, offset, gamma; negative values are mirrored. The - // linear-light conversion is a pure 2.2 power (effects crate exposure). + // linear-light conversion is a pure 2.2 power: effects::EXPOSURE_LINEAR_GAMMA + // (checked by the test shader_constants_match_the_cpu). if (P.u[0].y != 0u) { let x = c[i] * P.f[0][i] + P.f[1][i]; o[i] = sign(x) * powz(abs(x), 1.0 / P.f[2][i]); diff --git a/crates/gpu/src/tests.rs b/crates/gpu/src/tests.rs index 11dc81a93..a481ff736 100644 --- a/crates/gpu/src/tests.rs +++ b/crates/gpu/src/tests.rs @@ -855,7 +855,11 @@ fn effects_whose_kernels_failed_render_on_the_cpu() { /// working space, not linearised): Rec. 601 weights, as the CPU (`color_fx::luma_601`). #[test] fn gpu_tint_defaults_match_after_effects_greys() { - let Some(g) = gpu() else { return }; + let Some(g) = gpu() else { + // Visible skip (EFFECTCRAFT_REQUIRE_GPU=1 makes a missing adapter fail instead). + eprintln!("gpu_tint_defaults_match_after_effects_greys: skipped: no GPU adapter"); + return; + }; for (col, want) in [([1.0, 0.0, 0.0], 76.0), ([0.0, 1.0, 0.0], 150.0), ([0.0, 0.0, 1.0], 29.0), ([0.5, 0.5, 0.5], 128.0)] { let mut s = Scene::new(BitDepth::Bpc32); s.p.settings.working_space = Some(ColorSpace::Srgb); @@ -870,6 +874,29 @@ fn gpu_tint_defaults_match_after_effects_greys() { } } +/// WGSL has no way to read the CPU's constants, so the kernels repeat them as literals; this +/// ties each literal to its Rust source (runs without a GPU): Rec. 601 luma for Tint (`pointwise` +/// op 1), Tritone and Luma Key / Extract, Black & White's tint weights and Exposure's 2.2 power. +#[test] +fn shader_constants_match_the_cpu() { + use effectcraft_effects::{BW_TINT_LUMA, EXPOSURE_LINEAR_GAMMA, LUMA_601}; + let kernels = include_str!("shaders/kernels.wgsl"); + let color = include_str!("shaders/fx_color.wgsl"); + let key = include_str!("shaders/fx_key.wgsl"); + let [r, g, b] = LUMA_601; + let dot = format!("dot(c, vec3({r}, {g}, {b}))"); + assert!(kernels.contains(&format!("clamp({r} * c.x + {g} * c.y + {b} * c.z, 0.0, 1.0)")), "Tint luma in kernels.wgsl is not {LUMA_601:?}"); + assert!(color.contains(&format!("clamp({dot}, 0.0, 1.0)")), "Tritone luma in fx_color.wgsl is not {LUMA_601:?}"); + assert!(key.contains(&format!("return {dot};")), "fxk_key_luma in fx_key.wgsl is not {LUMA_601:?}"); + let [r, g, b] = BW_TINT_LUMA; + assert!(color.contains(&format!("let w = vec3({r}, {g}, {b});")), "fxc_bw_tint weights are not {BW_TINT_LUMA:?}"); + let gamma = EXPOSURE_LINEAR_GAMMA; + assert!( + kernels.contains(&format!("powz(abs(c[i]), {gamma})")) && kernels.contains(&format!("1.0 / {gamma})")), + "Exposure's power in kernels.wgsl is not {gamma}" + ); +} + /// A layer whose bounds change every frame reads back at a new size each time: the staging /// buffers kept for reuse stay within budget instead of growing for the whole render (#2081). #[test] diff --git a/docs/effects-parity.md b/docs/effects-parity.md index 099e783ec..6defcba8e 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -15,7 +15,7 @@ After Effects', and does the preset library exist. The per-effect catalogue is t | Parameter names, order, popups, units, defaults | aligned in M9.12 | Agent-checked against After Effects' documentation, not against a live dump | | On the GPU | **280** (92%) | Measured: registry flags; GPU vs CPU agreement tested per effect | | 32-bit float, no clamping | 306 | Measured: registry flags | -| **Rendered output matches After Effects** | **unmeasured** | Only Tint is checked against After Effects renders (four solids, within 1/255). `effectcraft_effects::fidelity::compare_frames` exists for the corpus ([fidelity/README.md](fidelity/README.md)); simulation effects are pinned by golden hashes of our own output (M13.27) | +| **Rendered output matches After Effects** | **partly measured** | Some colour effects are checked against After Effects 2026 renders of our own test projects: Tint, Tritone, Black & White, Exposure, Levels, Curves, Luma Key, Extract, 4-Color Gradient and Fill within 1–3/255, Brightness & Contrast up to 6.2/255 (Color Correction, Generate and Keying rows below); every other effect is unmeasured. `effectcraft_effects::fidelity::compare_frames` exists for the corpus ([fidelity/README.md](fidelity/README.md)); simulation effects are pinned by golden hashes of our own output (M13.27) | | Animation presets | **54** original presets (8 text-animator, 23 Transitions, 23 Backgrounds) against After Effects' **621** `.ffx` presets | Measured: `crates/engine/presets/text_animators.json`, `crates/engine/presets/transitions.json`, `crates/engine/presets/backgrounds.json`; the After Effects `Presets` folder | | 26.x additions | Curl Noise (26.3) present (#463 open); effect colour labels (26.5) present in Effect Controls only, not in the Timeline; the rebuilt Effect Controls (26.5) missing | Release notes | @@ -30,7 +30,7 @@ corpus compares renders, each effect is assumed to differ somewhere, and users k | Audio | 13 | 0 | CPU by design (applied in the mixdown) | | Blur & Sharpen | 16 | 15 | Gaussian Blur interaction and output to be measured first ([fidelity/ae-2026-acceptance.md](fidelity/ae-2026-acceptance.md)) | | Channel | 13 | 13 | — | -| Color Correction | 40 | 40 | Tint, Tritone and Black & White (sliders and Tint) match After Effects 2026 renders (Rec. 601 luminance); Levels (Individual Controls) matches After Effects 2026 in 32 bpc (channel controls before RGB, "Off for 32 bpc Color" unclipped; plain Levels' channel order not measured); Brightness & Contrast (non-legacy) within about 6/255 of After Effects 2026 ramps (negative settings invert the positive curves); Curves interpolates a natural cubic spline through its points, as After Effects 2026 (exact on its grey steps); Tint has Swap Colors (#963); ACES 2.0 / OCIO 2.5 (26.5); Apply Color LUT (#514) and 32-bpc blending (#494) are closed | +| Color Correction | 40 | 40 | Tint, Tritone and Black & White (sliders and Tint) match After Effects 2026 renders (Rec. 601 luminance); Levels (Individual Controls) matches After Effects 2026 in 32 bpc (channel controls before RGB, "Off for 32 bpc Color" unclipped; plain Levels' channel order not measured); Brightness & Contrast (non-legacy) close to After Effects 2026 ramps, not identical (up to 6.2/255 off; the test allows 7/255) (negative settings invert the positive curves); Curves interpolates a natural cubic spline through its points, as After Effects 2026 (exact on its grey steps); Tint has Swap Colors (#963); ACES 2.0 / OCIO 2.5 (26.5); Apply Color LUT (#514) and 32-bpc blending (#494) are closed | | Distort | 39 | 35 | Liquify has no viewer brush; #303 (Mesh Warp drag) and #534 (CC Power Pin Unstretch) are closed | | Expression Controls | 9 | 9 | — | | Generate | 26 | 25 | 4-Color Gradient's Blend matches After Effects 2026 (weights 1 / (d² + Blend²)); Fill's Opacity is the filled layer's opacity, as After Effects 2026 renders it (with a Fill Mask it still mixes with the original, not measured); Fractal on the CPU (needs f64); #526 (Reset) and #536 (Write-on marks) are closed | @@ -67,7 +67,7 @@ the GPU is performance, not parity. | 2026-10-19 | minor | Tritone uses Rec. 601 luminance, measured against After Effects 2026 renders of a red solid and a grey ramp (CPU and GPU) | | 2026-10-19 | minor | Black & White's Tint keeps the gray's luminosity (Color blend), measured against After Effects 2026 renders (CPU and GPU) | | 2026-10-19 | minor | Levels: "Off for 32 bpc Color" no longer clips in 32 bpc projects, unclipped values extrapolate below Output Black; Levels (Individual Controls) applies the channel controls before the RGB ones, measured against After Effects 2026 (CPU and GPU) | -| 2026-10-19 | minor | Brightness & Contrast: negative Brightness / Contrast are the inverse curves of the positive ones (Brightness −40: 20/255 → 6/255 off After Effects 2026), CPU and GPU | +| 2026-10-19 | minor | Brightness & Contrast: negative Brightness / Contrast are the inverse curves of the positive ones (Brightness −40: 20/255 → 6/255 off After Effects 2026; still up to 6.2/255 off, not the same pixels), CPU and GPU | | 2026-10-19 | minor | Luma Key: Key Out Brighter / Darker cut hard at Threshold, measured against After Effects 2026 renders of a ramp (CPU and GPU) | | 2026-10-19 | minor | Luma Key and Extract: Rec. 601 luminance (was Rec. 709), measured against After Effects 2026 (CPU and GPU) | | 2026-10-19 | minor | 4-Color Gradient: each point weighs 1 / (d² + Blend²), measured against After Effects 2026 at Blend 10 / 50 / 100 / 300 (CPU and GPU) | From 81548edff996104a48b22b1d3c69a64d6e532cda Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:22:36 +0200 Subject: [PATCH 20/46] Resolve SVG font-size percentages against the parent font size Found while testing crates/svg/src/lib.rs:624 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/svg/src/lib.rs | 6 +++++- crates/svg/src/tests.rs | 15 +++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index 24cc584e0..82ab5943f 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -621,7 +621,11 @@ impl<'a, 'i> Parser<'a, 'i> { if let Some(c) = d.get("color").and_then(|v| parse_color(v)) { st.color = c; } - if let Some(v) = d.get("font-size").and_then(|v| self.length(v, 'o', st.font_size)) { + let font_size = d.get("font-size").and_then(|v| match v.trim().strip_suffix('%') { + Some(p) => p.trim().parse::().ok().map(|p| p / 100.0 * st.font_size), + None => self.length(v, 'o', st.font_size), + }); + if let Some(v) = font_size.filter(|v| v.is_finite()) { st.font_size = v; } if let Some(p) = d.get("fill").and_then(|v| self.paint_ref(v)) { diff --git a/crates/svg/src/tests.rs b/crates/svg/src/tests.rs index 93db715c1..5c5838ebd 100644 --- a/crates/svg/src/tests.rs +++ b/crates/svg/src/tests.rs @@ -277,3 +277,18 @@ fn a_symbol_used_a_thousand_times_still_imports() { let doc = parse(svg.as_bytes()).unwrap(); assert_eq!(depth_and_shapes(&doc.root).1, 3000); } + +#[test] +fn font_size_percentage_resolves_against_the_parent_font_size() { + for (body, expected) in [ + (r#""#, 32.0), + (r#""#, 32.0), + (r#""#, 64.0), + ] { + let svg = format!(r#"{body}"#); + let d = parse(svg.as_bytes()).unwrap(); + let shapes = d.flatten(); + assert_eq!(shapes.len(), 1); + assert!(matches!(shapes[0].2.geom, Geom::Rect { w, .. } if w == expected), "{body}: {:?}", shapes[0].2.geom); + } +} From 35691f8d19f9cb37c7ddf94f91460a7d085a934a Mon Sep 17 00:00:00 2001 From: ded Date: Mon, 19 Oct 2026 20:11:14 +0300 Subject: [PATCH 21/46] G5: bench builds scenarios from an args file on Windows xtask bench passed the whole layers scenario on one command line, longer than Windows' 32 767-character limit (os error 206), so no scenario past demo ran. effectcraft-cli run takes --args-file F (one argument per line) and bench writes its commands there. Co-Authored-By: Claude Opus 5.5 --- apps/effectcraft-cli/src/main.rs | 12 ++++++++++-- apps/effectcraft-cli/tests/cli.rs | 23 +++++++++++++++++++++++ xtask/src/bench.rs | 15 +++++++++------ 3 files changed, 42 insertions(+), 8 deletions(-) diff --git a/apps/effectcraft-cli/src/main.rs b/apps/effectcraft-cli/src/main.rs index c7a012c76..6e5b8d6cc 100644 --- a/apps/effectcraft-cli/src/main.rs +++ b/apps/effectcraft-cli/src/main.rs @@ -6,6 +6,7 @@ //! effectcraft-cli exec [--params JSON] run one command //! effectcraft-cli exec --list [--filter TEXT] [--schemas] list engine commands (as `commands`) //! effectcraft-cli run [ …] run several commands in order +//! effectcraft-cli run --args-file F the same, one argument per line of F //! effectcraft-cli props [--flat] [--time S] a layer's property tree (with paths) //! effectcraft-cli get [--time S] read a property //! effectcraft-cli set [--time S] [--expression E] @@ -96,6 +97,7 @@ const USAGE: &str = "usage: effectcraft-cli [--params JSON] run one engine command (exec --list: list them, as `commands`) run [ ...] run several commands in order + run --args-file F the same, one argument per line of F (no command-line length limit) props [--flat] [--time S] a layer's property tree with paths get [--time S] read a property set [--time S] [--expression E] @@ -148,6 +150,7 @@ const VALUED: &[&str] = &[ "--expression", "--depth", "--eval", + "--args-file", // render "--template", "--values", @@ -515,10 +518,15 @@ fn run(cmd: &str, args: &Args, json_out: bool) -> Result<(), Failure> { emit(&with_saved(r, saved), json_out); } "run" => { - if args.pos.is_empty() { + let mut pairs = args.pos.clone(); + // Windows caps a command line at 32 767 characters: long batches come from a file. + if let Some(f) = args.opt("--args-file") { + let text = std::fs::read_to_string(f).map_err(|e| Failure::Usage(format!("--args-file {f}: {e}")))?; + pairs.extend(text.lines().map(str::trim).filter(|l| !l.is_empty()).map(String::from)); + } + if pairs.is_empty() { return usage_err("run needs [] pairs"); } - let pairs = args.pos.clone(); let mut b = backend(args, true)?; let mut results = vec![]; let mut i = 0; diff --git a/apps/effectcraft-cli/tests/cli.rs b/apps/effectcraft-cli/tests/cli.rs index 1111db403..538887d9b 100644 --- a/apps/effectcraft-cli/tests/cli.rs +++ b/apps/effectcraft-cli/tests/cli.rs @@ -110,6 +110,29 @@ fn exec_set_get_with_saved_project() { assert_eq!(v[1]["result"]["time"], v[0]["result"]["time"], "{v}"); } +/// `run --args-file` reads its command / params pairs from a file, one per line: a batch longer +/// than a Windows command line (32 767 characters, os error 206) still runs. +#[test] +fn run_reads_arguments_from_a_file() { + let proj = tmp("args-file.ecproj"); + let p = proj.to_str().unwrap(); + let mut lines = vec!["comp.new".to_string(), json!({"name": "A", "width": 64, "height": 64}).to_string()]; + for i in 0..400 { + lines.push("layer.newSolid".into()); + lines.push(json!({"width": 8, "height": 8, "name": format!("Solid with a long name to pass the limit {i:04}")}).to_string()); + lines.push(String::new()); + } + let text = lines.join("\n"); + assert!(text.len() > 32_767); + let file = tmp("args-file.txt"); + std::fs::write(&file, text).unwrap(); + let v = ok_json(&["run", "--args-file", file.to_str().unwrap(), "--empty", "--save-as", p]); + assert_eq!(v["result"].as_array().map(Vec::len), Some(401), "{v}"); + let out = bin().args(["run", "--args-file", "no/such/file.txt", "--empty"]).output().unwrap(); + assert_eq!(out.status.code(), Some(2)); + assert!(String::from_utf8_lossy(&out.stderr).contains("--args-file no/such/file.txt")); +} + #[test] fn render_frame_writes_png() { let out = tmp("f.png"); diff --git a/xtask/src/bench.rs b/xtask/src/bench.rs index b368d942f..6c89672aa 100644 --- a/xtask/src/bench.rs +++ b/xtask/src/bench.rs @@ -427,15 +427,18 @@ fn footage_cmds(seq_first: &Path, clip: &Path, still: &Path) -> Cmds { c } -/// Run `cmds` with `effectcraft-cli --empty run … --save-as out`. +/// Run `cmds` with `effectcraft-cli --empty run --args-file … --save-as out`: a file, because the +/// `layers` scenario is longer than a Windows command line (32 767 characters). fn build_project(bin: &Path, cmds: &Cmds, out: &Path) -> Result<(), String> { - let mut cmd = Command::new(bin); - cmd.arg("--empty").arg("run"); + let mut lines = String::new(); for (id, params) in cmds { - cmd.arg(id).arg(params.to_string()); + lines.push_str(&format!("{id}\n{params}\n")); } - cmd.arg("--save-as").arg(out); - cli(&mut cmd) + let args = out.with_extension("args"); + std::fs::write(&args, lines).map_err(|e| format!("{}: {e}", args.display()))?; + let r = cli(Command::new(bin).arg("--empty").arg("run").arg("--args-file").arg(&args).arg("--save-as").arg(out)); + let _ = std::fs::remove_file(&args); + r } /// Run a CLI command quietly; on failure, the tail of its output is the error. From e551cbe2cab89ea745677aac3fbb2e0066e84abd Mon Sep 17 00:00:00 2001 From: TimotheusG Date: Mon, 19 Oct 2026 18:26:34 -0400 Subject: [PATCH 22/46] test(render): use controlled costs for Auto backend checks --- crates/render/src/tests_auto.rs | 64 ++++++++++++++++++--------------- 1 file changed, 36 insertions(+), 28 deletions(-) diff --git a/crates/render/src/tests_auto.rs b/crates/render/src/tests_auto.rs index c34321cea..1e23254c0 100644 --- a/crates/render/src/tests_auto.rs +++ b/crates/render/src/tests_auto.rs @@ -9,31 +9,30 @@ use effectcraft_time::{FrameRate, Tick}; use crate::{Accelerator, AutoKey, AutoPick, Backend, NoFootage, RenderOpts, Renderer}; -/// An accelerator that takes `delay` per frame (or declines with `handle = false`) and keeps -/// Auto timing history. -struct TimedAccel { - delay: std::time::Duration, +/// An accelerator that returns a representative pixel (or declines) and keeps Auto history. +struct MockAccel { handle: bool, calls: AtomicUsize, pick: AutoPick, } -impl TimedAccel { - fn new(ms: u64, handle: bool) -> TimedAccel { - TimedAccel { delay: std::time::Duration::from_millis(ms), handle, calls: Default::default(), pick: Default::default() } +impl MockAccel { + /// Construct a renderer stand-in without making assumptions about scheduling or speed. + fn new(handle: bool) -> MockAccel { + MockAccel { handle, calls: Default::default(), pick: Default::default() } } fn calls(&self) -> usize { self.calls.load(Ordering::SeqCst) } } -impl Accelerator for TimedAccel { +impl Accelerator for MockAccel { fn name(&self) -> String { "timed".into() } + /// Count dispatched frames and return the same centre pixel as the CPU fixture. fn comp_frame(&self, _r: &Renderer, _comp: ItemId, _t: Tick) -> Option { self.calls.fetch_add(1, Ordering::SeqCst); - std::thread::sleep(self.delay); // The centre of the comp, as the CPU draws it (a red solid). self.handle.then(|| { let mut img = crate::Image::new(200, 100); @@ -64,7 +63,7 @@ fn project() -> (Project, ItemId) { } /// Render `n` frames with `backend`; how many the accelerator was asked for. -fn frames(p: &Project, cid: ItemId, a: &TimedAccel, backend: Backend, n: usize) -> usize { +fn frames(p: &Project, cid: ItemId, a: &MockAccel, backend: Backend, n: usize) -> usize { let before = a.calls(); for _ in 0..n { let mut r = Renderer::new(p, &NoFootage, RenderOpts { backend, ..Default::default() }); @@ -76,44 +75,53 @@ fn frames(p: &Project, cid: ItemId, a: &TimedAccel, backend: Backend, n: usize) a.calls() - before } +/// Drive the production selector with measured costs supplied by the test, not the OS clock. +fn measured_frames(pick: &AutoPick, key: AutoKey, cpu_ms: f64, gpu_ms: f64, n: usize) -> usize { + (0..n) + .filter(|_| { + let gpu = pick.choose(key); + pick.record(key, gpu, if gpu { gpu_ms } else { cpu_ms }); + gpu + }) + .count() +} + +/// A slower GPU is periodically re-probed; explicit GPU and Software Only ignore that history. #[test] fn auto_backend_moves_light_comps_to_the_cpu() { - // A light comp on a slow accelerator: after the warm-up (three frames on - // each side) Auto renders on the CPU, re-measuring the accelerator now and then. let (p, cid) = project(); - // Several times the CPU's frame time (unoptimised test builds are slow). - let t0 = std::time::Instant::now(); - for _ in 0..3 { - Renderer::new(&p, &NoFootage, RenderOpts::default()).comp_frame(cid, Tick::ZERO); - } - let cpu_ms = t0.elapsed().as_millis() as u64 / 3; - let slow = TimedAccel::new((cpu_ms * 5).max(20), true); + let slow = MockAccel::new(true); + let key = AutoKey::new(cid, 1.0, false); let n = 6 + 2 * AutoPick::REPROBE as usize; - let asked = frames(&p, cid, &slow, Backend::Auto, n); + let asked = measured_frames(&slow.pick, key, 1.0, 20.0, n); assert!((3..=5).contains(&asked), "asked the slow accelerator {asked} times in {n} frames"); - let s = slow.pick.stats(AutoKey::new(cid, 1.0, false)).unwrap(); + let s = slow.pick.stats(key).unwrap(); assert!(s.gpu_ms.unwrap() > s.cpu_ms.unwrap(), "{s:?}"); - // Backend::Gpu ignores the history; Software Only never asks. + // Check renderer wiring with known history before a real measurement can change it. + assert_eq!(frames(&p, cid, &slow, Backend::Auto, 1), 0); assert_eq!(frames(&p, cid, &slow, Backend::Gpu, 3), 3); let mut sw = p.clone(); sw.settings.gpu_acceleration = false; assert_eq!(frames(&sw, cid, &slow, Backend::Auto, 3), 0); } +/// Faster, tied and slightly noisier GPU costs stay on the GPU except during warm-up/probes. #[test] fn auto_backend_keeps_fast_accelerators() { - // As fast as the CPU (ties stay on the accelerator): every frame but the CPU warm-up and - // re-probes. let (p, cid) = project(); - let fast = TimedAccel::new(0, true); - let asked = frames(&p, cid, &fast, Backend::Auto, 40); - assert!(asked >= 36, "asked {asked} of 40"); + for gpu_ms in [1.0, 5.0, 5.5] { + let fast = MockAccel::new(true); + let asked = measured_frames(&fast.pick, AutoKey::new(cid, 1.0, false), 5.0, gpu_ms, 40); + assert!(asked >= 36, "asked {asked} of 40 for GPU cost {gpu_ms}"); + assert_eq!(frames(&p, cid, &fast, Backend::Auto, 1), 1); + } } +/// A declining accelerator is retried only periodically while the CPU supplies frames. #[test] fn auto_backend_falls_back_when_the_accelerator_declines() { let (p, cid) = project(); - let declining = TimedAccel::new(0, false); + let declining = MockAccel::new(false); let n = 2 * AutoPick::REPROBE as usize; let asked = frames(&p, cid, &declining, Backend::Auto, n); // Asked first, then only on the periodic re-probes. From c4505d34de24749a7784bac6f88c964e24a9d6cd Mon Sep 17 00:00:00 2001 From: TimotheusG Date: Mon, 19 Oct 2026 18:47:51 -0400 Subject: [PATCH 23/46] test(render): document automatic backend fixtures --- crates/render/src/tests_auto.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/render/src/tests_auto.rs b/crates/render/src/tests_auto.rs index 1e23254c0..22f1a6215 100644 --- a/crates/render/src/tests_auto.rs +++ b/crates/render/src/tests_auto.rs @@ -21,6 +21,7 @@ impl MockAccel { fn new(handle: bool) -> MockAccel { MockAccel { handle, calls: Default::default(), pick: Default::default() } } + /// Number of full-frame requests dispatched to this accelerator. fn calls(&self) -> usize { self.calls.load(Ordering::SeqCst) } @@ -51,6 +52,7 @@ impl Accelerator for MockAccel { } } +/// A small red solid whose centre pixel is shared by the CPU and mock GPU fixtures. fn project() -> (Project, ItemId) { let mut p = Project::default(); p.settings.gpu_acceleration = true; @@ -128,6 +130,7 @@ fn auto_backend_falls_back_when_the_accelerator_declines() { assert!((1..=4).contains(&asked), "asked {asked} times in {n} frames"); } +/// Periodic probes detect a formerly slow GPU becoming faster, without mixing cache keys. #[test] fn auto_pick_follows_changing_costs() { let pick = AutoPick::default(); From 915ba28a9d4a4734a314f887a7148a069ab7b0ec Mon Sep 17 00:00:00 2001 From: ded Date: Wed, 21 Oct 2026 10:35:47 +0300 Subject: [PATCH 24/46] Motion Tile's Output size works on adjustment layers (#1727) On an adjustment layer Motion Tile kept the comp-sized buffer whatever the Output Width / Height, and the adjustment draw dropped any stack result whose size differed from the canvas. After Effects grows (or crops) the comp rectangle there as on any layer, so a Transform after it brings the extra tiles into view, as in the frames attached to #1727 and #1409. - motion_tile_frame: one output frame for the CPU and GPU effect; on an adjustment it grows about the comp rectangle on whole pixels, keeps the buffer at 100 % and past 16 384 pixels a side. - adjustment_crop: the CPU and GPU adjustment draws put a stack result whose frame changed back onto the canvas instead of dropping it (Enc::crop on the GPU). Tests: the #1727 After Effects frames (910 x 340 % and 90 x 340 % then Scale 12 %), tiles past the frame and cropping on CPU, GPU vs CPU for adjustment and full-frame layers. Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/lib.rs | 37 ++++++ crates/effects/src/stylize2.rs | 8 +- crates/gpu/src/context.rs | 18 +++ crates/gpu/src/fx_stylize.rs | 8 +- crates/gpu/src/tests_fx_stylize.rs | 41 ++++++ crates/gpu/src/walk.rs | 6 +- crates/render/src/lib.rs | 8 ++ crates/render/src/tests_motion_tile.rs | 169 +++++++++++++++++++++++++ 8 files changed, 280 insertions(+), 15 deletions(-) create mode 100644 crates/render/src/tests_motion_tile.rs diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 40ed79a7d..aa11c4218 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -297,6 +297,43 @@ impl Buf { } } +/// Motion Tile's output frame for a buffer with layer origin `offset` (pixels) at `scale`: +/// `(width, height, offset)`. Output Width / Height grow (or crop) the layer rectangle about its +/// centre, on adjustment layers too, where the rectangle is the comp's and starts at the comp +/// origin in the buffer below (After Effects; a Transform after it brings the tiles into view, +/// #1727). An adjustment at 100 % keeps the buffer it was given; one whose frame would pass +/// 16 384 pixels a side does too. +pub fn motion_tile_frame(ctx: &EffectCtx, offset: [f64; 2], size: (u32, u32), scale: f64) -> (u32, u32, [f64; 2]) { + let (lw, lh) = (ctx.layer_size[0], ctx.layer_size[1]); + let (pw, ph) = (ctx.params.f("outputWidth"), ctx.params.f("outputHeight")); + let ow = (lw * pw / 100.0).max(1.0 / scale); + let oh = (lh * ph / 100.0).max(1.0 / scale); + let (nw, nh) = ((ow * scale).round().max(1.0), (oh * scale).round().max(1.0)); + let grow = [(ow - lw) * 0.5 * scale, (oh - lh) * 0.5 * scale]; + if !ctx.adjustment { + return (nw as u32, nh as u32, grow); + } + // The grown rectangle's top-left in the buffer below, on whole pixels so the result lines + // up with the comp again when the stack ends. + let (x0, y0) = ((offset[0] - grow[0]).round(), (offset[1] - grow[1]).round()); + let fits = [x0, y0, nw, nh].iter().all(|v| v.is_finite()) && nw <= 16384.0 && nh <= 16384.0; + if (pw == 100.0 && ph == 100.0) || !fits { + return (size.0, size.1, offset); + } + (nw as u32, nh as u32, [offset[0] - x0, offset[1] - y0]) +} + +/// Where an adjustment stack's result (comp origin at `offset`, `size` pixels) lies on the canvas +/// it came from (comp origin at `canvas_offset`): the result pixel at canvas pixel (0, 0). +/// `None` when it already is the canvas, frame for frame. +pub fn adjustment_crop(offset: [f64; 2], canvas_offset: [f64; 2], size: (u32, u32), canvas: (u32, u32)) -> Option<(i64, i64)> { + let (dx, dy) = ((offset[0] - canvas_offset[0]).round(), (offset[1] - canvas_offset[1]).round()); + if !dx.is_finite() || !dy.is_finite() || (dx == 0.0 && dy == 0.0 && size == canvas) { + return None; + } + Some((dx as i64, dy as i64)) +} + /// Where [`Buf::cover`] would take a buffer: shift its pixels by `(dx, dy)` and resize it to /// `width` × `height` so it spans the `size`-layer-pixel region that starts at its offset — the /// whole layer rectangle, which a sized source's buffer already is. `None` when the buffer diff --git a/crates/effects/src/stylize2.rs b/crates/effects/src/stylize2.rs index b957b4e41..e0354ea75 100644 --- a/crates/effects/src/stylize2.rs +++ b/crates/effects/src/stylize2.rs @@ -433,13 +433,7 @@ fn motion_tile(ctx: &EffectCtx, b: Buf) -> Buf { let mirror = ctx.params.b("mirrorEdges"); let phase = ctx.params.f("phase") / 360.0; let hshift = ctx.params.b("horizontalPhaseShift"); - let (nw, nh, noff) = if ctx.adjustment { - (b.img.width, b.img.height, b.offset) - } else { - let ow = (lw * ctx.params.f("outputWidth") / 100.0).max(1.0 / s); - let oh = (lh * ctx.params.f("outputHeight") / 100.0).max(1.0 / s); - ((ow * s).round().max(1.0) as u32, (oh * s).round().max(1.0) as u32, [(ow - lw) * 0.5 * s, (oh - lh) * 0.5 * s]) - }; + let (nw, nh, noff) = crate::motion_tile_frame(ctx, b.offset, (b.img.width, b.img.height), s); let mut out = Image::new(nw, nh); let src = &b.img; out.rows_mut().for_each(|(y, row)| { diff --git a/crates/gpu/src/context.rs b/crates/gpu/src/context.rs index 7229aa5ad..7f34d4b75 100644 --- a/crates/gpu/src/context.rs +++ b/crates/gpu/src/context.rs @@ -1246,6 +1246,24 @@ impl<'g> Enc<'g> { ); } + /// A `w` × `h` image showing `src` with its pixel (`x`, `y`) at the top-left, transparent + /// where `src` doesn't reach (Image::crop). + pub fn crop(&mut self, src: &GpuImage, x: i64, y: i64, w: u32, h: u32) -> GpuImage { + let dst = self.image(w, h); + let span = |o: i64, n: u32, len: u32| { + let (a, b) = (o.max(0), (o + n as i64).min(len as i64)); + (b > a).then(|| (a as u32, (a - o) as u32, (b - a) as u32)) + }; + let (Some((sx, dx, cw)), Some((sy, dy, ch))) = (span(x, w, src.width), span(y, h, src.height)) else { return dst }; + let enc = self.encoder(); + enc.copy_texture_to_texture( + wgpu::TexelCopyTextureInfo { texture: &src.texture, mip_level: 0, origin: wgpu::Origin3d { x: sx, y: sy, z: 0 }, aspect: wgpu::TextureAspect::All }, + wgpu::TexelCopyTextureInfo { texture: &dst.texture, mip_level: 0, origin: wgpu::Origin3d { x: dx, y: dy, z: 0 }, aspect: wgpu::TextureAspect::All }, + wgpu::Extent3d { width: cw, height: ch, depth_or_array_layers: 1 }, + ); + dst + } + /// Submit recorded work. pub fn submit(&mut self) { if let Some((enc, id)) = self.enc.take() { diff --git a/crates/gpu/src/fx_stylize.rs b/crates/gpu/src/fx_stylize.rs index f5b7708d9..d53367e76 100644 --- a/crates/gpu/src/fx_stylize.rs +++ b/crates/gpu/src/fx_stylize.rs @@ -637,13 +637,7 @@ fn motion_tile(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { let mirror = ctx.params.b("mirrorEdges"); let phase = ctx.params.f("phase") / 360.0; let hshift = ctx.params.b("horizontalPhaseShift"); - let (nw, nh, noff) = if ctx.adjustment { - (b.img.width, b.img.height, b.offset) - } else { - let ow = (lw * ctx.params.f("outputWidth") / 100.0).max(1.0 / s); - let oh = (lh * ctx.params.f("outputHeight") / 100.0).max(1.0 / s); - ((ow * s).round().max(1.0) as u32, (oh * s).round().max(1.0) as u32, [(ow - lw) * 0.5 * s, (oh - lh) * 0.5 * s]) - }; + let (nw, nh, noff) = effectcraft_effects::motion_tile_frame(ctx, b.offset, (b.img.width, b.img.height), s); if !e.g.fits(nw, nh) { return None; } diff --git a/crates/gpu/src/tests_fx_stylize.rs b/crates/gpu/src/tests_fx_stylize.rs index 44e156aec..0829fa3f4 100644 --- a/crates/gpu/src/tests_fx_stylize.rs +++ b/crates/gpu/src/tests_fx_stylize.rs @@ -88,6 +88,8 @@ fn tiles_kaleida_repetile() { "ec.stylize.motiontile", &[("horizontalPhaseShift", on()), ("phase", n(33.0)), ("tileCenter", pt(30.0, 17.0)), ("tileHeight", n(55.0)), ("outputWidth", n(80.0))], ); + // Output under 100 % on an adjustment crops the comp below (#1727). + effect_case("ec.stylize.motiontile", &[("outputWidth", n(60.0)), ("outputHeight", n(300.0)), ("mirrorEdges", on())]); effect_case("ec.stylize.cckaleida", &[]); effect_case("ec.stylize.cckaleida", &[("mirroring", e(2)), ("rotation", n(20.0)), ("size", n(150.0))]); effect_case("ec.stylize.ccrepetile", &[("expandRight", n(10.0)), ("expandUp", n(4.0))]); @@ -224,3 +226,42 @@ fn transform_motion_blur() { check(&format!("{label} half"), compare_at(&s, RenderOpts { scale: 0.5, ..opts() }, Tick::from_seconds_f64(0.3)), 0.0); } } + +/// Motion Tile's Output size on an adjustment layer with a Transform after it that scales the +/// tiles into view, and cropping (Output under 100 %): the stack's grown or cropped frame goes +/// back onto the canvas as on the CPU (#1727). +#[test] +fn adjustment_motion_tile_output_then_transform() { + for (out_w, out_h, scale) in [(300.0, 300.0, 40.0), (250.0, 120.0, 70.0), (60.0, 80.0, 100.0)] { + for depth in [BitDepth::Bpc8, BitDepth::Bpc32] { + let mut s = Scene::new(depth); + let bg = s.footage(97, 61); + s.push(bg); + let mut adj = s.solid([1.0, 1.0, 1.0], 97, 61); + adj.switches.adjustment = true; + s.effect(&mut adj, "ec.stylize.motiontile", &[("outputWidth", n(out_w)), ("outputHeight", n(out_h)), ("mirrorEdges", on())]); + s.effect(&mut adj, "ec.distort.transform", &[("scaleHeight", n(scale)), ("scaleWidth", n(scale))]); + s.push(adj); + let label = format!("adjustment Motion Tile {out_w}×{out_h} % then Scale {scale} % {depth:?}"); + check(&label, compare_at(&s, opts(), Tick::ZERO), 0.0); + check(&format!("{label} half"), compare_at(&s, RenderOpts { scale: 0.5, ..opts() }, Tick::ZERO), 0.0); + } + } +} + +/// The same on a full-frame footage layer (#1991). +#[test] +fn layer_motion_tile_output_then_transform() { + for (out_w, out_h, scale) in [(300.0, 300.0, 40.0), (250.0, 120.0, 70.0)] { + for depth in [BitDepth::Bpc8, BitDepth::Bpc32] { + let mut s = Scene::new(depth); + let mut l = s.footage(97, 61); + s.effect(&mut l, "ec.stylize.motiontile", &[("outputWidth", n(out_w)), ("outputHeight", n(out_h)), ("mirrorEdges", on())]); + s.effect(&mut l, "ec.distort.transform", &[("scaleHeight", n(scale)), ("scaleWidth", n(scale))]); + s.push(l); + let label = format!("layer Motion Tile {out_w}×{out_h} % then Scale {scale} % {depth:?}"); + check(&label, compare_at(&s, opts(), Tick::ZERO), 0.0); + check(&format!("{label} half"), compare_at(&s, RenderOpts { scale: 0.5, ..opts() }, Tick::ZERO), 0.0); + } + } +} diff --git a/crates/gpu/src/walk.rs b/crates/gpu/src/walk.rs index b1209070a..81f30d4f5 100644 --- a/crates/gpu/src/walk.rs +++ b/crates/gpu/src/walk.rs @@ -357,7 +357,11 @@ fn draw_adjustment(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, layer: &Layer, canv }; let mut fx = crate::effects::GpuFx::new(e, below, [0.0; 2], r.opts.scale, pipe.levels); r.run_effects_on(ctx, layer, true, &mut fx); - let (mut adjusted, _, _) = fx.finish()?; + let (mut adjusted, offset, _) = fx.finish()?; + // An effect that changed the frame (Motion Tile's Output size): back onto the canvas. + if let Some((dx, dy)) = effectcraft_effects::adjustment_crop(offset, [0.0; 2], (adjusted.width, adjusted.height), (w, h)) { + adjusted = e.crop(&adjusted, dx, dy, w, h); + } if adjusted.width != w || adjusted.height != h { return Some(()); } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index eccaed2f4..7897cb581 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -2043,6 +2043,12 @@ impl<'a> Renderer<'a> { color::convert(&mut below.img, &c); } let mut adjusted = self.apply_effects(ctx, layer, below, true); + // An effect that changed the frame (Motion Tile's Output size): back onto the canvas. + if let Some((dx, dy)) = + effectcraft_effects::adjustment_crop(adjusted.offset, o, (adjusted.img.width, adjusted.img.height), (canvas.width, canvas.height)) + { + adjusted.img = adjusted.img.crop(dx, dy, canvas.width, canvas.height); + } if let Some(c) = self.pipe.to_blend() { color::convert(&mut adjusted.img, &c); } @@ -2420,6 +2426,8 @@ mod tests_mask_blur; #[cfg(test)] mod tests_mask_expansion; #[cfg(test)] +mod tests_motion_tile; +#[cfg(test)] mod tests_nested; #[cfg(test)] mod tests_paint; diff --git a/crates/render/src/tests_motion_tile.rs b/crates/render/src/tests_motion_tile.rs new file mode 100644 index 000000000..7d1645a02 --- /dev/null +++ b/crates/render/src/tests_motion_tile.rs @@ -0,0 +1,169 @@ +//! Motion Tile's Output Width / Height on adjustment layers and on full-frame layers, with a +//! Transform after it that scales the tiled result into view (#1727, #1991). + +use effectcraft_color::Label; +use effectcraft_keyframe::Value; +use effectcraft_project::build::{self, Ids}; +use effectcraft_project::{Comp, ItemId, ItemKind, Layer, LayerSource, Project, Solid}; +use effectcraft_time::{FrameRate, Tick}; + +use crate::{Image, render_frame}; + +const W: u32 = 200; +const H: u32 = 100; + +fn solid(p: &mut Project, comp: &Comp, color: [f32; 3], w: u32, h: u32, pos: [f64; 2]) -> Layer { + let sid = p.add_item("Solid", Label::Red, None, ItemKind::Solid(Solid { color, width: w, height: h, pixel_aspect: 1.0 })); + let mut l = build::layer(p, comp, "Solid", LayerSource::Solid { item: sid }, (w, h), None); + l.props.prop_mut("transform/position").unwrap().value = Value::Vec3([pos[0], pos[1], 0.0]); + l +} + +/// A white frame with a red 20×20 square in its top-left corner. +fn picture(p: &mut Project, comp: &Comp) -> Vec { + vec![solid(p, comp, [1.0, 0.0, 0.0], 20, 20, [10.0, 10.0]), solid(p, comp, [1.0, 1.0, 1.0], W, H, [100.0, 50.0])] +} + +/// Motion Tile (Output `out` %) then Transform (Scale `scale` %), on `l`. +fn tile_then_scale(p: &mut Project, l: &mut Layer, out: f64, scale: f64) { + let size = [W as f64, H as f64]; + for (id, vals) in [ + ("ec.stylize.motiontile", vec![("outputWidth", Value::Scalar(out)), ("outputHeight", Value::Scalar(out))]), + ("ec.distort.transform", vec![("scaleHeight", Value::Scalar(scale)), ("scaleWidth", Value::Scalar(scale))]), + ] { + let spec = effectcraft_effects::find(id).unwrap(); + let mut next = p.next_id; + let mut g = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), spec.name, size); + p.next_id = next; + for (k, v) in vals { + g.prop_mut(k).unwrap().value = v; + } + l.props.sub_mut("effects").unwrap().children.push(g.into()); + } +} + +fn comp() -> (Project, ItemId, Comp) { + let mut p = Project::default(); + p.settings.bit_depth = effectcraft_project::BitDepth::Bpc32; + let comp = Comp::new(W, H, FrameRate::FPS_30, Tick::from_seconds_f64(1.0)); + let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into())); + (p, cid, comp) +} + +/// The picture under an adjustment layer carrying the two effects. +fn adjustment_scene(out: f64, scale: f64) -> (Project, ItemId) { + let (mut p, cid, comp) = comp(); + let mut adj = solid(&mut p, &comp, [1.0, 1.0, 1.0], W, H, [100.0, 50.0]); + adj.switches.adjustment = true; + tile_then_scale(&mut p, &mut adj, out, scale); + let mut layers = vec![adj]; + layers.extend(picture(&mut p, &comp)); + p.comp_mut(cid).unwrap().layers = layers; + (p, cid) +} + +/// The picture as a full-frame precomp layer carrying the two effects. +fn layer_scene(out: f64, scale: f64) -> (Project, ItemId) { + let (mut p, cid, comp) = comp(); + let inner = Comp::new(W, H, FrameRate::FPS_30, Tick::from_seconds_f64(1.0)); + let iid = p.add_item("Picture", Label::Sandstone, None, ItemKind::Comp(inner.clone().into())); + let layers = picture(&mut p, &inner); + p.comp_mut(iid).unwrap().layers = layers; + let mut pre = build::layer(&mut p, &comp, "Picture", LayerSource::Comp { item: iid }, (W, H), None); + tile_then_scale(&mut p, &mut pre, out, scale); + p.comp_mut(cid).unwrap().layers.push(pre); + (p, cid) +} + +fn is(img: &Image, x: i64, y: i64, want: [f32; 4], what: &str) { + let got = img.get(x, y); + assert!(got.iter().zip(want).all(|(a, b)| (a - b).abs() < 0.02), "{what}: ({x}, {y}) is {got:?}, want {want:?}"); +} + +const RED: [f32; 4] = [1.0, 0.0, 0.0, 1.0]; +const WHITE: [f32; 4] = [1.0; 4]; +const CLEAR: [f32; 4] = [0.0; 4]; + +/// Output 300 % then Scale 50 % about the frame centre: comp pixel (x, y) shows the tiled frame +/// at (100 + 2(x − 100), 50 + 2(y − 50)), so the red square repeats every 100 × 50 pixels and +/// the frame is covered edge to edge, the tiles from beyond the frame included. +fn assert_tiled(img: &Image, what: &str) { + for (x, y) in [(55, 30), (155, 30), (55, 80), (155, 80)] { + is(img, x, y, RED, what); + } + for (x, y) in [(5, 5), (195, 5), (5, 95), (195, 95), (100, 50), (100, 5)] { + is(img, x, y, WHITE, what); + } +} + +#[test] +fn adjustment_motion_tile_output_spreads_tiles_past_the_frame() { + let (p, cid) = adjustment_scene(300.0, 50.0); + assert_tiled(&render_frame(&p, cid, Tick::ZERO, 1.0), "adjustment, Output 300 %"); +} + +#[test] +fn layer_motion_tile_output_spreads_tiles_past_the_frame() { + let (p, cid) = layer_scene(300.0, 50.0); + assert_tiled(&render_frame(&p, cid, Tick::ZERO, 1.0), "full-frame layer, Output 300 %"); +} + +/// Output under 100 % crops: only the centre 50 % of the frame is left, the rest is transparent +/// (After Effects shows the comp background there). +#[test] +fn adjustment_motion_tile_output_under_100_crops_the_frame() { + let (p, cid) = adjustment_scene(50.0, 100.0); + let img = render_frame(&p, cid, Tick::ZERO, 1.0); + is(&img, 100, 50, WHITE, "inside the kept centre"); + is(&img, 60, 30, WHITE, "inside the kept centre"); + for (x, y) in [(5, 5), (10, 10), (195, 95), (45, 50), (100, 20)] { + is(&img, x, y, CLEAR, "outside the kept centre"); + } +} + +/// The After Effects 2026 frames in #1727: a 1920×1080 red solid under an adjustment layer with +/// Motion Tile (Output 910 × 340 %, then 90 × 340 %) and Transform (Scale 12 %, Position 1361, 540). +/// The tiled frame, 9.1 × 3.4 comps scaled to 12 %, is a 2097 × 441 band centred on (1361, 540): +/// x 313–1920 (cut by the frame), y 320–760; at 90 % a 207-pixel column, x 1257–1465. +#[test] +fn adjustment_motion_tile_matches_the_after_effects_frames() { + for (out_w, x0, x1) in [(910.0, 313.0, 1920.0), (90.0, 1257.0, 1465.0)] { + let mut p = Project::default(); + p.settings.bit_depth = effectcraft_project::BitDepth::Bpc32; + let comp = Comp::new(1920, 1080, FrameRate::FPS_30, Tick::from_seconds_f64(1.0)); + let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into())); + let red = solid(&mut p, &comp, [1.0, 0.0, 0.0], 1920, 1080, [960.0, 540.0]); + let mut adj = solid(&mut p, &comp, [1.0, 1.0, 1.0], 1920, 1080, [960.0, 540.0]); + adj.switches.adjustment = true; + for (id, vals) in [ + ("ec.stylize.motiontile", vec![("outputWidth", Value::Scalar(out_w)), ("outputHeight", Value::Scalar(340.0))]), + ( + "ec.distort.transform", + vec![("scaleHeight", Value::Scalar(12.0)), ("scaleWidth", Value::Scalar(12.0)), ("position", Value::Vec2([1361.0, 540.0]))], + ), + ] { + let spec = effectcraft_effects::find(id).unwrap(); + let mut next = p.next_id; + let mut g = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), spec.name, [1920.0, 1080.0]); + p.next_id = next; + for (k, v) in vals { + g.prop_mut(k).unwrap().value = v; + } + adj.props.sub_mut("effects").unwrap().children.push(g.into()); + } + p.comp_mut(cid).unwrap().layers = vec![adj, red]; + // Quarter resolution: comp pixel (x, y) is output pixel (x / 4, y / 4). + let img = render_frame(&p, cid, Tick::ZERO, 0.25); + let at = |x: f64, y: f64| img.get((x / 4.0) as i64, (y / 4.0) as i64); + let what = format!("Output {out_w} %"); + for (x, y) in [(x0 + 8.0, 540.0), (x1 - 8.0, 540.0), (1361.0, 328.0), (1361.0, 752.0)] { + assert_eq!(at(x, y), RED, "{what}: ({x}, {y}) is red"); + } + for (x, y) in [(x0 - 8.0, 540.0), (1361.0, 312.0), (1361.0, 768.0), (100.0, 100.0)] { + assert_eq!(at(x, y), CLEAR, "{what}: ({x}, {y}) is empty"); + } + if x1 < 1920.0 { + assert_eq!(at(x1 + 8.0, 540.0), CLEAR, "{what}: right of the column"); + } + } +} From f14a57fb3e49162e0710f1b9d1fc46cc6c2685c3 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Wed, 21 Oct 2026 13:31:58 -0400 Subject: [PATCH 25/46] 3D: Layer > New > 3D Primitive... dialog for a primitive's type, name and dimensions (#1734) OK runs layer.newPrimitive, which switches a Classic 3D comp to Advanced 3D in the same undo step. The dimension fields are the Geometry Options the engine builds for the chosen type. --- crates/engine/src/menus.rs | 1 + crates/ui-egui/src/i18n.rs | 10 ++ crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/menus.rs | 4 + crates/ui-egui/src/panels/dialogs.rs | 2 + crates/ui-egui/src/panels/mod.rs | 1 + crates/ui-egui/src/panels/primitive_dialog.rs | 109 ++++++++++++++++++ 7 files changed, 129 insertions(+) create mode 100644 crates/ui-egui/src/panels/primitive_dialog.rs diff --git a/crates/engine/src/menus.rs b/crates/engine/src/menus.rs index bc486a0c5..aa2e4ffe1 100644 --- a/crates/engine/src/menus.rs +++ b/crates/engine/src/menus.rs @@ -733,6 +733,7 @@ Layer Shape Layer | layer.newShape Adjustment Layer | layer.newAdjustment Content-Aware Fill Layer... | layer.newContentAwareFill + 3D Primitive... | layer.newPrimitive Cube | layer.new3dPrimitive {"kind":"cube"} Sphere | layer.new3dPrimitive {"kind":"sphere"} Plane | layer.new3dPrimitive {"kind":"plane"} diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 8b8b01b51..5bad9cdd5 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -505,6 +505,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str, &str)] = &[ ("layer.newShape", "Shape Layer", "形状图层"), ("layer.newAdjustment", "Adjustment Layer", "调整图层"), ("layer.newContentAwareFill", "Content-Aware Fill Layer...", "内容识别填充图层..."), + ("layer.newPrimitive", "3D Primitive...", "3D 基本体..."), ("layer.new3dPrimitive", "Cube", "立方体"), ("layer.new3dPrimitive", "Sphere", "球体"), ("layer.new3dPrimitive", "Plane", "平面"), @@ -1139,6 +1140,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str, &str)] = &[ ("layer.newShape", "Shape Layer", "形狀圖層"), ("layer.newAdjustment", "Adjustment Layer", "調整圖層"), ("layer.newContentAwareFill", "Content-Aware Fill Layer...", "內容感知填滿圖層..."), + ("layer.newPrimitive", "3D Primitive...", "3D 基本體..."), ("layer.new3dPrimitive", "Cube", "立方體"), ("layer.new3dPrimitive", "Sphere", "球體"), ("layer.new3dPrimitive", "Plane", "平面"), @@ -1770,6 +1772,7 @@ const JAPANESE: &[(&str, &str, &str)] = &[ ("layer.newShape", "Shape Layer", "シェイプレイヤー"), ("layer.newAdjustment", "Adjustment Layer", "調整レイヤー"), ("layer.newContentAwareFill", "Content-Aware Fill Layer...", "コンテンツに応じた塗りつぶしレイヤー..."), + ("layer.newPrimitive", "3D Primitive...", "3D プリミティブ..."), ("layer.new3dPrimitive", "Cube", "立方体"), ("layer.new3dPrimitive", "Sphere", "球体"), ("layer.new3dPrimitive", "Plane", "平面"), @@ -2404,6 +2407,7 @@ const UKRAINIAN: &[(&str, &str, &str)] = &[ ("layer.newShape", "Shape Layer", "Шар фігури"), ("layer.newAdjustment", "Adjustment Layer", "Коригувальний шар"), ("layer.newContentAwareFill", "Content-Aware Fill Layer...", "Шар заливки з урахуванням вмісту..."), + ("layer.newPrimitive", "3D Primitive...", "3D-примітив..."), ("layer.new3dPrimitive", "Cube", "Куб"), ("layer.new3dPrimitive", "Sphere", "Сфера"), ("layer.new3dPrimitive", "Plane", "Площина"), @@ -3039,6 +3043,7 @@ const SPANISH: &[(&str, &str, &str)] = &[ ("layer.newShape", "Shape Layer", "Capa de forma"), ("layer.newAdjustment", "Adjustment Layer", "Capa de ajuste"), ("layer.newContentAwareFill", "Content-Aware Fill Layer...", "Capa de relleno según el contenido..."), + ("layer.newPrimitive", "3D Primitive...", "Primitiva 3D..."), ("layer.new3dPrimitive", "Cube", "Cubo"), ("layer.new3dPrimitive", "Sphere", "Esfera"), ("layer.new3dPrimitive", "Plane", "Plano"), @@ -3672,6 +3677,7 @@ const PORTUGUESE: &[(&str, &str, &str)] = &[ ("layer.newShape", "Shape Layer", "Camada de forma"), ("layer.newAdjustment", "Adjustment Layer", "Camada de ajuste"), ("layer.newContentAwareFill", "Content-Aware Fill Layer...", "Camada de preenchimento sensível ao conteúdo..."), + ("layer.newPrimitive", "3D Primitive...", "Primitiva 3D..."), ("layer.new3dPrimitive", "Cube", "Cubo"), ("layer.new3dPrimitive", "Sphere", "Esfera"), ("layer.new3dPrimitive", "Plane", "Plano"), @@ -4312,6 +4318,7 @@ const RUSSIAN: &[(&str, &str, &str)] = &[ ("layer.newShape", "Shape Layer", "Слой-фигура"), ("layer.newAdjustment", "Adjustment Layer", "Корректирующий слой"), ("layer.newContentAwareFill", "Content-Aware Fill Layer...", "Слой заливки с учетом содержимого..."), + ("layer.newPrimitive", "3D Primitive...", "3D-примитив..."), ("layer.new3dPrimitive", "Cube", "Куб"), ("layer.new3dPrimitive", "Sphere", "Сфера"), ("layer.new3dPrimitive", "Plane", "Плоскость"), @@ -4945,6 +4952,7 @@ const FRENCH: &[(&str, &str, &str)] = &[ ("layer.newShape", "Shape Layer", "Calque de forme"), ("layer.newAdjustment", "Adjustment Layer", "Calque de réglage"), ("layer.newContentAwareFill", "Content-Aware Fill Layer...", "Calque de remplissage d’après le contenu..."), + ("layer.newPrimitive", "3D Primitive...", "Primitive 3D..."), ("layer.new3dPrimitive", "Cube", "Cube"), ("layer.new3dPrimitive", "Sphere", "Sphère"), ("layer.new3dPrimitive", "Plane", "Plan"), @@ -5578,6 +5586,7 @@ const GERMAN: &[(&str, &str, &str)] = &[ ("layer.newShape", "Shape Layer", "Formebene"), ("layer.newAdjustment", "Adjustment Layer", "Einstellungsebene"), ("layer.newContentAwareFill", "Content-Aware Fill Layer...", "Ebene für inhaltsbasierte Füllung..."), + ("layer.newPrimitive", "3D Primitive...", "3D-Grundkörper..."), ("layer.new3dPrimitive", "Cube", "Würfel"), ("layer.new3dPrimitive", "Sphere", "Kugel"), ("layer.new3dPrimitive", "Plane", "Fläche"), @@ -6212,6 +6221,7 @@ const CZECH: &[(&str, &str, &str)] = &[ ("layer.newShape", "Shape Layer", "Vrstva tvaru"), ("layer.newAdjustment", "Adjustment Layer", "Vrstva úprav"), ("layer.newContentAwareFill", "Content-Aware Fill Layer...", "Vrstva výplně podle obsahu..."), + ("layer.newPrimitive", "3D Primitive...", "3D primitivum..."), ("layer.new3dPrimitive", "Cube", "Krychle"), ("layer.new3dPrimitive", "Sphere", "Koule"), ("layer.new3dPrimitive", "Plane", "Rovina"), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 72016cc53..783f07798 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -72,6 +72,8 @@ pub enum Dialog { ViewOptions, CameraSettings, LightSettings, + /// Layer ▸ New ▸ 3D Primitive… (`panels::primitive_dialog`). + NewPrimitive, KeyVelocity, KeyInterpolation, TimeStretch, diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 92893dcb6..f04778bbe 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -399,6 +399,10 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V if crate::panels::dialogs_3d::route(app, id, ¶ms)? { return Ok(Value::Null); } + // New 3D Primitive without parameters opens its dialog. + if crate::panels::primitive_dialog::route(app, id, ¶ms)? { + return Ok(Value::Null); + } // Layer Settings on a solid, adjustment layer or null opens its settings dialog. if crate::panels::dialogs::route_layer_settings(app, id, ¶ms)? { return Ok(Value::Null); diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index d773fc433..f4413d147 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -47,6 +47,7 @@ pub struct DialogState { pub info: (String, String), pub camera: super::dialogs_3d::CameraDraft, pub light: super::dialogs_3d::LightDraft, + pub primitive: super::primitive_dialog::PrimitiveDraft, pub velocity: super::key_dialogs::VelocityDraft, pub interp: super::key_dialogs::InterpDraft, pub stretch: super::key_dialogs::StretchDraft, @@ -248,6 +249,7 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context) { Dialog::ViewOptions => super::forms::show_view_options(app, ctx, &t), Dialog::CameraSettings => super::dialogs_3d::camera(app, ctx, &t), Dialog::LightSettings => super::dialogs_3d::light(app, ctx, &t), + Dialog::NewPrimitive => super::primitive_dialog::dialog(app, ctx, &t), Dialog::KeyVelocity => super::key_dialogs::velocity(app, ctx, &t), Dialog::KeyInterpolation => super::key_dialogs::interpolation(app, ctx, &t), Dialog::TimeStretch => super::key_dialogs::time_stretch(app, ctx, &t), diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 3aa9a19c5..f65b39706 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -35,6 +35,7 @@ pub mod paint_panels; pub mod panel_kit; pub mod path_vr_panels; pub mod precomp; +pub mod primitive_dialog; pub mod project; pub mod properties; pub mod puppet_tool; diff --git a/crates/ui-egui/src/panels/primitive_dialog.rs b/crates/ui-egui/src/panels/primitive_dialog.rs new file mode 100644 index 000000000..7626984a3 --- /dev/null +++ b/crates/ui-egui/src/panels/primitive_dialog.rs @@ -0,0 +1,109 @@ +//! New 3D Primitive dialog (Layer ▸ New ▸ 3D Primitive…): the primitive's type, name and +//! dimensions. OK runs `layer.newPrimitive`, which switches a Classic 3D comp to Advanced 3D; +//! the layer's Geometry Options edit it afterwards. + +use std::collections::BTreeMap; + +use crate::i18n::tr; +use effectcraft_engine::project::build::{Ids, primitive_geometry_options}; +use effectcraft_engine::project::{ParamUi, PrimitiveKind, Property}; +use egui::{Color32, vec2}; +use serde_json::{Value, json}; + +use crate::theme::Tokens; +use crate::{Dialog, EffectcraftApp}; + +#[derive(Clone, Debug, Default)] +pub struct PrimitiveDraft { + pub kind: PrimitiveKind, + pub name: String, + /// Whether the name was typed; until then it follows the type. + pub named: bool, + /// Dimensions by Geometry Options match id, kept when the type changes (a sphere's radius + /// carries over to a cone). + pub dims: BTreeMap, +} + +/// The dimensions a primitive type has: its Geometry Options as the engine builds them. +fn dimensions(kind: PrimitiveKind) -> Vec { + let mut next = 0; + primitive_geometry_options(&mut Ids(&mut next), kind).props().cloned().collect() +} + +/// Open the dialog for a new primitive in the active comp. +pub fn open(app: &mut EffectcraftApp) -> Result<(), String> { + app.session.active_comp().ok_or("no composition is open")?; + let kind = PrimitiveKind::Cube; + app.dialog_state.primitive = PrimitiveDraft { kind, name: kind.label().to_string(), named: false, dims: BTreeMap::new() }; + app.dialog = Some(Dialog::NewPrimitive); + Ok(()) +} + +pub fn dialog(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { + let mut d = app.dialog_state.primitive.clone(); + let (mut ok, mut close) = (false, false); + super::dialogs::modal(ctx, "New 3D Primitive", vec2(420.0, 300.0), t, |ui| { + egui::Grid::new("primitive-grid").num_columns(2).spacing([14.0, 9.0]).show(ui, |ui| { + ui.label(tr("Type")); + let combo = egui::ComboBox::from_id_salt("primitive-type").selected_text(tr(d.kind.label())).show_ui(ui, |ui| { + for (i, k) in PrimitiveKind::ALL.into_iter().enumerate() { + let item = ui.selectable_value(&mut d.kind, k, tr(k.label())); + app.auto.add(&format!("dialog.primitive.typeItem.{i}"), item.rect, k.label()); + } + }); + app.auto.add_response("dialog.primitive.type", &combo.response, "Type"); + if !d.named { + d.name = d.kind.label().to_string(); + } + ui.end_row(); + ui.label(tr("Name")); + let r = ui.add(egui::TextEdit::singleline(&mut d.name).desired_width(220.0)); + d.named |= r.changed(); + app.auto.add_response("dialog.primitive.name", &r, "Name"); + ui.end_row(); + for p in dimensions(d.kind) { + let ParamUi::Slider { min, max, decimals, .. } = p.ui else { continue }; + let v = d.dims.entry(p.match_id.clone()).or_insert(p.value.as_f64()); + ui.label(tr(&p.name)); + // Counts (segments, rings) are whole numbers; sizes are pixels. + let suffix = if decimals == 0 { "" } else { " px" }; + let r = ui.add(crate::widgets::drag_value(egui::DragValue::new(v).range(min..=max).max_decimals(decimals as usize).suffix(suffix))); + app.auto.add_response(&format!("dialog.primitive.{}", p.match_id), &r, &p.name); + ui.end_row(); + } + }); + ui.add_space(14.0); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let b = ui.add(egui::Button::new(egui::RichText::new(tr(" OK ")).color(Color32::WHITE)).fill(t.accent)); + app.auto.add("dialog.primitive.ok", b.rect, "OK"); + ok |= b.clicked(); + let c = ui.button(tr("Cancel")); + app.auto.add("dialog.primitive.cancel", c.rect, "Cancel"); + close |= c.clicked(); + }); + }); + app.dialog_state.primitive = d.clone(); + if ok || super::dialogs::ui_enter(ctx) { + let mut p = json!({"kind": d.kind.label().to_ascii_lowercase(), "name": d.name}); + for dim in dimensions(d.kind) { + if let Some(v) = d.dims.get(&dim.match_id) { + p[dim.match_id.as_str()] = json!(v); + } + } + if let Err(e) = app.session.execute("layer.newPrimitive", p) { + app.ui.status = e.to_string(); + } + close = true; + } + if close { + app.dialog = None; + } +} + +/// Menu routing: New 3D Primitive without parameters opens the dialog. Returns true when it did. +pub fn route(app: &mut EffectcraftApp, id: &str, params: &Value) -> Result { + if id != "layer.newPrimitive" || !params.as_object().is_none_or(|m| m.is_empty()) { + return Ok(false); + } + open(app).map(|_| true) +} From cb561a6a3dec2f2da77cc11b31362786727d2f5d Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Wed, 21 Oct 2026 13:31:59 -0400 Subject: [PATCH 26/46] 3D: test the New 3D Primitive dialog from a blank comp (#1734) --- crates/ui-egui/tests/ui_new_primitive.rs | 169 +++++++++++++++++++++++ 1 file changed, 169 insertions(+) create mode 100644 crates/ui-egui/tests/ui_new_primitive.rs diff --git a/crates/ui-egui/tests/ui_new_primitive.rs b/crates/ui-egui/tests/ui_new_primitive.rs new file mode 100644 index 000000000..2093e69d5 --- /dev/null +++ b/crates/ui-egui/tests/ui_new_primitive.rs @@ -0,0 +1,169 @@ +//! Layer ▸ New ▸ 3D Primitive… (#1734): the dialog picks a type, name and dimensions and runs +//! `layer.newPrimitive`, which switches a Classic 3D comp to Advanced 3D in the same step. + +use effectcraft_engine::Session; +use effectcraft_engine::project::{Layer, LayerSource, PrimitiveKind, Renderer}; +use effectcraft_ui_egui::{Dialog, EffectcraftApp}; +use egui::{Event, Key, Modifiers, PointerButton}; +use egui_kittest::Harness; +use serde_json::json; + +fn harness() -> Harness<'static, EffectcraftApp> { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Scene", "width": 640, "height": 360, "duration": 2})).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.step(); + h +} + +/// An automation element's rect in the last frame drawn (a dialog settles its place in its +/// first frames). +fn rect(h: &Harness<'_, EffectcraftApp>, id: &str) -> Option<[f32; 4]> { + let auto = &h.state().auto; + auto.elements.iter().chain(auto.previous.iter()).find(|e| e.id == id).map(|e| e.rect) +} + +fn click(h: &mut Harness<'_, EffectcraftApp>, id: &str) { + let [x, y, w, hh] = rect(h, id).unwrap_or_else(|| panic!("{id} is registered")); + let p = egui::pos2(x + w / 2.0, y + hh / 2.0); + h.input_mut().events.push(Event::PointerMoved(p)); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); +} + +fn key(h: &mut Harness<'_, EffectcraftApp>, key: Key, modifiers: Modifiers) { + for pressed in [true, false] { + h.input_mut().events.push(Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }); + } + h.run_steps(2); +} + +/// Types `text` over a field's contents and leaves it with Tab. +fn type_into(h: &mut Harness<'_, EffectcraftApp>, id: &str, text: &str) { + click(h, id); + assert!(h.ctx.egui_wants_keyboard_input(), "{id} has focus"); + key(h, Key::A, Modifiers::COMMAND); + h.input_mut().events.push(Event::Text(text.into())); + h.step(); + key(h, Key::Tab, Modifiers::NONE); +} + +/// Layer ▸ New ▸ 3D Primitive…, as the menu runs it. +fn open(h: &mut Harness<'_, EffectcraftApp>) { + let (path, entry) = + effectcraft_engine::menus::entries().into_iter().find(|(_, e)| e.label == "3D Primitive...").expect("Layer ▸ New lists 3D Primitive..."); + assert_eq!(path, ["Layer", "New"]); + assert_eq!(entry.command, "layer.newPrimitive"); + let ctx = h.ctx.clone(); + effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, &entry.command, entry.params_or_empty()).unwrap(); + h.run_steps(2); + assert_eq!(h.state().dialog, Some(Dialog::NewPrimitive)); +} + +fn choose(h: &mut Harness<'_, EffectcraftApp>, kind: PrimitiveKind) { + click(h, "dialog.primitive.type"); + let i = PrimitiveKind::ALL.iter().position(|k| *k == kind).unwrap(); + click(h, &format!("dialog.primitive.typeItem.{i}")); +} + +fn layers(h: &Harness<'_, EffectcraftApp>) -> Vec { + h.state().session.active_comp().unwrap().layers.clone() +} + +fn renderer(h: &Harness<'_, EffectcraftApp>) -> Renderer { + h.state().session.active_comp().unwrap().renderer +} + +fn geometry(l: &Layer, id: &str) -> f64 { + l.props.prop(&format!("geometryOptions/{id}")).unwrap_or_else(|| panic!("{id}")).value.as_f64() +} + +/// A torus named "Ring" with a 250 px radius, the rest left at their defaults. +fn create_ring(h: &mut Harness<'_, EffectcraftApp>) { + open(h); + choose(h, PrimitiveKind::Torus); + type_into(h, "dialog.primitive.name", "Ring"); + type_into(h, "dialog.primitive.radius", "250"); + click(h, "dialog.primitive.ok"); + assert_eq!(h.state().dialog, None); +} + +fn assert_ring(l: &Layer) { + assert_eq!(l.source, LayerSource::Primitive { kind: PrimitiveKind::Torus }); + assert_eq!(l.name, "Ring"); + assert_eq!(geometry(l, "radius"), 250.0); + assert_eq!(geometry(l, "tubeRadius"), 50.0); +} + +#[test] +fn creates_a_primitive_in_a_classic_3d_comp_in_one_undoable_step() { + let mut h = harness(); + assert_eq!(renderer(&h), Renderer::Classic3D); + let steps = h.state().session.history.undo.len(); + create_ring(&mut h); + let ls = layers(&h); + assert_eq!(ls.len(), 1); + assert_ring(&ls[0]); + assert_eq!(renderer(&h), Renderer::Advanced3D, "the comp switches to Advanced 3D"); + assert_eq!(h.state().session.history.undo.len(), steps + 1); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + assert!(layers(&h).is_empty()); + assert_eq!(renderer(&h), Renderer::Classic3D, "undo restores the renderer too"); + h.state_mut().session.execute("edit.redo", json!({})).unwrap(); + assert_ring(&layers(&h)[0]); + assert_eq!(renderer(&h), Renderer::Advanced3D); +} + +#[test] +fn cancel_creates_nothing() { + let mut h = harness(); + let steps = h.state().session.history.undo.len(); + open(&mut h); + choose(&mut h, PrimitiveKind::Sphere); + type_into(&mut h, "dialog.primitive.radius", "80"); + click(&mut h, "dialog.primitive.cancel"); + assert_eq!(h.state().dialog, None); + assert!(layers(&h).is_empty()); + assert_eq!(renderer(&h), Renderer::Classic3D); + assert_eq!(h.state().session.history.undo.len(), steps); +} + +#[test] +fn each_type_offers_the_dimensions_its_layer_has() { + let mut h = harness(); + for kind in PrimitiveKind::ALL { + open(&mut h); + choose(&mut h, kind); + click(&mut h, "dialog.primitive.ok"); + let l = layers(&h).into_iter().find(|l| l.source == LayerSource::Primitive { kind }).unwrap(); + assert_eq!(l.name, kind.label(), "the name follows the type until typed"); + let group = l.props.group("geometryOptions").unwrap(); + let ids: Vec<&str> = group.props().map(|p| p.match_id.as_str()).collect(); + open(&mut h); + choose(&mut h, kind); + let controls = ["type", "name", "ok", "cancel"]; + let shown: Vec<&str> = (h.state().auto.elements.iter()) + .filter_map(|e| e.id.strip_prefix("dialog.primitive.")) + .filter(|id| !controls.contains(id) && !id.starts_with("typeItem.")) + .collect(); + assert_eq!(shown, ids, "{} dimensions", kind.label()); + click(&mut h, "dialog.primitive.cancel"); + } +} + +#[test] +fn a_created_primitive_survives_save_and_reopen() { + let mut h = harness(); + create_ring(&mut h); + let path = std::env::temp_dir().join(format!("ec-ui-new-primitive-{}.ecproj", std::process::id())); + h.state_mut().session.execute("file.saveAs", json!({"path": path.to_string_lossy()})).unwrap(); + let mut o = Session::default(); + o.execute("file.open", json!({"path": path.to_string_lossy()})).unwrap(); + let _ = std::fs::remove_file(&path); + let (_, comp) = o.project.comps().next().unwrap(); + assert_eq!(comp.renderer, Renderer::Advanced3D); + assert_ring(&comp.layers[0]); +} From c21210a63a578407a0dca9b7c84d12c9ab02d8b0 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Wed, 21 Oct 2026 14:51:41 -0400 Subject: [PATCH 27/46] 3D: close the New 3D Primitive dialog when its project or comp changes; untouched dimensions take the type's defaults (#1734) The draft records the project epoch and comp it was opened for. A script or the control channel opening a project or activating another comp while it is open closed nothing before, and OK added the primitive (and switched to Advanced 3D) in whatever comp was active then, even in a new project reusing the comp id. --- crates/ui-egui/src/panels/primitive_dialog.rs | 30 +++++++--- crates/ui-egui/tests/ui_new_primitive.rs | 58 +++++++++++++++++++ 2 files changed, 81 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/panels/primitive_dialog.rs b/crates/ui-egui/src/panels/primitive_dialog.rs index 7626984a3..a3483a976 100644 --- a/crates/ui-egui/src/panels/primitive_dialog.rs +++ b/crates/ui-egui/src/panels/primitive_dialog.rs @@ -6,7 +6,7 @@ use std::collections::BTreeMap; use crate::i18n::tr; use effectcraft_engine::project::build::{Ids, primitive_geometry_options}; -use effectcraft_engine::project::{ParamUi, PrimitiveKind, Property}; +use effectcraft_engine::project::{ItemId, ParamUi, PrimitiveKind, Property}; use egui::{Color32, vec2}; use serde_json::{Value, json}; @@ -19,9 +19,15 @@ pub struct PrimitiveDraft { pub name: String, /// Whether the name was typed; until then it follows the type. pub named: bool, - /// Dimensions by Geometry Options match id, kept when the type changes (a sphere's radius - /// carries over to a cone). + /// Dimensions typed, by Geometry Options match id, kept when the type changes (a sphere's + /// radius carries over to a cone); the rest are the type's defaults. pub dims: BTreeMap, + /// The project (its epoch) and comp the dialog was opened for. + pub target: Option<(u64, ItemId)>, +} + +fn target(app: &EffectcraftApp) -> Option<(u64, ItemId)> { + Some((app.session.script_ui.project_epoch, app.session.active_comp_id()?)) } /// The dimensions a primitive type has: its Geometry Options as the engine builds them. @@ -32,15 +38,22 @@ fn dimensions(kind: PrimitiveKind) -> Vec { /// Open the dialog for a new primitive in the active comp. pub fn open(app: &mut EffectcraftApp) -> Result<(), String> { - app.session.active_comp().ok_or("no composition is open")?; + let target = Some(target(app).ok_or("no composition is open")?); let kind = PrimitiveKind::Cube; - app.dialog_state.primitive = PrimitiveDraft { kind, name: kind.label().to_string(), named: false, dims: BTreeMap::new() }; + app.dialog_state.primitive = PrimitiveDraft { kind, name: kind.label().to_string(), named: false, dims: BTreeMap::new(), target }; app.dialog = Some(Dialog::NewPrimitive); Ok(()) } pub fn dialog(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { let mut d = app.dialog_state.primitive.clone(); + // A project opened or another comp made active since (by a script or the control channel): + // the primitive would land somewhere the dialog was not opened for. + if target(app) != d.target { + app.ui.status = "New 3D Primitive closed: the composition changed".into(); + app.dialog = None; + return; + } let (mut ok, mut close) = (false, false); super::dialogs::modal(ctx, "New 3D Primitive", vec2(420.0, 300.0), t, |ui| { egui::Grid::new("primitive-grid").num_columns(2).spacing([14.0, 9.0]).show(ui, |ui| { @@ -63,12 +76,15 @@ pub fn dialog(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { ui.end_row(); for p in dimensions(d.kind) { let ParamUi::Slider { min, max, decimals, .. } = p.ui else { continue }; - let v = d.dims.entry(p.match_id.clone()).or_insert(p.value.as_f64()); + let mut v = d.dims.get(&p.match_id).copied().unwrap_or(p.value.as_f64()); ui.label(tr(&p.name)); // Counts (segments, rings) are whole numbers; sizes are pixels. let suffix = if decimals == 0 { "" } else { " px" }; - let r = ui.add(crate::widgets::drag_value(egui::DragValue::new(v).range(min..=max).max_decimals(decimals as usize).suffix(suffix))); + let r = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut v).range(min..=max).max_decimals(decimals as usize).suffix(suffix))); app.auto.add_response(&format!("dialog.primitive.{}", p.match_id), &r, &p.name); + if r.changed() { + d.dims.insert(p.match_id, v); + } ui.end_row(); } }); diff --git a/crates/ui-egui/tests/ui_new_primitive.rs b/crates/ui-egui/tests/ui_new_primitive.rs index 2093e69d5..fbb3a34e5 100644 --- a/crates/ui-egui/tests/ui_new_primitive.rs +++ b/crates/ui-egui/tests/ui_new_primitive.rs @@ -167,3 +167,61 @@ fn a_created_primitive_survives_save_and_reopen() { assert_eq!(comp.renderer, Renderer::Advanced3D); assert_ring(&comp.layers[0]); } + +#[test] +fn untouched_dimensions_take_each_types_defaults_and_typed_ones_carry_over() { + let mut h = harness(); + open(&mut h); + choose(&mut h, PrimitiveKind::Plane); + click(&mut h, "dialog.primitive.ok"); + let plane = layers(&h).remove(0); + assert_eq!(geometry(&plane, "width"), 400.0, "a plane's default, not the cube's 300"); + open(&mut h); + type_into(&mut h, "dialog.primitive.width", "120"); + choose(&mut h, PrimitiveKind::Plane); + click(&mut h, "dialog.primitive.ok"); + let plane = layers(&h).into_iter().find(|l| l.id != plane.id).unwrap(); + assert_eq!(geometry(&plane, "width"), 120.0, "the typed width carries over"); + assert_eq!(geometry(&plane, "height"), 400.0); +} + +/// A dialog left open while something else changes the editing context closes without creating +/// anything rather than adding the primitive where it was not opened. +fn closes_when(change: impl FnOnce(&mut Session)) { + let mut h = harness(); + let a = h.state().session.active_comp_id().unwrap(); + open(&mut h); + choose(&mut h, PrimitiveKind::Torus); + change(&mut h.state_mut().session); + let b = h.state().session.active_comp_id().unwrap(); + let steps = h.state().session.history.undo.len(); + h.run_steps(2); + assert_eq!(h.state().dialog, None, "the dialog closes"); + assert!(rect(&h, "dialog.primitive.ok").is_none()); + let s = &h.state().session; + for c in [a, b] { + if let Some(c) = s.project.comp(c) { + assert!(c.layers.is_empty(), "nothing is created"); + assert_eq!(c.renderer, Renderer::Classic3D); + } + } + assert_eq!(s.history.undo.len(), steps); + assert!(h.state().ui.status.contains("changed"), "{}", h.state().ui.status); +} + +#[test] +fn another_comp_made_active_closes_the_dialog() { + closes_when(|s| { + s.execute("comp.new", json!({"name": "Other", "width": 640, "height": 360, "duration": 2})).unwrap(); + }); +} + +#[test] +fn a_replaced_project_closes_the_dialog_even_when_the_comp_id_is_reused() { + closes_when(|s| { + let before = s.active_comp_id().unwrap(); + s.execute("file.newProject", json!({})).unwrap(); + s.execute("comp.new", json!({"name": "Scene", "width": 640, "height": 360, "duration": 2})).unwrap(); + assert_eq!(s.active_comp_id(), Some(before), "the new project reuses the comp's id"); + }); +} From c601829be19226aa12e87ea6672efc7a3561857e Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Wed, 21 Oct 2026 14:57:06 -0400 Subject: [PATCH 28/46] 3D: drop the NewPrimitive variant's doc comment (#1734) --- crates/ui-egui/src/lib.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 783f07798..18d30f184 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -72,7 +72,6 @@ pub enum Dialog { ViewOptions, CameraSettings, LightSettings, - /// Layer ▸ New ▸ 3D Primitive… (`panels::primitive_dialog`). NewPrimitive, KeyVelocity, KeyInterpolation, From 3f4ffdc0fc7ab765cb894c192f2ed7503381cbaf Mon Sep 17 00:00:00 2001 From: Madhav Agarwal Date: Thu, 22 Oct 2026 18:41:26 +0530 Subject: [PATCH 29/46] engine: a keyframe on the out point no longer warns (#2084) --- crates/engine/src/commands/prop.rs | 4 +++- crates/engine/src/tests_key_time.rs | 5 ++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/commands/prop.rs b/crates/engine/src/commands/prop.rs index 3af877d34..24598a3a7 100644 --- a/crates/engine/src/commands/prop.rs +++ b/crates/engine/src/commands/prop.rs @@ -179,7 +179,9 @@ pub(crate) fn key_time(layer: &Layer, frame_rate: FrameRate, p: &Value, param: & pub(crate) fn key_timing(layer: &Layer, t: Tick) -> Value { let ct = layer.comp_time(t); let mut out = json!({"time": t.seconds(), "layerTime": t.seconds(), "compTime": ct.seconds()}); - if !layer.is_active_at(ct) { + // A key exactly on the out point ends a fade normally: it is outside the half-open visible + // range, but it is not a caller mistake, so it does not warn (#2084). + if ct < layer.in_point || ct > layer.out_point { out["warning"] = json!(format!( "Keyframe at comp time {} s is outside the layer's active range [{} s, {} s).", ct.seconds(), diff --git a/crates/engine/src/tests_key_time.rs b/crates/engine/src/tests_key_time.rs index 4d4ab367b..d42d6d4a0 100644 --- a/crates/engine/src/tests_key_time.rs +++ b/crates/engine/src/tests_key_time.rs @@ -57,7 +57,10 @@ fn default_layer_times_are_preserved_and_outside_keys_warn() { assert!(selected.get("warning").is_some()); let moved = s.execute("keys.set", json!({"layer": layer, "path": "transform/opacity", "time": 15.3, "newTime": 9.5, "timeBase": "layer"})).unwrap(); assert_seconds(&moved["compTime"], 20.0); - assert!(moved.get("warning").is_some(), "out point is exclusive"); + assert!(moved.get("warning").is_none(), "a key on the out point is accepted"); + let past = s.execute("prop.addKey", json!({"layer": layer, "path": "transform/rotation", "time": 20.1, "timeBase": "comp", "value": 45})).unwrap(); + assert_seconds(&past["compTime"], 20.1); + assert!(past.get("warning").is_some(), "a key past the out point warns"); } #[test] From 963f489eab4947936cf770f8e01710c50298f69b Mon Sep 17 00:00:00 2001 From: Madhav Agarwal Date: Thu, 22 Oct 2026 18:47:59 +0530 Subject: [PATCH 30/46] engine: resolve effect match-name paths in prop_ref (#2064) --- crates/engine/src/commands/prop.rs | 7 +++++-- crates/engine/src/tests_effects.rs | 21 +++++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/commands/prop.rs b/crates/engine/src/commands/prop.rs index 3af877d34..596164b42 100644 --- a/crates/engine/src/commands/prop.rs +++ b/crates/engine/src/commands/prop.rs @@ -22,8 +22,11 @@ pub(crate) fn prop_ref(s: &Session, p: &Value, cmd: &str) -> Result<(ItemId, Lay }); } let path = str_p(p, "path").ok_or_else(|| bad(cmd, "missing `path` (e.g. transform/position) or `prop` uid"))?; - let pr = layer.props.prop(path).ok_or_else(|| { - let group = layer.props.group(path).or_else(|| path.trim().strip_prefix('@')?.parse().ok().and_then(|uid| layer.props.find_group(uid))); + // `effects/[#n]/` is how `essential.list`, presets and Copy write an + // effect property; turn it into the uid path `PropGroup::prop` parses (#2064). + let resolved = super::resolve_effect_path(layer, path); + let pr = layer.props.prop(&resolved).ok_or_else(|| { + let group = layer.props.group(&resolved).or_else(|| path.trim().strip_prefix('@')?.parse().ok().and_then(|uid| layer.props.find_group(uid))); if group.is_some() { bad(cmd, format!("`{path}` is a property group; use layer.tree (MCP: get_layer) to inspect its children, then select a leaf property")) } else { diff --git a/crates/engine/src/tests_effects.rs b/crates/engine/src/tests_effects.rs index 18fcc6f84..1ea57f4cf 100644 --- a/crates/engine/src/tests_effects.rs +++ b/crates/engine/src/tests_effects.rs @@ -772,6 +772,27 @@ fn preset_round_trips_effect_properties_by_match_name() { } } +/// #2064: `prop.addKey` accepts the `effects/[#n]/` path that +/// `essential.list` reports. +#[test] +fn add_key_resolves_effect_match_name_paths() { + let (mut s, a, _) = comp_with_two_solids(); + apply(&mut s, a, "Slider Control"); + apply(&mut s, a, "Slider Control"); + let slider = |s: &Session, n: usize| match layer(s, a).effects().unwrap().groups().nth(n).unwrap().get("slider").unwrap().value { + KV::Scalar(v) => v, + ref other => panic!("not a scalar: {other:?}"), + }; + for (path, n, value) in [("effects/ec.control.slider/slider", 0, 30.0), ("effects/ec.control.slider#2/slider", 1, 70.0)] { + let reply = s.execute("prop.addKey", json!({"layer": a, "path": path, "time": 0, "value": value})).unwrap(); + assert_eq!(reply, json!(1), "{path}"); + let got = s.execute("prop.get", json!({"layer": a, "path": path, "time": 0})).unwrap(); + assert_eq!(got["value"], json!(value), "{path}"); + assert_eq!(got["keys"].as_array().unwrap().len(), 1, "{path}"); + assert_eq!(slider(&s, n), 0.0, "the static value stays put; the key carries {value}"); + } +} + /// #1746: Edit ▸ Copy of an effect property writes the same `effects/ec.blur.gaussian/blurriness` path, /// and Paste (the value, or Copy Expression Only) sets that property of the other layer's effect. #[test] From ee1358681404568b6890ca642deb07ef9921648a Mon Sep 17 00:00:00 2001 From: Barzabel Date: Thu, 22 Oct 2026 20:13:50 +0300 Subject: [PATCH 31/46] svg: share CSS declaration blocks and cap {rects}"#).into_bytes() +} + +#[test] +fn comma_selector_lists_share_one_declaration_block() { + // Before #2140 every selector of a comma list got its own copy of the declaration block, so + // `a,a,a…{…}` stored selectors × declarations strings; one selector past the cap is refused. + let sels = vec!["rect"; MAX_CSS_SELECTORS + 1].join(","); + let decls: String = (0..10).map(|i| format!("p{i}:v;")).collect(); + let svg = styled(&format!("{sels}{{fill:red;{decls}}}"), &[""]); + let e = parse(&svg).unwrap_err(); + assert!(e.to_string().contains("selectors"), "{e}"); +} + +#[test] +fn a_style_sheet_with_too_many_declarations_errors() { + let decls: String = (0..=MAX_CSS_DECLARATIONS).map(|i| format!("p{i}:v;")).collect(); + let svg = styled(&format!("rect{{{decls}}}"), &[""]); + let e = parse(&svg).unwrap_err(); + assert!(e.to_string().contains("declarations"), "{e}"); +} + +#[test] +fn selector_lists_keep_the_css_cascade() { + let green = Some(Paint::Color([0.0, 1.0, 0.0])); + let blue = Some(Paint::Color([0.0, 0.0, 1.0])); + let red = Some(Paint::Color([1.0, 0.0, 0.0])); + let css = "rect, .b, #c { fill: #00ff00 } .b, .d { fill: #0000ff; stroke: #000000; stroke-width: 3 } rect.e, #c { fill: #ff0000 }"; + let svg = styled(css, &["", r#"class="b""#, r#"id="c" class="b""#, r#"class="b" style="fill:#00ff00""#, r##"class="e" fill="#0000ff""##, r#"class="d""#]); + let doc = parse(&svg).unwrap(); + let shapes = doc.flatten(); + let fills: Vec<_> = shapes.iter().map(|s| s.2.fill.clone()).collect(); + // Type selector; class beats type; id beats class; inline style beats every rule; a later, + // more specific rule beats the presentation attribute. + assert_eq!(fills, vec![green.clone(), blue.clone(), red.clone(), green, red, blue]); + assert!(shapes[0].2.stroke.is_none()); + // The second list's block reaches both of its selectors. + for i in [1, 5] { + assert_eq!(shapes[i].2.stroke.as_ref().map(|s| s.width), Some(3.0), "shape {i}"); + } +} + +#[test] +fn a_comma_list_stores_its_declarations_once() { + let sels = vec!["rect"; 1000].join(","); + let decls: String = (0..50).map(|i| format!("p{i}:v;")).collect(); + let mut sheet = StyleSheet::default(); + parse_css(&format!("{sels}{{{decls}}}"), &mut sheet).unwrap(); + assert_eq!(sheet.rules.len(), 1000); + assert_eq!(sheet.blocks.len(), 1); + assert_eq!(sheet.decls, 50); +} + +#[test] +fn matching_work_past_the_budget_errors_without_doing_it() { + // Within both sheet caps, yet one rectangle would apply 10 000 x 5 001 declarations. + let sels = vec!["rect"; 10_000].join(","); + let decls: String = (0..5_001).map(|i| format!("p{i}:v;")).collect(); + let svg = styled(&format!("{sels}{{{decls}}}"), &[""]); + let e = parse(&svg).unwrap_err(); + assert!(e.to_string().contains("{uses}"## + ) + }; + let e = parse(svg(&uses).as_bytes()).unwrap_err(); + assert!(e.to_string().contains("{uses}"## + ) + }; + let e = parse(svg(&uses).as_bytes()).unwrap_err(); + assert!(e.to_string().contains("