From 248cf6af3afc807dd89100073ea7f9f0969f0a87 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 08:59:04 -0400 Subject: [PATCH] M13.10: visual editors for Lumetri curves, Colorama output cycle, Glow colour map, Reshape correspondence points; scrolling Preview, AE-style comp tabs; web docs Effect Controls: Lumetri RGB and hue/saturation curve graphs, Colorama's palette wheel, Glow's colour map strip and Arbitrary Map curves, Reshape correspondence buttons and viewer handles (all prop.set, one undo step per gesture, automation ids). Preview panel scrolls; Composition/Timeline tabs get close, label swatch and viewer lock; Effect Controls tab the layer swatch; dropdowns elide long text. Fix the browser start (no default disk cache folder on wasm32). Browser smoke test covers frame workers, waiting renders and the Media Browser. Co-Authored-By: Claude Opus 5.5 --- apps/effectcraft-web/tests/smoke.mjs | 47 +- crates/effects/src/color3.rs | 6 +- crates/effects/src/lib.rs | 4 +- crates/effects/src/misc.rs | 2 +- crates/engine/src/session_settings.rs | 6 +- crates/ui-egui/src/dock.rs | 61 +- crates/ui-egui/src/dock_ui.rs | 40 +- crates/ui-egui/src/lib.rs | 4 +- crates/ui-egui/src/panels/effect_controls.rs | 28 +- crates/ui-egui/src/panels/fx_editors.rs | 932 +++++++++++++++++++ crates/ui-egui/src/panels/misc.rs | 23 +- crates/ui-egui/src/panels/mod.rs | 1 + crates/ui-egui/src/state.rs | 5 + crates/ui-egui/src/widgets.rs | 21 +- crates/ui-egui/tests/ui_fx_editors.rs | 269 ++++++ docs/parity.md | 8 +- docs/web.md | 86 +- 17 files changed, 1502 insertions(+), 41 deletions(-) create mode 100644 crates/ui-egui/src/panels/fx_editors.rs create mode 100644 crates/ui-egui/tests/ui_fx_editors.rs diff --git a/apps/effectcraft-web/tests/smoke.mjs b/apps/effectcraft-web/tests/smoke.mjs index 3ba19343..81bc81cc 100644 --- a/apps/effectcraft-web/tests/smoke.mjs +++ b/apps/effectcraft-web/tests/smoke.mjs @@ -167,14 +167,48 @@ try { const f = await js("effectcraft.renderFrame({max_side: 480}).then(r => ({w: r.width, h: r.height, pngBytes: atob(r.png).length}))"); report.steps.renderFrame = f; - // Render Queue: a short, small GIF → download. + // Viewer frames render in frame workers (project replicas fed by diffs): with the CPU + // renderer, scrubbing (and an edit, which travels as a diff) keeps the page's event loop free. + await js(`effectcraft.execute("render.backend", {backend: "cpu"})`); + report.steps.frameWorkers = await js(`(async () => { + const before = effectcraft.info().frameWorkers; + let maxGapMs = 0, last = performance.now(); + const timer = setInterval(() => { const now = performance.now(); maxGapMs = Math.max(maxGapMs, now - last); last = now; }, 10); + await effectcraft.execute("layer.newSolid", {name: "Frame Worker Solid", color: "#3366cc", width: 120, height: 80}); + for (let k = 0; k < 12; k++) { + await effectcraft.execute("time.set", {time: k / 4}); + await new Promise((r) => setTimeout(r, 150)); + } + const t0 = performance.now(); + while (effectcraft.info().frameWorkers.rendered < before.rendered + 3 && performance.now() - t0 < 120000) await new Promise((r) => setTimeout(r, 100)); + clearInterval(timer); + await effectcraft.execute("edit.undo", {}); + return {before, after: effectcraft.info().frameWorkers, maxGapMs}; + })()`); + const fwk = report.steps.frameWorkers; + await js(`effectcraft.execute("render.backend", {backend: "gpu"})`); + check(fwk.after.alive > 0, `frame workers alive: ${JSON.stringify(fwk.after)}`); + check(fwk.after.rendered >= fwk.before.rendered + 3, `frames rendered in workers: ${JSON.stringify(fwk)}`); + check(fwk.after.syncs >= 2, `project synced as diffs: ${JSON.stringify(fwk.after)}`); + check(fwk.maxGapMs < 400, `event loop blocked ${fwk.maxGapMs} ms while scrubbing`); + + // Render Queue: a short, small GIF → download. `renderQueue.render` waits by default; the + // render still runs in a worker and the reply comes when it ends (the page keeps running). const rqT = Date.now(); // (`ui.menu.invoke`: `engine.execute`'s strict parameter check does not know the spread // Render Settings / Output Module keys) await js(`effectcraft.request("ui.menu.invoke", {id: "renderQueue.add", params: {format: "gif", output: "smoke_[compName].gif", resolution: 0.25, timeSpan: "custom", start: 0, end: 1}})`); - await js(`effectcraft.execute("renderQueue.render", {})`); + const waited = await js(`(async () => { + let maxGapMs = 0, last = performance.now(); + const timer = setInterval(() => { const now = performance.now(); maxGapMs = Math.max(maxGapMs, now - last); last = now; }, 10); + const r = await effectcraft.execute("renderQueue.render", {}); + clearInterval(timer); + return {rendering: r.rendering, status: r.items.map((i) => i.status), maxGapMs}; + })()`); const gif = await waitDownload((n) => n.endsWith(".gif")); - report.steps.gifExport = { ...gif, ms: Date.now() - rqT }; + report.steps.gifExport = { ...gif, ms: Date.now() - rqT, waited }; + check(!waited.rendering && waited.status.every((s) => s === "Done"), `wait:true replies when done: ${JSON.stringify(waited)}`); + check(waited.maxGapMs < 400, `event loop blocked ${waited.maxGapMs} ms during a waiting render`); await idle(); // An image sequence arrives as one .zip. @@ -231,6 +265,13 @@ try { return effectcraft.addFile(new File([b], "tone.wav")); })()`); await until(`effectcraft.execute("layer.addItem", {item: "tone.wav", time: 0}).then(() => true, () => false)`, 20000, 300); + // Media Browser: browser storage (where tone.wav now is) and its actions. + const mb = await js(`effectcraft.execute("mediaBrowser.go", {})`); + report.steps.mediaBrowser = { path: mb.path, entries: mb.entries.map((e) => e.name), places: mb.places, actions: mb.actions.map((a) => a.id) }; + check(mb.path === "/Browser Storage" && mb.entries.some((e) => e.name === "tone.wav" && e.kind === "audio"), `Media Browser: ${JSON.stringify(report.steps.mediaBrowser)}`); + check(mb.actions.some((a) => a.id === "addFiles"), "Media Browser: Add Files…"); + const mbImport = await js(`effectcraft.execute("mediaBrowser.import", {paths: [${JSON.stringify(mb.entries.find((e) => e.name === "tone.wav")?.path ?? "/tone.wav")}]})`); + check(!mbImport.pending && mbImport.items && mbImport.items.length === 1, `Media Browser import: ${JSON.stringify(mbImport)}`); await js(`effectcraft.request("ui.playback", {action: "stop"})`); await js(`effectcraft.execute("time.set", {time: 0})`); await js(`effectcraft.request("ui.playback", {action: "play"})`); diff --git a/crates/effects/src/color3.rs b/crates/effects/src/color3.rs index 7d626d19..c85ab8d0 100644 --- a/crates/effects/src/color3.rs +++ b/crates/effects/src/color3.rs @@ -269,7 +269,7 @@ fn parse_points(s: &str) -> Vec<(f32, f32)> { /// around (x = 0 and x = 1 are the same hue); the others are flat beyond their end points. /// Tabulated as offsets `y − 0.5`. #[derive(Clone, Debug)] -pub(crate) struct OffsetCurve { +pub struct OffsetCurve { lut: Vec, } @@ -277,7 +277,7 @@ impl OffsetCurve { const N: usize = 512; /// `None` for an empty or flat-neutral curve. - pub(crate) fn parse(s: &str, periodic: bool) -> Option { + pub fn parse(s: &str, periodic: bool) -> Option { let pts = parse_points(s); if pts.is_empty() || pts.iter().all(|p| (p.1 - 0.5).abs() < 1e-6) { return None; @@ -327,7 +327,7 @@ impl OffsetCurve { } /// Offset from neutral at `x` (clamped to 0..1). - pub(crate) fn at(&self, x: f32) -> f32 { + pub fn at(&self, x: f32) -> f32 { let f = x.clamp(0.0, 1.0) * Self::N as f32; let i = (f as usize).min(Self::N - 1); let t = f - i as f32; diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 6f671630..d4f128f9 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -66,17 +66,19 @@ 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, LEVELS_CHANNELS, exposure_settings, fill_uses_masks, huesat_ranges_identity, levels_channel_ids, levels_channels_identity, levels_clip, }; pub use color2::Curve; +pub use color3::OffsetCurve; pub use distort::transform_shutter; pub use distort2::parse_mesh; use effectcraft_keyframe::Value; use effectcraft_project::build::Ids; use effectcraft_project::{GroupKind, ParamUi, PropGroup, Property}; pub use effectcraft_raster::{AuxChannels, Image}; -pub use misc::{INVERT_ALPHA, INVERT_CHANNELS, glow_operation}; +pub use misc::{INVERT_ALPHA, INVERT_CHANNELS, glow_ab_t, glow_operation}; pub use sim::particle_state; pub use sim3::playground_state; // CPU helpers the GPU kernels share (effectcraft-gpu). diff --git a/crates/effects/src/misc.rs b/crates/effects/src/misc.rs index 89b496bb..c119118d 100644 --- a/crates/effects/src/misc.rs +++ b/crates/effects/src/misc.rs @@ -103,7 +103,7 @@ fn unsharp(ctx: &EffectCtx, mut b: Buf) -> Buf { /// Position (0 = Color A, 1 = Color B) in Glow's A & B gradient for brightness `l`: `loops` /// cycles starting at `phase` (revolutions), shaped by Color Looping (0 Sawtooth A>B, /// 1 Sawtooth B>A, 2 Triangle A>B>A, 3 Triangle B>A>B) and biased so `mid` maps to the middle. -pub(crate) fn glow_ab_t(l: f32, looping: u32, loops: f32, phase: f32, mid: f32) -> f32 { +pub fn glow_ab_t(l: f32, looping: u32, loops: f32, phase: f32, mid: f32) -> f32 { let u = l.clamp(0.0, 1.0) * loops.max(0.0) + phase; let mut t = u - u.floor(); if u > 0.0 && t <= 0.0 { diff --git a/crates/engine/src/session_settings.rs b/crates/engine/src/session_settings.rs index 96cd5a29..ecb7f44a 100644 --- a/crates/engine/src/session_settings.rs +++ b/crates/engine/src/session_settings.rs @@ -48,8 +48,12 @@ impl Session { if !s.is_empty() { return Some(std::path::PathBuf::from(s)); } + // (the browser has no file system: no default folder there) + if self.config.is_none() || cfg!(target_arch = "wasm32") { + return None; + } let base = effectcraft_render::disk_cache::default_folder(); - self.config.is_some().then(|| base.parent().map(|p| p.join(name)).unwrap_or_else(|| base.join(name))) + Some(base.parent().map(|p| p.join(name)).unwrap_or_else(|| base.join(name))) } /// Settings ▸ Disk ▸ Database and Cache Folder (media cache: audio waveform summaries). diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 28c04837..e20a1315 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -701,6 +701,18 @@ pub enum DockAction { PanelMenu(PanelKind, egui::Pos2), /// A tab started being dragged (re-dock or undock). BeginDrag(PanelKind), + /// The tab's lock was toggled (Composition / Timeline viewer lock). + ToggleLock(PanelKind), +} + +/// After Effects-style extras on a tab that shows a composition (Composition, Timeline): a close +/// button (×), the item's label colour as a small square and the viewer lock. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct TabDeco { + pub swatch: egui::Color32, + pub locked: bool, + /// Close button and lock (Composition, Timeline); Effect Controls shows just the swatch. + pub viewer: bool, } /// Lay out the tree into group rects (with `gap` gutters) and handle gutter dragging. @@ -786,6 +798,7 @@ pub fn draw_group_chrome( t: &Tokens, reg: &mut crate::automation::Registry, title: &dyn Fn(PanelKind) -> String, + decos: &[(PanelKind, TabDeco)], ) -> Vec { let mut actions = Vec::new(); let painter = ui.painter().clone(); @@ -812,7 +825,14 @@ pub fn draw_group_chrome( let label = title(*p); let galley = painter.layout_no_wrap(label.clone(), Tokens::ui(12.0), if is_active { t.tab_text_active } else { t.tab_text }); let menu_w = if is_active { 20.0 } else { 0.0 }; - let w = galley.size().x + 16.0 + menu_w; + let deco = decos.iter().find(|(k, _)| k == p).map(|(_, d)| *d); + // × (active tab only), swatch and lock before the label. + let deco_w = match deco { + Some(d) if d.viewer => (if is_active { 16.0 } else { 0.0 }) + 14.0 + 16.0, + Some(_) => 14.0, + None => 0.0, + }; + let w = galley.size().x + 16.0 + menu_w + deco_w; if x + w > strip.max.x - 20.0 && i > g.active { let r = Rect::from_min_size(pos2(strip.max.x - 22.0, strip.min.y + 6.0), vec2(18.0, 20.0)); let resp = ui.interact(r, egui::Id::new(("tab-overflow", g.path.clone())), Sense::click()); @@ -830,7 +850,44 @@ pub fn draw_group_chrome( actions.push(DockAction::BeginDrag(*p)); } reg.add(&format!("panel.tab.{}", p.id()), tab, &label); - let label_x = tab.min.x + 8.0; + let mut label_x = tab.min.x + 8.0; + if let Some(d) = deco { + let mut dx = tab.min.x + 6.0; + if is_active && d.viewer { + let cr = Rect::from_center_size(pos2(dx + 5.0, text_y), vec2(10.0, 10.0)); + let cresp = ui.interact(cr.expand(2.0), egui::Id::new(("tab-close", g.path.clone(), i)), Sense::click()); + let cc = if cresp.hovered() { t.tab_text_active } else { t.tab_text }; + let k = 3.5; + painter.line_segment([cr.center() + vec2(-k, -k), cr.center() + vec2(k, k)], Stroke::new(1.2, cc)); + painter.line_segment([cr.center() + vec2(-k, k), cr.center() + vec2(k, -k)], Stroke::new(1.2, cc)); + reg.add(&format!("panel.tab.{}.close", p.id()), cr, "Close"); + if cresp.clicked() { + actions.push(DockAction::Close(*p)); + } + dx += 16.0; + } + let sw = Rect::from_center_size(pos2(dx + 5.0, text_y), vec2(9.0, 9.0)); + painter.rect_filled(sw, 1.0, d.swatch); + dx += 14.0; + label_x = dx; + if d.viewer { + let lr = Rect::from_center_size(pos2(dx + 6.0, text_y), vec2(12.0, 12.0)); + let lresp = ui.interact(lr.expand(2.0), egui::Id::new(("tab-lock", g.path.clone(), i)), Sense::click()); + let lc = if d.locked { + t.tab_text_active + } else if lresp.hovered() { + t.tab_text + } else { + t.text_faint + }; + icons::paint(&painter, lr, Icon::Lock, lc); + reg.add(&format!("panel.tab.{}.lock", p.id()), lr, if d.locked { "Unlock" } else { "Lock" }); + if lresp.clicked() { + actions.push(DockAction::ToggleLock(*p)); + } + label_x = dx + 16.0; + } + } let label_w = galley.size().x; let col = if is_active || resp.hovered() { t.tab_text_active } else { t.tab_text }; painter.galley_with_override_text_color(pos2(label_x, text_y - galley.size().y / 2.0), galley, col); diff --git a/crates/ui-egui/src/dock_ui.rs b/crates/ui-egui/src/dock_ui.rs index 61fa5ad6..1f42df2e 100644 --- a/crates/ui-egui/src/dock_ui.rs +++ b/crates/ui-egui/src/dock_ui.rs @@ -35,6 +35,26 @@ impl EffectcraftApp { out } + /// The Composition and Timeline tabs' close button, label-colour swatch and viewer lock. + fn tab_decos(&self) -> Vec<(PanelKind, dock::TabDeco)> { + let Some(cid) = self.session.active_comp_id() else { return vec![] }; + let swatch = match self.session.project.item(cid).map(|i| i.label) { + Some(l) if l != effectcraft_engine::color::Label::None => self.tokens.label(l), + _ => self.tokens.text_faint, + }; + let mut v: Vec<(PanelKind, dock::TabDeco)> = [PanelKind::Composition, PanelKind::Timeline] + .into_iter() + .map(|p| (p, dock::TabDeco { swatch, locked: self.ui.locked_tabs.contains(&p.id()), viewer: true })) + .collect(); + // Effect Controls carries the selected layer's label colour. + if let Some(l) = self.session.active_comp().and_then(|c| self.session.state.selected_layers.first().and_then(|id| c.layer(*id))) + && l.label != effectcraft_engine::color::Label::None + { + v.push((PanelKind::EffectControls, dock::TabDeco { swatch: self.tokens.label(l.label), locked: false, viewer: false })); + } + v + } + /// Settings ▸ Appearance ▸ Use Label Color for Related Tabs: the Composition and Timeline /// tabs carry their comp's label colour, Effect Controls and Properties the layer's. fn label_tab_marks(&self, ui: &egui::Ui) { @@ -44,12 +64,9 @@ impl EffectcraftApp { let Some(cid) = self.session.active_comp_id() else { return }; let comp_label = self.session.project.item(cid).map(|i| i.label); let layer_label = self.session.active_comp().and_then(|c| self.session.state.selected_layers.first().and_then(|l| c.layer(*l))).map(|l| l.label); - for (p, label) in [ - (PanelKind::Composition, comp_label), - (PanelKind::Timeline, comp_label), - (PanelKind::EffectControls, layer_label), - (PanelKind::Properties, layer_label), - ] { + // (the Composition, Timeline and Effect Controls tabs carry their swatch already) + let _ = comp_label; + for (p, label) in [(PanelKind::Properties, layer_label)] { let (Some(label), Some(e)) = (label, self.auto.find(&format!("panel.tab.{}", p.id()))) else { continue }; if label == effectcraft_engine::color::Label::None { continue; @@ -131,9 +148,10 @@ impl EffectcraftApp { dock::layout(ui, &mut dock, body, &t, "", &mut groups, &mut self.auto); let mut actions = Vec::new(); let titles = self.tab_titles(); + let decos = self.tab_decos(); let title = |p: PanelKind| titles.iter().find(|(k, _)| *k == p).map(|(_, s)| s.clone()).unwrap_or_else(|| p.title().to_string()); for g in &groups { - actions.extend(dock::draw_group_chrome(ui, g, self.ui.focused, &t, &mut self.auto, &title)); + actions.extend(dock::draw_group_chrome(ui, g, self.ui.focused, &t, &mut self.auto, &title, &decos)); } self.label_tab_marks(ui); if maximized.is_none() { @@ -174,6 +192,11 @@ impl EffectcraftApp { DockAction::BeginDrag(p) => ctx.data_mut(|d| { d.insert_temp(drag_id(), p); }), + DockAction::ToggleLock(p) => { + if !self.ui.locked_tabs.remove(&p.id()) { + self.ui.locked_tabs.insert(p.id().to_string()); + } + } } } self.tab_drag(&ctx, &groups, &float_groups); @@ -213,7 +236,8 @@ impl EffectcraftApp { active: f.active.min(f.panels.len().saturating_sub(1)), stacked: None, }; - actions.extend(dock::draw_group_chrome(ui, &g, self.ui.focused, &t, &mut self.auto, title)); + let decos = self.tab_decos(); + actions.extend(dock::draw_group_chrome(ui, &g, self.ui.focused, &t, &mut self.auto, title, &decos)); ui.painter().rect_stroke(r, t.radius, Stroke::new(1.0, t.field_border), StrokeKind::Outside); if let Some(p) = g.panels.get(g.active).copied() { self.panel_body(ui, p, g.content); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index d47ca3aa..6160edcf 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -991,7 +991,9 @@ impl EffectcraftApp { for ev in self.session.drain_events() { match ev { effectcraft_engine::Event::OpenComp(_) => { - self.ui.dock.activate(PanelKind::Composition); + if !self.ui.locked_tabs.contains(&PanelKind::Composition.id()) { + self.ui.dock.activate(PanelKind::Composition); + } self.ui.timeline.pps = None; } effectcraft_engine::Event::Toast { message, .. } => self.toast = Some((message, ctx.input(|i| i.time))), diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index 02dcb1a9..b5160410 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -12,7 +12,8 @@ //! - layer parameters have a layer popup and a Source / Masks / Effects & Masks popup; mask //! ("Path") parameters list the layer's masks; //! - Curves has its graph editor, Levels its histogram with input/output triangles, Auto -//! Levels a histogram. +//! Levels a histogram; Lumetri's curves, Colorama's output cycle, Glow's colour map and +//! Reshape's correspondence points have visual editors ([`super::fx_editors`]). use effectcraft_engine::geom::Mat3; use effectcraft_engine::keyframe::Value; @@ -40,7 +41,7 @@ fn selected_layer(app: &EffectcraftApp) -> Option { /// An undo-merge key for one gesture on `id`: a new key each time a drag starts, so separate /// drags are separate undo steps while one drag is one step. -fn gesture_key(ui: &egui::Ui, id: egui::Id, started: bool) -> String { +pub(super) fn gesture_key(ui: &egui::Ui, id: egui::Id, started: bool) -> String { let kid = id.with("gesture"); let n: u64 = if started { let n = ui.data(|d| d.get_temp::(kid)).unwrap_or(0) + 1; @@ -536,6 +537,15 @@ fn group_rows( } if open { let sub = fx.sub(&sg.match_id); + // A visual editor for the group's hidden parameters (Lumetri curves, …). + let eh = super::fx_editors::inline_height(fx.effect, &sub.path, rect.width()); + if eh > 0.0 { + let er = Rect::from_min_size(pos2(rect.min.x, *y), vec2(rect.width(), eh)); + *y += eh; + if er.max.y >= rect.min.y && er.min.y <= rect.max.y { + super::fx_editors::inline_editor(app, ui, p, layer, fx.root, sg, fx.effect, &sub.path, ectx, er, actions); + } + } group_rows(app, ui, p, layer, sg, ectx, rect, y, depth + 1, &sub, actions); } } @@ -603,8 +613,9 @@ fn roto_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, l // Curves / Levels editors /// Height of the custom editor shown under an effect's header (0 when none). -fn editor_height(effect: &str, width: f32) -> f32 { +fn editor_height(effect: &str, g: &PropGroup, width: f32) -> f32 { match effect { + super::fx_editors::GLOW | super::fx_editors::RESHAPE => super::fx_editors::header_height(effect, g, width), "ec.color.curves" => 34.0 + curves_size(width) + 26.0, effectcraft_engine::effects::warp_stab::ID => 50.0, effectcraft_engine::effects::camera_tracker::ID => super::camera_tracker_ui::EDITOR_HEIGHT, @@ -995,7 +1006,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { item(ui, "Move Down", fi + 1 < fx.len(), "effect.reorder", json!({"layer": lid, "effect": g.uid, "index": fi + 2})); }); if open { - let eh = editor_height(effect, body.width()); + let eh = editor_height(effect, g, body.width()); if eh > 0.0 { let er = Rect::from_min_size(pos2(body.min.x, y), vec2(body.width(), eh)); y += eh; @@ -1006,6 +1017,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { effectcraft_engine::effects::camera_tracker::ID => super::camera_tracker_ui::editor(app, ui, &bp, &layer, g, er, &mut actions), effectcraft_engine::effects::roto::ID => roto_editor(app, ui, &bp, &layer, g, er, &mut actions), "ec.color.levels" | "ec.color.levelsic" => levels_editor(app, ui, &bp, &layer, g, effect, &ectx, er, &mut actions), + super::fx_editors::GLOW | super::fx_editors::RESHAPE => { + super::fx_editors::header_editor(app, ui, &bp, &layer, g, effect, &ectx, er, &mut actions) + } _ => histogram_only(app, ui, &bp, g, er), } } @@ -1130,10 +1144,14 @@ pub fn viewer_hook( } else if app.ui.viewer.show_layer_controls { // ⊕ controls for the point parameters of the selected effects. let sel = app.session.state.selected_props.clone(); - for layer in ectx.comp.layers.iter().filter(|l| app.session.state.selected_layers.contains(&l.id) && l.is_active_at(ectx.time)) { + let selected = app.session.state.selected_layers.clone(); + for layer in ectx.comp.layers.iter().filter(|l| selected.contains(&l.id) && l.is_active_at(ectx.time)) { let Some(fx) = layer.effects() else { continue }; let m = l2c(ectx, layer); for g in fx.groups().filter(|g| g.enabled && sel.iter().any(|(l, u)| *l == layer.id && *u == g.uid)) { + if matches!(&g.kind, GroupKind::Effect { effect } if effect == super::fx_editors::RESHAPE) { + super::fx_editors::reshape_overlay(app, ui, painter, map, ectx, layer, g, &m, &mut actions); + } let pts: Vec<&Property> = g.props().filter(|p| matches!(p.ui, ParamUi::Point)).collect(); let screen: Vec = pts .iter() diff --git a/crates/ui-egui/src/panels/fx_editors.rs b/crates/ui-egui/src/panels/fx_editors.rs new file mode 100644 index 00000000..e47d0a8d --- /dev/null +++ b/crates/ui-egui/src/panels/fx_editors.rs @@ -0,0 +1,932 @@ +//! Visual editors for effect parameters stored as hidden or text parameters (Effect Controls): +//! +//! - Lumetri Color ▸ Curves ▸ RGB Curves: master / red / green / blue curve graphs (channel +//! swatches like After Effects'), and ▸ Hue Saturation Curves: Hue vs Sat, Hue vs Hue, Hue vs +//! Luma, Luma vs Sat and Sat vs Sat graphs over their colour gradients (y = ½ is neutral); +//! - Colorama ▸ Output Cycle: the palette wheel, with draggable colour stops (click the ring to +//! add one, Alt-click or drag one off to remove it, select one to pick its colour); editing a +//! preset palette turns it into Custom; +//! - Glow: the colour map (A & B Colors: a preview strip of the gradient over glow brightness; +//! Arbitrary Map: red / green / blue curve graphs and the resulting strip); +//! - Reshape: correspondence point list buttons here, and on the Composition viewer the source +//! and destination mask outlines with draggable correspondence handles ([`reshape_overlay`]). +//! +//! Every edit is a `prop.set` engine command (undoable; one drag = one undo step) and every +//! handle registers an automation id. + +use effectcraft_engine::effects::{ColoramaPalette, OffsetCurve}; +use effectcraft_engine::geom::Mat3; +use effectcraft_engine::keyframe::Value; +use effectcraft_engine::project::{Layer, PropGroup}; +use effectcraft_engine::render::EvalCtx; +use egui::{Align2, Color32, Pos2, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; +use serde_json::json; + +use super::effect_controls::gesture_key; +use super::fx_widgets as fw; +use crate::theme::Tokens; +use crate::{EffectcraftApp, widgets}; + +type Actions = Vec<(String, serde_json::Value)>; + +pub const LUMETRI: &str = "ec.color.lumetri"; +pub const COLORAMA: &str = "ec.color.colorama"; +pub const GLOW: &str = "ec.stylize.glow"; +pub const RESHAPE: &str = "ec.distort.reshape"; + +fn str_value(layer: &Layer, ectx: &EvalCtx, g: &PropGroup, id: &str) -> String { + match g.get(id).map(|p| ectx.value(layer, p)) { + Some(Value::Str(s)) => s, + _ => String::new(), + } +} + +fn set_str(actions: &mut Actions, layer: &Layer, g: &PropGroup, id: &str, v: String, merge: Option) { + let Some(p) = g.get(id) else { return }; + let mut a = json!({"layer": layer.id.0, "prop": p.uid, "value": v}); + if let Some(m) = merge { + a["merge"] = json!(m); + } + actions.push(("prop.set".into(), a)); +} + +/// Register automation ids for a curve's control points (`.point.`). +fn register_points(app: &mut EffectcraftApp, prefix: &str, gr: Rect, pts: &[[f32; 2]]) { + for (i, q) in pts.iter().enumerate() { + let s = pos2(gr.min.x + q[0] * gr.width(), gr.max.y - q[1] * gr.height()); + app.auto.add(&format!("{prefix}.point.{i}"), Rect::from_center_size(s, vec2(9.0, 9.0)), &format!("Point {}", i + 1)); + } +} + +/// Small coloured tabs (`names[i]`, swatch `cols[i]`); returns the clicked one. +fn tabs( + app: &mut EffectcraftApp, + ui: &mut egui::Ui, + p: &egui::Painter, + x0: f32, + cy: f32, + cur: usize, + names: &[&str], + cols: &[Color32], + id: &str, +) -> Option { + let t = app.tokens; + let mut x = x0; + let mut out = None; + for (i, name) in names.iter().enumerate() { + let w = if cols.is_empty() { 16.0 + name.chars().count() as f32 * 6.2 } else { 20.0 }; + let r = Rect::from_min_size(pos2(x, cy - 9.0), vec2(w, 18.0)); + let resp = ui.interact(r, egui::Id::new((id, i)), Sense::click()); + let sel = i == cur; + if sel { + p.rect_filled(r, 3.0, t.row_selected); + } else if resp.hovered() { + p.rect_filled(r, 3.0, t.hover); + } + match cols.get(i) { + Some(c) => { + p.circle_filled(r.center(), 5.5, *c); + if sel { + p.circle_stroke(r.center(), 7.0, Stroke::new(1.0, t.text)); + } + } + None => { + p.text(r.center(), Align2::CENTER_CENTER, *name, Tokens::ui(11.0), if sel { t.text } else { t.text_dim }); + } + } + app.auto.add(&format!("{id}.{i}"), r, name); + if resp.clicked() { + out = Some(i); + } + resp.on_hover_text(*name); + x += w + 4.0; + } + out +} + +fn reset_link(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, r: Rect, id: &str) -> bool { + let t = app.tokens; + let resp = ui.interact(r, egui::Id::new(id), Sense::click()); + p.text(r.center(), Align2::CENTER_CENTER, "Reset", Tokens::ui(11.5), if resp.hovered() { t.hot_text } else { t.text_dim }); + app.auto.add(id, r, "Reset"); + resp.clicked() +} + +fn graph_size(width: f32) -> f32 { + (width - 72.0).clamp(120.0, 256.0) +} + +// --------------------------------------------------------------------------------------------- +// Inline editors (inside a parameter group) + +/// Height of the editor shown at the top of group `path` (spec-id path with a trailing `/`) +/// of `effect` (0 when none). +pub fn inline_height(effect: &str, path: &str, width: f32) -> f32 { + match (effect, path) { + (LUMETRI, "curves/rgbCurves/") => 30.0 + graph_size(width) + 22.0, + (LUMETRI, "curves/hueSaturationCurves/") => 30.0 + graph_size(width) * 0.6 + 34.0, + (COLORAMA, "outputCycle/") => 30.0 + wheel_size(width) + 34.0, + _ => 0.0, + } +} + +/// Draw the editor of group `g` (at `path`) in `r`. `root`: the effect's group. +#[allow(clippy::too_many_arguments)] +pub fn inline_editor( + app: &mut EffectcraftApp, + ui: &mut egui::Ui, + p: &egui::Painter, + layer: &Layer, + root: &PropGroup, + g: &PropGroup, + effect: &str, + path: &str, + ectx: &EvalCtx, + r: Rect, + actions: &mut Actions, +) { + match (effect, path) { + (LUMETRI, "curves/rgbCurves/") => rgb_curves(app, ui, p, layer, root, g, ectx, r, actions), + (LUMETRI, "curves/hueSaturationCurves/") => huesat_curves(app, ui, p, layer, root, g, ectx, r, actions), + (COLORAMA, "outputCycle/") => colorama_wheel(app, ui, p, layer, root, g, ectx, r, actions), + _ => {} + } +} + +const RGB_CURVES: [(&str, &str); 4] = [("master", "Master"), ("red", "Red"), ("green", "Green"), ("blue", "Blue")]; + +#[allow(clippy::too_many_arguments)] +fn rgb_curves( + app: &mut EffectcraftApp, + ui: &mut egui::Ui, + p: &egui::Painter, + layer: &Layer, + root: &PropGroup, + g: &PropGroup, + ectx: &EvalCtx, + r: Rect, + actions: &mut Actions, +) { + let t = app.tokens; + let euid = root.uid; + let prefix = format!("effectControls.effect.{euid}.lumetri.rgbCurves"); + let ch = app.ui.fx_curve_channel.get(&g.uid).copied().unwrap_or(0).min(3); + let x0 = r.min.x + 38.0; + let cy = r.min.y + 15.0; + let cols = [Color32::from_gray(0xe8), Color32::from_rgb(0xf0, 0x50, 0x50), Color32::from_rgb(0x50, 0xd0, 0x60), Color32::from_rgb(0x50, 0x80, 0xff)]; + let names: Vec<&str> = RGB_CURVES.iter().map(|c| c.1).collect(); + if let Some(n) = tabs(app, ui, p, x0, cy, ch, &names, &cols, &format!("{prefix}.channel")) { + app.ui.fx_curve_channel.insert(g.uid, n); + } + let id = RGB_CURVES[ch].0; + let cur = fw::CurvePoints::parse(&str_value(layer, ectx, g, id)); + let size = graph_size(r.width()); + if reset_link(app, ui, p, Rect::from_min_size(pos2(x0 + size - 44.0, cy - 9.0), vec2(44.0, 18.0)), &format!("{prefix}.reset")) { + set_str(actions, layer, g, id, fw::CurvePoints::identity().format(), None); + } + let gr = Rect::from_min_size(pos2(x0, r.min.y + 30.0), vec2(size, size)); + let gid = egui::Id::new(("lumetri-rgb", euid, ch)); + let (resp, edit) = fw::curves_graph(ui, gr, &cur, ch, false, gid, &t); + app.auto.add(&format!("{prefix}.graph"), gr, "RGB Curves"); + register_points(app, &prefix, gr, &cur.0); + let key = gesture_key(ui, gid, resp.drag_started() || resp.clicked()); + if let Some(np) = edit.changed { + set_str(actions, layer, g, id, np.format(), Some(key)); + } + p.text(pos2(gr.min.x, gr.max.y + 11.0), Align2::LEFT_CENTER, "Click adds a point; drag one off to remove", Tokens::ui(10.5), t.text_faint); +} + +/// A hue / saturation curve's points (`x,y` in 0..1, y = ½ neutral), any count (empty = flat). +#[derive(Clone, Debug, Default, PartialEq)] +pub struct OffsetPoints(pub Vec<[f32; 2]>); + +impl OffsetPoints { + pub fn parse(s: &str) -> OffsetPoints { + let mut v: Vec<[f32; 2]> = s + .split(|c: char| c.is_whitespace() || c == ';') + .filter_map(|tk| { + let (x, y) = tk.split_once(',')?; + let (x, y) = (x.trim().parse::().ok()?, y.trim().parse::().ok()?); + (x.is_finite() && y.is_finite()).then_some([x.clamp(0.0, 1.0), y.clamp(0.0, 1.0)]) + }) + .collect(); + v.sort_by(|a, b| a[0].total_cmp(&b[0])); + OffsetPoints(v) + } + pub fn format(&self) -> String { + let f = |v: f32| { + let s = format!("{v:.4}"); + let s = s.trim_end_matches('0').trim_end_matches('.').to_string(); + if s.is_empty() || s == "-0" { "0".into() } else { s } + }; + self.0.iter().map(|q| format!("{},{}", f(q[0]), f(q[1]))).collect::>().join(" ") + } + pub fn insert(&mut self, x: f32, y: f32) -> usize { + let i = self.0.partition_point(|q| q[0] < x); + self.0.insert(i, [x.clamp(0.0, 1.0), y.clamp(0.0, 1.0)]); + i + } + /// Move point `i`, keeping the order. + pub fn move_point(&mut self, i: usize, x: f32, y: f32) { + let n = self.0.len(); + if i >= n { + return; + } + let lo = if i == 0 { 0.0 } else { self.0[i - 1][0] + 0.002 }; + let hi = if i + 1 == n { 1.0 } else { self.0[i + 1][0] - 0.002 }; + self.0[i] = [x.clamp(lo, hi.max(lo)), y.clamp(0.0, 1.0)]; + } + pub fn hit(&self, x: f32, y: f32, r: [f32; 2]) -> Option { + self.0 + .iter() + .enumerate() + .map(|(i, q)| (i, ((q[0] - x) / r[0]).hypot((q[1] - y) / r[1]))) + .filter(|(_, d)| *d <= 1.0) + .min_by(|a, b| a.1.total_cmp(&b.1)) + .map(|(i, _)| i) + } +} + +/// Lumetri's hue / saturation curves: parameter id, tab name, x axis (0 hue, 1 luma, 2 sat), +/// whether x wraps around. +const HUESAT: [(&str, &str, u8, bool); 5] = [ + ("hueVsSat", "Hue vs Sat", 0, true), + ("hueVsHue", "Hue vs Hue", 0, true), + ("hueVsLuma", "Hue vs Luma", 0, true), + ("lumaVsSat", "Luma vs Sat", 1, false), + ("satVsSat", "Sat vs Sat", 2, false), +]; + +fn hue_color(h: f32) -> Color32 { + let c = egui::ecolor::Hsva::new(h.rem_euclid(1.0), 0.85, 0.9, 1.0); + Color32::from(c) +} + +#[allow(clippy::too_many_arguments)] +fn huesat_curves( + app: &mut EffectcraftApp, + ui: &mut egui::Ui, + p: &egui::Painter, + layer: &Layer, + root: &PropGroup, + g: &PropGroup, + ectx: &EvalCtx, + r: Rect, + actions: &mut Actions, +) { + let t = app.tokens; + let euid = root.uid; + let prefix = format!("effectControls.effect.{euid}.lumetri.hueSatCurves"); + let k = app.ui.fx_curve_channel.get(&g.uid).copied().unwrap_or(0).min(4); + let x0 = r.min.x + 38.0; + let cy = r.min.y + 15.0; + let names: Vec<&str> = HUESAT.iter().map(|c| c.1).collect(); + if let Some(n) = tabs(app, ui, p, x0, cy, k, &names, &[], &format!("{prefix}.curve")) { + app.ui.fx_curve_channel.insert(g.uid, n); + } + let (id, name, axis, periodic) = HUESAT[k]; + let cur = OffsetPoints::parse(&str_value(layer, ectx, g, id)); + let size = graph_size(r.width()); + let gr = Rect::from_min_size(pos2(x0, r.min.y + 30.0), vec2(size, size * 0.6)); + // Background: the x axis' colours (hue spectrum, grey ramp) as a band, the neutral line. + p.rect_filled(gr, 0.0, Color32::from_rgb(0x1c, 0x1c, 0x1c)); + let n = 48; + for i in 0..n { + let f = (i as f32 + 0.5) / n as f32; + let c = match axis { + 0 => hue_color(f), + 1 => Color32::from_gray((f * 255.0) as u8), + _ => Color32::from(egui::ecolor::Hsva::new(0.0, f, 0.85, 1.0)), + }; + let x = gr.min.x + gr.width() * i as f32 / n as f32; + let band = Rect::from_min_max(pos2(x, gr.max.y - 8.0), pos2(x + gr.width() / n as f32 + 0.5, gr.max.y)); + p.rect_filled(band, 0.0, c); + p.rect_filled(Rect::from_min_max(pos2(band.min.x, gr.min.y), pos2(band.max.x, gr.max.y - 8.0)), 0.0, c.gamma_multiply(0.12)); + } + let to_s = |x: f32, y: f32| pos2(gr.min.x + x * gr.width(), gr.max.y - y * gr.height()); + p.line_segment([to_s(0.0, 0.5), to_s(1.0, 0.5)], Stroke::new(1.0, Color32::from_white_alpha(60))); + p.rect_stroke(gr, 0.0, Stroke::new(1.0, t.field_border), StrokeKind::Outside); + let curve = OffsetCurve::parse(&cur.format(), periodic); + let line: Vec = (0..=96) + .map(|i| { + let x = i as f32 / 96.0; + to_s(x, 0.5 + curve.as_ref().map(|c| c.at(x)).unwrap_or(0.0)) + }) + .collect(); + p.add(egui::Shape::line(line, Stroke::new(1.5, Color32::from_gray(0xe8)))); + let gid = egui::Id::new(("lumetri-hs", euid, k)); + let resp = ui.interact(gr.expand(6.0), gid, Sense::click_and_drag()); + app.auto.add(&format!("{prefix}.graph"), gr, name); + let drag_id = gid.with("drag"); + let dragging: Option = ui.data(|d| d.get_temp(drag_id)); + for (i, q) in cur.0.iter().enumerate() { + let s = to_s(q[0], q[1]); + let hr = Rect::from_center_size(s, vec2(7.0, 7.0)); + p.circle_filled(s, 3.5, if dragging == Some(i) { Color32::WHITE } else { Color32::from_rgb(0x1c, 0x1c, 0x1c) }); + p.circle_stroke(s, 3.5, Stroke::new(1.2, Color32::WHITE)); + app.auto.add(&format!("{prefix}.point.{i}"), hr, &format!("Point {}", i + 1)); + } + let to_g = |s: Pos2| [((s.x - gr.min.x) / gr.width()).clamp(0.0, 1.0), ((gr.max.y - s.y) / gr.height()).clamp(0.0, 1.0)]; + let hit_r = [7.0 / gr.width(), 7.0 / gr.height()]; + let mut changed: Option = None; + let alt = ui.input(|i| i.modifiers.alt); + // One undo step per click or drag. + let key = gesture_key(ui, gid, resp.drag_started() || resp.clicked()); + if resp.drag_started() + && let Some(pt) = ui.input(|i| i.pointer.press_origin()) + { + let q = to_g(pt); + let i = match cur.hit(q[0], q[1], hit_r) { + Some(i) => i, + None => { + let mut np = cur.clone(); + let i = np.insert(q[0], q[1]); + changed = Some(np); + i + } + }; + ui.data_mut(|d| d.insert_temp(drag_id, i)); + } else if resp.dragged() + && let (Some(i), Some(pt)) = (dragging, resp.interact_pointer_pos()) + { + let q = to_g(pt); + let mut np = cur.clone(); + np.move_point(i, q[0], q[1]); + if np != cur { + changed = Some(np); + } + } + if resp.drag_stopped() { + if let (Some(i), Some(pt)) = (dragging, resp.interact_pointer_pos()) + && !gr.expand(14.0).contains(pt) + && i < cur.0.len() + { + let mut np = cur.clone(); + np.0.remove(i); + changed = Some(np); + } + ui.data_mut(|d| d.remove::(drag_id)); + } + if resp.clicked() + && let Some(pt) = resp.interact_pointer_pos() + { + let q = to_g(pt); + let mut np = cur.clone(); + match cur.hit(q[0], q[1], hit_r) { + Some(i) if alt => { + np.0.remove(i); + } + Some(_) => {} + None => { + np.insert(q[0], q[1]); + } + } + if np != cur { + changed = Some(np); + } + } + if let Some(np) = changed { + set_str(actions, layer, g, id, np.format(), Some(key)); + } + if reset_link(app, ui, p, Rect::from_min_size(pos2(gr.max.x - 44.0, gr.max.y + 4.0), vec2(44.0, 18.0)), &format!("{prefix}.reset")) { + set_str(actions, layer, g, id, String::new(), None); + } + p.text(pos2(gr.min.x, gr.max.y + 13.0), Align2::LEFT_CENTER, "Click adds a point (½ height = no change)", Tokens::ui(10.5), t.text_faint); +} + +// --------------------------------------------------------------------------------------------- +// Colorama output cycle + +fn wheel_size(width: f32) -> f32 { + (width - 90.0).clamp(110.0, 200.0) +} + +/// Colorama's palette as stops: (position, straight RGBA). +pub fn format_palette(stops: &[(f32, [f32; 4])]) -> String { + let f = |v: f32| { + let s = format!("{v:.4}"); + let s = s.trim_end_matches('0').trim_end_matches('.').to_string(); + if s.is_empty() || s == "-0" { "0".into() } else { s } + }; + stops + .iter() + .map(|(pos, c)| { + let rgb = format!("{},{},{}", f(c[0]), f(c[1]), f(c[2])); + if (c[3] - 1.0).abs() > 1e-4 { format!("{}:{rgb},{}", f(*pos), f(c[3])) } else { format!("{}:{rgb}", f(*pos)) } + }) + .collect::>() + .join(" ") +} + +fn rgba8(c: [f32; 4]) -> Color32 { + let b = |v: f32| (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8; + Color32::from_rgb(b(c[0]), b(c[1]), b(c[2])) +} + +/// Phase (0..1, clockwise from the top) of a point around `c`. +fn phase_at(c: Pos2, q: Pos2) -> f32 { + let a = (q.x - c.x).atan2(-(q.y - c.y)); + (a / std::f32::consts::TAU).rem_euclid(1.0) +} + +fn wheel_point(c: Pos2, radius: f32, phase: f32) -> Pos2 { + let a = phase * std::f32::consts::TAU; + pos2(c.x + radius * a.sin(), c.y - radius * a.cos()) +} + +#[allow(clippy::too_many_arguments)] +fn colorama_wheel( + app: &mut EffectcraftApp, + ui: &mut egui::Ui, + p: &egui::Painter, + layer: &Layer, + root: &PropGroup, + g: &PropGroup, + ectx: &EvalCtx, + r: Rect, + actions: &mut Actions, +) { + let t = app.tokens; + let euid = root.uid; + let prefix = format!("effectControls.effect.{euid}.colorama"); + let preset = g.get("usePresetPalette").map(|pr| ectx.value(layer, pr).as_enum()).unwrap_or(0); + let custom = preset as usize == effectcraft_engine::effects::COLORAMA_PRESETS.len() - 1; + let interp = g.get("interpolatePalette").is_none_or(|pr| ectx.value(layer, pr).as_bool()); + let pal = if custom { ColoramaPalette::parse(&str_value(layer, ectx, g, "palette")) } else { None }.unwrap_or_else(|| ColoramaPalette::preset(preset)); + let x0 = r.min.x + 38.0; + let label = if custom { "Custom palette" } else { effectcraft_engine::effects::COLORAMA_PRESETS.get(preset as usize).copied().unwrap_or("Preset") }; + p.text(pos2(x0, r.min.y + 15.0), Align2::LEFT_CENTER, format!("Output Cycle: {label}"), Tokens::ui(11.5), t.text_dim); + let size = wheel_size(r.width()); + let c = pos2(x0 + size / 2.0, r.min.y + 30.0 + size / 2.0); + let (outer, inner) = (size / 2.0 - 8.0, size / 2.0 - 26.0); + // The ring: palette colours around the wheel. + let n = 96; + for i in 0..n { + let (a0, a1) = (i as f32 / n as f32, (i + 1) as f32 / n as f32); + let col = rgba8(pal.eval((a0 + a1) * 0.5, interp)); + let quad = vec![wheel_point(c, inner, a0), wheel_point(c, outer, a0), wheel_point(c, outer, a1 + 0.002), wheel_point(c, inner, a1 + 0.002)]; + p.add(egui::Shape::convex_polygon(quad, col, Stroke::NONE)); + } + p.circle_stroke(c, outer, Stroke::new(1.0, Color32::from_black_alpha(160))); + p.circle_stroke(c, inner, Stroke::new(1.0, Color32::from_black_alpha(160))); + let wid = egui::Id::new(("colorama-wheel", euid)); + let ring = Rect::from_center_size(c, vec2(outer * 2.0 + 40.0, outer * 2.0 + 40.0)); + let resp = ui.interact(ring, wid, Sense::click_and_drag()); + app.auto.add(&format!("{prefix}.wheel"), ring, "Output Cycle"); + let sel_id = wid.with("sel"); + let drag_id = wid.with("drag"); + let mut sel: usize = ui.data(|d| d.get_temp(sel_id)).unwrap_or(0); + if sel >= pal.stops.len() { + sel = 0; + } + let dragging: Option = ui.data(|d| d.get_temp(drag_id)); + // Stops: triangles on the outside of the ring. + let mut stop_rects = vec![]; + for (i, (ph, col)) in pal.stops.iter().enumerate() { + let tip = wheel_point(c, outer + 2.0, *ph); + let a = wheel_point(c, outer + 12.0, *ph - 0.018); + let b = wheel_point(c, outer + 12.0, *ph + 0.018); + let fill = rgba8(*col); + p.add(egui::Shape::convex_polygon( + vec![tip, a, b], + fill, + Stroke::new(if i == sel || dragging == Some(i) { 1.6 } else { 1.0 }, if i == sel { Color32::WHITE } else { Color32::from_gray(0x80) }), + )); + let hr = Rect::from_center_size(wheel_point(c, outer + 8.0, *ph), vec2(14.0, 14.0)); + app.auto.add(&format!("{prefix}.stop.{i}"), hr, &format!("Stop {}", i + 1)); + stop_rects.push(hr); + } + let hit = |q: Pos2| -> Option { + stop_rects + .iter() + .enumerate() + .filter(|(_, hr)| hr.expand(3.0).contains(q)) + .min_by(|a, b| a.1.center().distance(q).total_cmp(&b.1.center().distance(q))) + .map(|(i, _)| i) + }; + let mut new_stops: Option> = None; + let alt = ui.input(|i| i.modifiers.alt); + // One undo step per click or drag (a colour pick is its own step). + let key = gesture_key(ui, wid, resp.drag_started() || resp.clicked() || !resp.dragged()); + if resp.drag_started() + && let Some(q) = ui.input(|i| i.pointer.press_origin()) + && let Some(i) = hit(q) + { + ui.data_mut(|d| { + d.insert_temp(drag_id, i); + d.insert_temp(sel_id, i); + }); + } else if resp.dragged() + && let (Some(i), Some(q)) = (dragging, resp.interact_pointer_pos()) + && i < pal.stops.len() + { + let mut st = pal.stops.clone(); + st[i].0 = phase_at(c, q); + new_stops = Some(st); + } + if resp.drag_stopped() { + if let (Some(i), Some(q)) = (dragging, resp.interact_pointer_pos()) + && q.distance(c) > outer + 40.0 + && pal.stops.len() > 1 + && i < pal.stops.len() + { + let mut st = pal.stops.clone(); + st.remove(i); + new_stops = Some(st); + } + ui.data_mut(|d| d.remove::(drag_id)); + } + if resp.clicked() + && let Some(q) = resp.interact_pointer_pos() + { + match hit(q) { + Some(i) if alt && pal.stops.len() > 1 => { + let mut st = pal.stops.clone(); + st.remove(i); + new_stops = Some(st); + } + Some(i) => { + ui.data_mut(|d| d.insert_temp(sel_id, i)); + } + // A click on the ring adds a stop with the colour there (and selects it). + None if (inner - 4.0..=outer + 4.0).contains(&q.distance(c)) => { + let ph = phase_at(c, q); + let mut st = pal.stops.clone(); + st.push((ph, pal.eval(ph, interp))); + st.sort_by(|a, b| a.0.total_cmp(&b.0)); + let k = st.iter().position(|s| s.0 == ph).unwrap_or(0); + ui.data_mut(|d| d.insert_temp(sel_id, k)); + new_stops = Some(st); + } + None => {} + } + } + // The selected stop's colour. + let sr = Rect::from_min_size(pos2(x0, c.y + size / 2.0 + 8.0), vec2(30.0, 16.0)); + if let Some((ph, col)) = pal.stops.get(sel).copied() { + let pop = wid.with("color"); + if widgets::swatch(ui, sr, col, wid.with("swatch"), &t).clicked() { + widgets::open_popup(ui, pop); + } + app.auto.add(&format!("{prefix}.color"), sr, "Stop color"); + p.text(pos2(sr.max.x + 8.0, sr.center().y), Align2::LEFT_CENTER, format!("Stop {} at {:.0}°", sel + 1, ph * 360.0), Tokens::ui(11.0), t.text_dim); + let mut rgb = [col[0], col[1], col[2]]; + if crate::header::color_popup(ui, pop, sr.left_bottom(), &mut rgb) { + let mut st = pal.stops.clone(); + st[sel].1 = [rgb[0], rgb[1], rgb[2], col[3]]; + new_stops = Some(st); + } + } + p.text( + pos2(sr.min.x, sr.max.y + 9.0), + Align2::LEFT_CENTER, + "Click the ring to add a colour; Alt-click a stop (or drag it off) to remove it", + Tokens::ui(10.5), + t.text_faint, + ); + if let Some(mut st) = new_stops { + st.sort_by(|a, b| a.0.total_cmp(&b.0)); + // Editing a preset makes it the custom palette (both in one undo step). + if !custom && let Some(pr) = g.get("usePresetPalette") { + actions.push(( + "prop.set".into(), + json!({"layer": layer.id.0, "prop": pr.uid, "value": effectcraft_engine::effects::COLORAMA_PRESETS.len() - 1, "merge": key}), + )); + } + set_str(actions, layer, g, "palette", format_palette(&st), Some(key)); + } +} + +// --------------------------------------------------------------------------------------------- +// Header editors (under the effect's title) + +/// Height of the editor under `effect`'s header (`g`: the instance), 0 when none. +pub fn header_height(effect: &str, g: &PropGroup, width: f32) -> f32 { + match effect { + GLOW => match g.get("colors").map(|p| p.value.as_enum()) { + Some(2) => 30.0 + graph_size(width) + 40.0, + Some(1) => 34.0, + _ => 0.0, + }, + RESHAPE => 50.0, + _ => 0.0, + } +} + +#[allow(clippy::too_many_arguments)] +pub fn header_editor( + app: &mut EffectcraftApp, + ui: &mut egui::Ui, + p: &egui::Painter, + layer: &Layer, + g: &PropGroup, + effect: &str, + ectx: &EvalCtx, + r: Rect, + actions: &mut Actions, +) { + match effect { + GLOW => glow_map(app, ui, p, layer, g, ectx, r, actions), + RESHAPE => reshape_buttons(app, ui, p, layer, g, ectx, r, actions), + _ => {} + } +} + +/// Glow's Arbitrary Map: "red | green | blue" Curves point lists. +pub fn split_map(s: &str) -> [fw::CurvePoints; 3] { + let mut parts = s.split('|'); + std::array::from_fn(|_| parts.next().map(fw::CurvePoints::parse).unwrap_or_else(fw::CurvePoints::identity)) +} + +pub fn join_map(m: &[fw::CurvePoints; 3]) -> String { + m.iter().map(|c| c.format()).collect::>().join(" | ") +} + +#[allow(clippy::too_many_arguments)] +fn glow_map(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, layer: &Layer, g: &PropGroup, ectx: &EvalCtx, r: Rect, actions: &mut Actions) { + let t = app.tokens; + let euid = g.uid; + let prefix = format!("effectControls.effect.{euid}.glow"); + let mode = g.get("colors").map(|pr| ectx.value(layer, pr).as_enum()).unwrap_or(0); + let x0 = r.min.x + 38.0; + let num = |id: &str| g.get(id).map(|pr| ectx.value(layer, pr).as_f64()).unwrap_or(0.0); + let looping = g.get("colorLooping").map(|pr| ectx.value(layer, pr).as_enum()).unwrap_or(2); + let (loops, phase, mid) = (num("colorLoops") as f32, (num("colorPhase") / 360.0) as f32, (num("abMidpoint") / 100.0) as f32); + let map = split_map(&str_value(layer, ectx, g, "arbitraryMap")); + let color_at = |l: f32| -> Color32 { + let tt = effectcraft_engine::effects::glow_ab_t(l, looping, loops, phase, mid); + if mode == 2 { + Color32::from_rgb( + (map[0].eval(tt).clamp(0.0, 1.0) * 255.0) as u8, + (map[1].eval(tt).clamp(0.0, 1.0) * 255.0) as u8, + (map[2].eval(tt).clamp(0.0, 1.0) * 255.0) as u8, + ) + } else { + let col = |id: &str| match g.get(id).map(|pr| ectx.value(layer, pr)) { + Some(Value::Color(c)) => c, + _ => [0.0; 4], + }; + let (a, b) = (col("colorA"), col("colorB")); + let m = |i: usize| ((a[i] + (b[i] - a[i]) * tt as f64).clamp(0.0, 1.0) * 255.0) as u8; + Color32::from_rgb(m(0), m(1), m(2)) + } + }; + let size = graph_size(r.width()); + let strip_y = if mode == 2 { r.min.y + 30.0 + size + 12.0 } else { r.min.y + 8.0 }; + let strip = Rect::from_min_size(pos2(x0, strip_y), vec2(size, 12.0)); + let n = 64; + for i in 0..n { + let f = (i as f32 + 0.5) / n as f32; + let x = strip.min.x + strip.width() * i as f32 / n as f32; + p.rect_filled(Rect::from_min_max(pos2(x, strip.min.y), pos2(x + strip.width() / n as f32 + 0.5, strip.max.y)), 0.0, color_at(f)); + } + p.rect_stroke(strip, 0.0, Stroke::new(1.0, t.field_border), StrokeKind::Outside); + app.auto.add(&format!("{prefix}.colorMap"), strip, "Glow colour map"); + p.text(pos2(strip.max.x + 8.0, strip.center().y), Align2::LEFT_CENTER, "Colour map", Tokens::ui(10.5), t.text_faint); + if mode != 2 { + return; + } + let ch = app.ui.fx_curve_channel.get(&euid).copied().unwrap_or(0).min(2); + let cols = [Color32::from_rgb(0xf0, 0x50, 0x50), Color32::from_rgb(0x50, 0xd0, 0x60), Color32::from_rgb(0x50, 0x80, 0xff)]; + if let Some(n) = tabs(app, ui, p, x0, r.min.y + 15.0, ch, &["Red", "Green", "Blue"], &cols, &format!("{prefix}.channel")) { + app.ui.fx_curve_channel.insert(euid, n); + } + if reset_link(app, ui, p, Rect::from_min_size(pos2(x0 + size - 44.0, r.min.y + 6.0), vec2(44.0, 18.0)), &format!("{prefix}.reset")) { + let mut m = map.clone(); + m[ch] = fw::CurvePoints::identity(); + set_str(actions, layer, g, "arbitraryMap", join_map(&m), None); + } + let gr = Rect::from_min_size(pos2(x0, r.min.y + 30.0), vec2(size, size)); + let gid = egui::Id::new(("glow-map", euid, ch)); + let (resp, edit) = fw::curves_graph(ui, gr, &map[ch], ch + 1, false, gid, &t); + app.auto.add(&format!("{prefix}.graph"), gr, "Arbitrary Map"); + register_points(app, &prefix, gr, &map[ch].0); + let key = gesture_key(ui, gid, resp.drag_started() || resp.clicked()); + if let Some(np) = edit.changed { + let mut m = map.clone(); + m[ch] = np; + set_str(actions, layer, g, "arbitraryMap", join_map(&m), Some(key)); + } +} + +// --------------------------------------------------------------------------------------------- +// Reshape + +/// Reshape's correspondence pairs (source, destination outline fractions), in stored order. +pub fn parse_pairs(s: &str) -> Vec<(f64, f64)> { + s.split_whitespace() + .filter_map(|tk| { + let (a, b) = tk.split_once(',')?; + Some((a.trim().parse::().ok()?.rem_euclid(1.0), b.trim().parse::().ok()?.rem_euclid(1.0))) + }) + .collect() +} + +pub fn format_pairs(v: &[(f64, f64)]) -> String { + let f = |x: f64| { + let s = format!("{x:.4}"); + let s = s.trim_end_matches('0').trim_end_matches('.').to_string(); + if s.is_empty() { "0".into() } else { s } + }; + v.iter().map(|(a, b)| format!("{},{}", f(*a), f(*b))).collect::>().join(" ") +} + +/// A new pair in the widest gap between the destination fractions (the source fraction +/// interpolated between its neighbours'), or (0, 0) for the first. +pub fn add_pair(v: &[(f64, f64)]) -> (f64, f64) { + if v.is_empty() { + return (0.0, 0.0); + } + let mut s: Vec<(f64, f64)> = v.to_vec(); + s.sort_by(|a, b| a.1.total_cmp(&b.1)); + let n = s.len(); + let (mut best, mut gap) = (0, -1.0); + for i in 0..n { + let g = (s[(i + 1) % n].1 - s[i].1).rem_euclid(1.0); + let g = if n == 1 { 1.0 } else { g }; + if g > gap { + gap = g; + best = i; + } + } + let (a, b) = (s[best], s[(best + 1) % n]); + let ds = if n == 1 { 1.0 } else { (b.0 - a.0).rem_euclid(1.0) }; + ((a.0 + ds * 0.5).rem_euclid(1.0), (a.1 + gap * 0.5).rem_euclid(1.0)) +} + +#[allow(clippy::too_many_arguments)] +fn reshape_buttons( + app: &mut EffectcraftApp, + ui: &mut egui::Ui, + p: &egui::Painter, + layer: &Layer, + g: &PropGroup, + ectx: &EvalCtx, + r: Rect, + actions: &mut Actions, +) { + let t = app.tokens; + let euid = g.uid; + let pairs = parse_pairs(&str_value(layer, ectx, g, "correspondencePoints")); + let b1 = Rect::from_min_size(pos2(r.min.x + 24.0, r.min.y + 4.0), vec2(84.0, 22.0)); + let b2 = Rect::from_min_size(pos2(b1.max.x + 8.0, b1.min.y), vec2(96.0, 22.0)); + let b3 = Rect::from_min_size(pos2(b2.max.x + 8.0, b1.min.y), vec2(56.0, 22.0)); + if widgets::text_button(ui, b1, "Add Point", false, &t, egui::Id::new(("reshape-add", euid))).clicked() { + let mut v = pairs.clone(); + v.push(add_pair(&pairs)); + set_str(actions, layer, g, "correspondencePoints", format_pairs(&v), None); + } + app.auto.add(&format!("effectControls.effect.{euid}.reshape.add"), b1, "Add Point"); + if widgets::text_button(ui, b2, "Remove Point", false, &t, egui::Id::new(("reshape-remove", euid))).clicked() && !pairs.is_empty() { + let mut v = pairs.clone(); + v.pop(); + set_str(actions, layer, g, "correspondencePoints", format_pairs(&v), None); + } + app.auto.add(&format!("effectControls.effect.{euid}.reshape.remove"), b2, "Remove Point"); + if widgets::text_button(ui, b3, "Clear", false, &t, egui::Id::new(("reshape-clear", euid))).clicked() && !pairs.is_empty() { + set_str(actions, layer, g, "correspondencePoints", String::new(), None); + } + app.auto.add(&format!("effectControls.effect.{euid}.reshape.clear"), b3, "Clear"); + let status = if pairs.is_empty() { + "Correspondence: automatic (select the effect to edit points on the viewer)".to_string() + } else { + format!("{} correspondence point(s): drag them along the mask outlines in the viewer", pairs.len()) + }; + p.text(pos2(r.min.x + 24.0, r.min.y + 38.0), Align2::LEFT_CENTER, status, Tokens::ui(11.5), t.text_dim); +} + +/// Arc-length fraction (0..1) of the point of a polyline nearest to `q`. +pub fn nearest_fraction(pts: &[[f64; 2]], closed: bool, q: [f64; 2]) -> f64 { + let n = pts.len(); + if n < 2 { + return 0.0; + } + let total = effectcraft_engine::effects::util::poly_length(pts, closed).max(1e-9); + let segs = if closed { n } else { n - 1 }; + let (mut best, mut best_s, mut acc) = (f64::INFINITY, 0.0, 0.0); + for i in 0..segs { + let (a, b) = (pts[i], pts[(i + 1) % n]); + let (dx, dy) = (b[0] - a[0], b[1] - a[1]); + let l2 = dx * dx + dy * dy; + let l = l2.sqrt(); + let tt = if l2 > 0.0 { (((q[0] - a[0]) * dx + (q[1] - a[1]) * dy) / l2).clamp(0.0, 1.0) } else { 0.0 }; + let d = (a[0] + dx * tt - q[0]).hypot(a[1] + dy * tt - q[1]); + if d < best { + best = d; + best_s = acc + l * tt; + } + acc += l; + } + (best_s / total).rem_euclid(1.0) +} + +/// The Composition viewer's Reshape controls while the effect is selected: the source (yellow) +/// and destination (cyan) mask outlines and each correspondence pair as a line between its two +/// handles; dragging a handle slides it along its outline (one undo step per drag). +#[allow(clippy::too_many_arguments)] +pub fn reshape_overlay( + app: &mut EffectcraftApp, + ui: &mut egui::Ui, + painter: &egui::Painter, + map: &super::viewer::ViewerMap, + ectx: &EvalCtx, + layer: &Layer, + g: &PropGroup, + m: &Mat3, + actions: &mut Actions, +) { + let shapes = effectcraft_engine::render::masks::shapes(ectx, layer); + let mask = |id: &str| -> Option<(Vec<[f64; 2]>, bool)> { + let i = g.get(id).map(|pr| ectx.value(layer, pr).as_enum()).unwrap_or(0) as usize; + let s = shapes.get(i.checked_sub(1)?)?; + (s.points.len() >= 2).then(|| (s.points.clone(), s.closed)) + }; + let (Some(src), Some(dst)) = (mask("sourceMask"), mask("destinationMask")) else { return }; + let to_screen = |q: [f64; 2]| map.to_screen(fw::layer_to_comp(m, q)); + let outline = |pts: &[[f64; 2]], closed: bool, col: Color32| { + let mut v: Vec = pts.iter().map(|q| to_screen(*q)).collect(); + if closed && let Some(f) = v.first().copied() { + v.push(f); + } + painter.add(egui::Shape::dashed_line(&v, Stroke::new(1.0, col), 5.0, 3.0)); + }; + let (ys, cs) = (Color32::from_rgb(0xff, 0xd8, 0x40), Color32::from_rgb(0x40, 0xd8, 0xff)); + outline(&src.0, src.1, ys); + outline(&dst.0, dst.1, cs); + let at = |pts: &[[f64; 2]], closed: bool, f: f64| { + let len = effectcraft_engine::effects::util::poly_length(pts, closed); + effectcraft_engine::effects::util::poly_point_at(pts, closed, f * len).0 + }; + let raw = match g.get("correspondencePoints").map(|pr| ectx.value(layer, pr)) { + Some(Value::Str(s)) => s, + _ => String::new(), + }; + let pairs = parse_pairs(&raw); + let ctx = ui.ctx().clone(); + for (i, (fs, fd)) in pairs.iter().enumerate() { + let (ps, pd) = (to_screen(at(&src.0, src.1, *fs)), to_screen(at(&dst.0, dst.1, *fd))); + painter.line_segment([ps, pd], Stroke::new(1.0, Color32::from_white_alpha(150))); + for (end, s, col) in [(0usize, ps, ys), (1, pd, cs)] { + let hr = Rect::from_center_size(s, vec2(12.0, 12.0)); + let id = egui::Id::new(("reshape-pt", g.uid, i, end)); + let resp = ui.interact(hr, id, Sense::drag()); + let hot = resp.hovered() || resp.dragged(); + painter.rect_filled(Rect::from_center_size(s, vec2(7.0, 7.0)), 0.0, if hot { Color32::WHITE } else { col }); + painter.rect_stroke(Rect::from_center_size(s, vec2(7.0, 7.0)), 0.0, Stroke::new(1.0, Color32::BLACK), StrokeKind::Outside); + let which = if end == 0 { "source" } else { "dest" }; + app.auto.add(&format!("viewer.reshape.{}.{which}.{i}", g.uid), hr, &format!("Correspondence point {} ({which})", i + 1)); + if hot { + ctx.set_cursor_icon(egui::CursorIcon::Move); + } + if resp.dragged() + && let Some(pos) = resp.interact_pointer_pos() + && let Some(lp) = fw::comp_to_layer(m, map.to_comp(pos)) + { + let mut v = pairs.clone(); + if end == 0 { + v[i].0 = nearest_fraction(&src.0, src.1, lp); + } else { + v[i].1 = nearest_fraction(&dst.0, dst.1, lp); + } + let key = gesture_key(ui, id, resp.drag_started()); + if let Some(pr) = g.get("correspondencePoints") { + actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": pr.uid, "value": format_pairs(&v), "merge": key}))); + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn offset_points_and_palettes_round_trip() { + let p = OffsetPoints::parse("0.5,0.75 0.1,0.5"); + assert_eq!(p.0, vec![[0.1, 0.5], [0.5, 0.75]]); + assert_eq!(OffsetPoints::parse(&p.format()), p); + assert!(OffsetPoints::parse("").0.is_empty()); + let stops = vec![(0.0, [1.0, 0.0, 0.0, 1.0]), (0.5, [0.0, 0.25, 1.0, 0.5])]; + let s = format_palette(&stops); + assert_eq!(s, "0:1,0,0 0.5:0,0.25,1,0.5"); + assert_eq!(ColoramaPalette::parse(&s).unwrap().stops, stops); + let m = split_map("0,0 1,0.5 | | 0,1 1,0"); + assert_eq!(m[1], fw::CurvePoints::identity()); + assert_eq!(split_map(&join_map(&m)), m); + } + + #[test] + fn reshape_pairs_and_outline_fractions() { + assert_eq!(parse_pairs("0.25,0 1.5,0.5"), vec![(0.25, 0.0), (0.5, 0.5)]); + assert_eq!(format_pairs(&[(0.25, 0.0), (0.5, 0.125)]), "0.25,0 0.5,0.125"); + assert_eq!(add_pair(&[]), (0.0, 0.0)); + assert_eq!(add_pair(&[(0.1, 0.0)]), (0.6, 0.5)); + let (s, d) = add_pair(&[(0.0, 0.0), (0.2, 0.25)]); + assert!((d - 0.625).abs() < 1e-9 && (s - 0.6).abs() < 1e-9, "{s} {d}"); + // A 10×10 square (closed): the point nearest (10, 5) is 3/8 of the way round. + let sq = [[0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [0.0, 10.0]]; + assert!((nearest_fraction(&sq, true, [12.0, 5.0]) - 0.375).abs() < 1e-9); + assert!(nearest_fraction(&sq, true, [-1.0, -1.0]).abs() < 1e-9); + } +} diff --git a/crates/ui-egui/src/panels/misc.rs b/crates/ui-egui/src/panels/misc.rs index 7b750be7..6c9bac94 100644 --- a/crates/ui-egui/src/panels/misc.rs +++ b/crates/ui-egui/src/panels/misc.rs @@ -21,7 +21,16 @@ pub fn preview(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let t = app.tokens; let ctx = ui.ctx().clone(); let p = ui.painter().with_clip_rect(rect); - let y = rect.min.y + 12.0; + // The panel scrolls when it is shorter than its controls (the default workspace shows just + // the transport row, as in After Effects). + let scroll_id = egui::Id::new("preview-scroll"); + let mut scroll: f32 = ctx.data(|d| d.get_temp(scroll_id).unwrap_or(0.0)); + if ui.rect_contains_pointer(rect) { + scroll = (scroll - ui.input(|i| i.smooth_scroll_delta.y)).max(0.0); + } + let panel_clip = ui.clip_rect(); + ui.set_clip_rect(rect.intersect(panel_clip)); + let y = rect.min.y + 12.0 - scroll; let bw = 30.0; let total = bw * 5.0 + 16.0; let mut x = rect.center().x - total / 2.0; @@ -223,6 +232,18 @@ pub fn preview(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let status = if app.playback.playing && app.playback.caching { " — caching before playback" } else { "" }; p.text(pos2(bar.min.x, bar.max.y + 12.0), Align2::LEFT_CENTER, format!("{cached} / {total} frames cached{status}"), Tokens::ui(11.0), t.text_faint); } + ui.set_clip_rect(panel_clip); + let content = yy + 30.0 - (rect.min.y - scroll); + let max = (content - rect.height()).max(0.0); + scroll = scroll.min(max); + ctx.data_mut(|d| d.insert_temp(scroll_id, scroll)); + if max > 0.0 { + // A thin scroll indicator on the right edge. + let h = (rect.height() * rect.height() / content).max(12.0); + let top = rect.min.y + (rect.height() - h) * (scroll / max); + p.rect_filled(Rect::from_min_size(pos2(rect.max.x - 5.0, top), vec2(3.0, h)), 1.5, t.text_faint.gamma_multiply(0.6)); + app.auto.add("preview.scroll", Rect::from_min_size(pos2(rect.max.x - 6.0, rect.min.y), vec2(6.0, rect.height())), "Preview scroll"); + } } fn fmt_rate(r: f64) -> String { diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index d82c6d97..28c26ff1 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -14,6 +14,7 @@ pub mod expr_editor; pub mod flowchart; pub mod footage_panel; pub mod forms; +pub mod fx_editors; pub mod fx_widgets; pub mod graph; pub mod graph_tools; diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 4b657f52..57434526 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -533,6 +533,10 @@ pub struct UiState { /// The panel maximized to fill the dock area (`~`), if any. #[serde(default)] pub maximized: Option, + /// Locked Composition / Timeline tabs (panel ids): opening another composition does not + /// bring a locked panel forward (the tab's lock icon, as After Effects' viewer lock). + #[serde(default)] + pub locked_tabs: BTreeSet, pub focused: PanelKind, pub viewer: ViewerState, pub timeline: TimelineState, @@ -634,6 +638,7 @@ impl Default for UiState { floating: vec![], saved_floating: Default::default(), maximized: None, + locked_tabs: BTreeSet::new(), focused: PanelKind::Composition, viewer: ViewerState::default(), timeline: TimelineState::default(), diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 9703d6ba..184bf9ec 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -154,8 +154,27 @@ pub fn dropdown(ui: &mut Ui, rect: Rect, text: &str, t: &Tokens, id: egui::Id) - let resp = ui.interact(rect, id, Sense::click()); ui.painter().rect_filled(rect, 3.0, if resp.hovered() { t.hover } else { t.field_bg }); ui.painter().rect_stroke(rect, 3.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside); - ui.painter().text(pos2(rect.min.x + 7.0, rect.center().y), Align2::LEFT_CENTER, text, Tokens::ui(11.5), t.text); + // Text that doesn't fit before the arrow is elided (narrow panels). + let room = rect.width() - 7.0 - 18.0; + let fits = |s: &str| ui.painter().layout_no_wrap(s.to_string(), Tokens::ui(11.5), t.text).size().x <= room; + let shown = if fits(text) { + text.to_string() + } else { + let chars: Vec = text.chars().collect(); + let mut n = chars.len(); + loop { + n = n.saturating_sub(1); + let s: String = chars[..n].iter().collect::().trim_end().to_string() + "…"; + if n == 0 || fits(&s) { + break s; + } + } + }; + ui.painter().text(pos2(rect.min.x + 7.0, rect.center().y), Align2::LEFT_CENTER, shown, Tokens::ui(11.5), t.text); icons::paint(ui.painter(), Rect::from_center_size(pos2(rect.max.x - 9.0, rect.center().y), vec2(8.0, 8.0)), Icon::ChevronDown, t.text_dim); + if text.chars().count() > 0 && !fits(text) { + return resp.on_hover_text(text.to_string()); + } resp } diff --git a/crates/ui-egui/tests/ui_fx_editors.rs b/crates/ui-egui/tests/ui_fx_editors.rs new file mode 100644 index 00000000..ef72d86a --- /dev/null +++ b/crates/ui-egui/tests/ui_fx_editors.rs @@ -0,0 +1,269 @@ +//! Headless checks (egui_kittest) of the visual editors for hidden / text effect parameters +//! (M13.10): Lumetri's RGB and hue/saturation curves, Colorama's output cycle wheel, Glow's +//! colour map and Reshape's correspondence points (Effect Controls buttons and viewer handles). +//! Each edit goes through `prop.set` and undoes in one step. + +use effectcraft_engine::Session; +use effectcraft_engine::keyframe::Value as KV; +use effectcraft_engine::project::{LayerId, Node, PropGroup}; +use effectcraft_ui_egui::EffectcraftApp; +use effectcraft_ui_egui::dock::PanelKind; +use egui::{Event, Pos2, pos2}; +use egui_kittest::Harness; +use serde_json::json; + +fn harness(s: Session) -> Harness<'static, EffectcraftApp> { + let mut app = EffectcraftApp::new(s); + app.show_panel(PanelKind::EffectControls); + app.toggle_maximize(PanelKind::EffectControls); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1400.0)).build_eframe(|_| app); + h.run_steps(4); + h +} + +/// A comp with one 320×180 solid carrying `effect`; returns the session, layer and effect uid. +fn with_effect(effect: &str) -> (Session, u64, u64) { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Fx", "width": 320, "height": 180, "frameRate": 24, "duration": 4})).unwrap(); + let l = s.execute("layer.newSolid", json!({"name": "S", "color": "#6080a0"})).unwrap()["layer"].as_u64().unwrap(); + let fx = s.execute("effect.apply", json!({"layers": [l], "effect": effect})).unwrap()["effects"][0].as_u64().unwrap(); + s.state.selected_layers = vec![LayerId(l)]; + (s, l, fx) +} + +fn group(h: &Harness<'_, EffectcraftApp>, layer: u64, fx: u64) -> PropGroup { + h.state().session.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.find_group(fx).unwrap().clone() +} + +/// A parameter's value by its spec-id path inside the effect (`curves/rgbCurves/master`). +fn value(h: &Harness<'_, EffectcraftApp>, layer: u64, fx: u64, path: &str) -> KV { + let g = group(h, layer, fx); + let mut cur = &g; + let parts: Vec<&str> = path.split('/').collect(); + for p in &parts[..parts.len() - 1] { + cur = cur.sub(p).unwrap_or_else(|| panic!("no group {p}")); + } + cur.get(parts[parts.len() - 1]).unwrap().value.clone() +} + +fn s(v: KV) -> String { + match v { + KV::Str(s) => s, + other => panic!("{other:?}"), + } +} + +/// Close every parameter group of the effect except those named (and their parents). +fn only_groups(h: &mut Harness<'_, EffectcraftApp>, layer: u64, fx: u64, keep: &[&str]) { + fn walk(g: &PropGroup, keep: &[&str], out: &mut Vec) { + for c in &g.children { + if let Node::Group(sg) = c { + if !keep.contains(&sg.match_id.as_str()) { + out.push(sg.uid); + } + walk(sg, keep, out); + } + } + } + let g = group(h, layer, fx); + let mut closed = vec![]; + walk(&g, keep, &mut closed); + h.state_mut().ui.fx_closed.extend(closed); + h.run_steps(3); +} + +fn rect(h: &Harness<'_, EffectcraftApp>, id: &str) -> egui::Rect { + let e = h.state().auto.previous.iter().chain(h.state().auto.elements.iter()).find(|e| e.id == id).cloned().unwrap_or_else(|| { + let near: Vec = h + .state() + .auto + .elements + .iter() + .map(|e| e.id.clone()) + .filter(|i| i.starts_with("effectControls.effect") || i.starts_with("viewer.reshape")) + .collect(); + panic!("no element {id}; have {near:?}") + }); + egui::Rect::from_min_size(pos2(e.rect[0], e.rect[1]), egui::vec2(e.rect[2], e.rect[3])) +} + +fn at(r: egui::Rect, fx: f32, fy: f32) -> Pos2 { + pos2(r.min.x + r.width() * fx, r.min.y + r.height() * fy) +} + +fn click_n(h: &mut Harness<'_, EffectcraftApp>, p: Pos2, n: u32) { + h.event(Event::PointerMoved(p)); + h.step(); + // (all in one frame: the harness' frame time is longer than a double-click's) + for _ in 0..n { + h.event(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.event(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + } + h.step(); + h.run_steps(2); +} + +fn click_id(h: &mut Harness<'_, EffectcraftApp>, id: &str) { + let p = rect(h, id).center(); + click_n(h, p, 1); +} + +fn drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2) { + h.event(Event::PointerMoved(from)); + h.step(); + h.event(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.step(); + for k in 1..=6 { + let t = k as f32 / 6.0; + h.event(Event::PointerMoved(from + (to - from) * t)); + h.step(); + } + h.event(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + h.run_steps(3); +} + +fn undo(h: &mut Harness<'_, EffectcraftApp>) { + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + h.run_steps(2); +} + +#[test] +fn lumetri_rgb_and_hue_saturation_curve_editors() { + let (s0, l, fx) = with_effect("Lumetri Color"); + let mut h = harness(s0); + only_groups(&mut h, l, fx, &["curves", "rgbCurves", "hueSaturationCurves"]); + let pre = format!("effectControls.effect.{fx}.lumetri.rgbCurves"); + for k in 0..4 { + rect(&h, &format!("{pre}.channel.{k}")); + } + rect(&h, &format!("{pre}.point.0")); + // Red channel: a click in the graph adds a point to the red curve only. + click_id(&mut h, &format!("{pre}.channel.1")); + let gr = rect(&h, &format!("{pre}.graph")); + click_n(&mut h, at(gr, 0.5, 0.25), 1); + let red = s(value(&h, l, fx, "curves/rgbCurves/red")); + assert_eq!(red.split_whitespace().count(), 3, "{red}"); + assert_eq!(s(value(&h, l, fx, "curves/rgbCurves/master")), "0,0 1,1"); + rect(&h, &format!("{pre}.point.2")); + undo(&mut h); + assert_eq!(s(value(&h, l, fx, "curves/rgbCurves/red")), "0,0 1,1"); + // Hue vs Hue: pick the tab, add a point, drag it: one undo step for the drag. + let hs = format!("effectControls.effect.{fx}.lumetri.hueSatCurves"); + click_id(&mut h, &format!("{hs}.curve.1")); + let gr = rect(&h, &format!("{hs}.graph")); + click_n(&mut h, at(gr, 0.3, 0.5), 1); + let v = s(value(&h, l, fx, "curves/hueSaturationCurves/hueVsHue")); + assert_eq!(v.split_whitespace().count(), 1, "{v}"); + assert_eq!(s(value(&h, l, fx, "curves/hueSaturationCurves/hueVsSat")), ""); + let p0 = rect(&h, &format!("{hs}.point.0")).center(); + let undo_len = h.state().session.history.undo.len(); + drag(&mut h, p0, p0 + egui::vec2(0.0, -gr.height() * 0.25)); + let v = s(value(&h, l, fx, "curves/hueSaturationCurves/hueVsHue")); + let y: f32 = v.split(',').nth(1).unwrap().parse().unwrap(); + assert!(y > 0.7, "{v}"); + assert_eq!(h.state().session.history.undo.len(), undo_len + 1, "one undo step per drag"); + // Reset empties the curve. + click_id(&mut h, &format!("{hs}.reset")); + assert_eq!(s(value(&h, l, fx, "curves/hueSaturationCurves/hueVsHue")), ""); +} + +#[test] +fn colorama_output_cycle_wheel() { + let (s0, l, fx) = with_effect("Colorama"); + let mut h = harness(s0); + only_groups(&mut h, l, fx, &["outputCycle"]); + let pre = format!("effectControls.effect.{fx}.colorama"); + // The Hue Cycle preset has six stops. + for i in 0..6 { + rect(&h, &format!("{pre}.stop.{i}")); + } + // Clicking the ring adds a stop: the palette becomes Custom with seven stops. + let w = rect(&h, &format!("{pre}.wheel")); + let mid = w.width() / 2.0 - 20.0 - 9.0; + let ring = pos2(w.center().x + mid * 0.7071, w.center().y + mid * 0.7071); + click_n(&mut h, ring, 1); + assert_eq!(value(&h, l, fx, "outputCycle/usePresetPalette"), KV::Enum(8)); + let pal = s(value(&h, l, fx, "outputCycle/palette")); + assert_eq!(pal.split_whitespace().count(), 7, "{pal}"); + rect(&h, &format!("{pre}.stop.6")); + // Dragging a stop moves its position. + let st = rect(&h, &format!("{pre}.stop.0")).center(); + drag(&mut h, st, pos2(w.max.x - 6.0, w.center().y)); + let pal2 = s(value(&h, l, fx, "outputCycle/palette")); + assert_ne!(pal, pal2); + assert!(pal2.split_whitespace().any(|t| t.starts_with("0.25") || t.starts_with("0.24") || t.starts_with("0.26")), "{pal2}"); + rect(&h, &format!("{pre}.color")); +} + +#[test] +fn glow_arbitrary_map_editor() { + let (mut s0, l, fx) = with_effect("Glow"); + let colors = s0.active_comp().unwrap().layer(LayerId(l)).unwrap().props.find_group(fx).unwrap().get("colors").unwrap().uid; + s0.execute("prop.set", json!({"layer": l, "prop": colors, "value": 1})).unwrap(); + let mut h = harness(s0); + let pre = format!("effectControls.effect.{fx}.glow"); + rect(&h, &format!("{pre}.colorMap")); + assert!(!h.state().auto.elements.iter().any(|e| e.id == format!("{pre}.graph")), "A & B: just the strip"); + h.state_mut().session.execute("prop.set", json!({"layer": l, "prop": colors, "value": 2})).unwrap(); + h.run_steps(3); + click_id(&mut h, &format!("{pre}.channel.2")); + let gr = rect(&h, &format!("{pre}.graph")); + click_n(&mut h, at(gr, 0.5, 0.9), 1); + let m = s(value(&h, l, fx, "arbitraryMap")); + let parts: Vec<&str> = m.split('|').map(str::trim).collect(); + assert_eq!(parts.len(), 3, "{m}"); + assert_eq!(parts[0], "0,0 1,1"); + assert_eq!(parts[2].split_whitespace().count(), 3, "{m}"); +} + +#[test] +fn reshape_correspondence_points_on_the_viewer() { + let (mut s0, l, fx) = with_effect("Reshape"); + let sq = |x0: f64, y0: f64, d: f64| json!([[x0, y0], [x0 + d, y0], [x0 + d, y0 + d], [x0, y0 + d]]); + s0.execute("mask.new", json!({"layer": l, "vertices": sq(60.0, 40.0, 60.0), "closed": true})).unwrap(); + s0.execute("mask.new", json!({"layer": l, "vertices": sq(180.0, 60.0, 80.0), "closed": true})).unwrap(); + let g = s0.active_comp().unwrap().layer(LayerId(l)).unwrap().props.find_group(fx).unwrap().clone(); + s0.execute("prop.set", json!({"layer": l, "prop": g.get("sourceMask").unwrap().uid, "value": 1})).unwrap(); + s0.execute("prop.set", json!({"layer": l, "prop": g.get("destinationMask").unwrap().uid, "value": 2})).unwrap(); + let mut h = harness(s0); + let pre = format!("effectControls.effect.{fx}.reshape"); + click_id(&mut h, &format!("{pre}.add")); + assert_eq!(s(value(&h, l, fx, "correspondencePoints")), "0,0"); + click_id(&mut h, &format!("{pre}.add")); + assert_eq!(s(value(&h, l, fx, "correspondencePoints")).split_whitespace().count(), 2); + click_id(&mut h, &format!("{pre}.remove")); + assert_eq!(s(value(&h, l, fx, "correspondencePoints")), "0,0"); + // With the effect selected, the viewer shows the handles: drag the destination one along + // its outline. + h.state_mut().toggle_maximize(PanelKind::EffectControls); + h.state_mut().session.state.selected_props = vec![(LayerId(l), fx)]; + for _ in 0..6 { + h.step(); + } + let d0 = rect(&h, &format!("viewer.reshape.{fx}.dest.0")); + rect(&h, &format!("viewer.reshape.{fx}.source.0")); + let undo_len = h.state().session.history.undo.len(); + drag(&mut h, d0.center(), d0.center() + egui::vec2(60.0, 0.0)); + let v = s(value(&h, l, fx, "correspondencePoints")); + let d: f64 = v.split(',').nth(1).unwrap().parse().unwrap(); + assert!(d > 0.02 && d < 0.3, "moved along the top edge: {v}"); + assert_eq!(h.state().session.history.undo.len(), undo_len + 1); +} + +/// UI fixes of M13.10: the Composition / Timeline tabs' close button, swatch and lock; the +/// Preview panel scrolls in the default workspace (where it shows just the transport). +#[test] +fn composition_tabs_and_scrolling_preview() { + let mut s = Session::default(); + s.execute("file.openDemoProject", json!({})).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.run_steps(4); + rect(&h, "panel.tab.Timeline.close"); + rect(&h, "preview.scroll"); + click_id(&mut h, "panel.tab.Composition.lock"); + assert!(h.state().ui.locked_tabs.contains("Composition")); + click_id(&mut h, "panel.tab.Composition.lock"); + assert!(h.state().ui.locked_tabs.is_empty()); + click_id(&mut h, "panel.tab.Timeline.close"); + assert!(!h.state().ui.dock.contains(PanelKind::Timeline)); +} diff --git a/docs/parity.md b/docs/parity.md index 93a24c73..0e7dfb00 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -16,7 +16,7 @@ How close EffectCraft is to After Effects 2026, feature by feature, and how much | **Wall-clock estimate** | **≈ 4–5 hours** with five agents in parallel; ≈ 3 hours for 100% of P0 + P1 | Partial features: effect implementations (42 simplified effects), GPU coverage (Advanced 3D, more -GPU effects), the web app (no threads for viewer frames and Roto Brush), the Learn panel, ScriptUI resource +GPU effects), the web app (GPU effects, disk cache), the Learn panel, ScriptUI resource strings and `onDraw`, Essential Graphics font and scale controls, and the learned-model quality of Roto Brush and face tracking (both are classical). Menu items still absent: Save Frame As ▸ Photoshop Layers / ProEXR, Open in Essential Graphics, Vanishing Point import, Watch Folder. @@ -128,10 +128,10 @@ the disk cache in the formats wave; the 3D Camera Tracker in M12.6.) | Compositions | ≈ 80% | 3.5 | Mocha-style planar tracks for templates, Essential Graphics' rare controls (font menus, mirrored properties) (the marker dialog, Composition Flowchart, Essential Graphics with master properties, `.ectemplate` templates and Responsive Design — Time landed: CMP-6, CMP-7) | | Animation | 70% | 9.0 | puppet depth beyond pins and recording (puppet pin recording with Record Options landed in M13.1), Wiggler/Smoother/Motion Sketch (motion-path handles and the graph editor transform box landed in M5.8; keyframe colour labels and Select Keyframe Label Group, Graph Editor snapping to markers / layer ends in M13.5) | | Text | ≈ 94% | 0.5 | no extruded strokes, variable-axis animation changes outlines but not advances (OpenType features — stylistic sets, discretionary ligatures, contextual / stylistic alternates, swash, titling, ordinals, fractions, figure styles, true small caps / all small caps and superior / inferior glyphs with faux fallback — per character with the Character panel's OpenType popup and `text.fontFeatures` landed in M13.2; vertical Roman / Tate-Chu-Yoko, forced LTR paragraphs, caret on animated and path text, Variable Font Axes and Lottie style runs landed in M13.5; extruded, bevelled text in M7.6; per-character styles, paragraph settings, on-canvas editing and the `sourceText` style API in M9.9–M9.10) | -| Web | 85% | 1.0 | viewer frames and Roto Brush propagation still on the page's thread; GPU effects in the browser (browser storage, Web Audio, Web Worker renders/analyses, WebGPU viewer and offline install landed in M15.2) | +| Web | 93% | 0.5 | GPU effects in the browser (WebGPU compositing only on the page), no disk cache, no shared-memory threads inside one engine instance (browser storage, Web Audio, Web Worker renders/analyses, WebGPU viewer and offline install landed in M15.2; viewer frames in frame workers fed by project diffs, Roto Brush propagation in a worker, non-blocking `wait: true` jobs and a browser Media Browser (File System Access folders, browser storage) in M13.10) | | 3D | 88% | 5.5 | multi-view layouts, the Extended Viewer for Advanced 3D comps (Classic 3D Extended Viewer landed in M13.5 UI completion); collapsed precomps of another size seen through the parent's camera render (fixed in M13.2); stereo rigs, orbit nulls, lights controlled by the camera, cameras/lights from glTF models, environment backgrounds, Advanced 3D motion blur, blend modes and track mattes landed in M7.7; Classic 3D iris-shaped bokeh with highlights, progressive depth of field on tilted layers and the focus-link commands landed in M13.5; Advanced 3D depth of field with the iris and highlight options, collapsed precomps as real Advanced 3D geometry and extruded text/shape strokes landed in M13.8; Advanced 3D (glTF/OBJ models, primitives, extruded text and shapes, PBR, image-based light, shadow maps, GPU rasteriser) in M7.4–M7.6 | | Effects | ≈ 80% | 4.0 | GPU versions of the remaining effects (58 run on the GPU since M12.7) and the missing controls listed as partial in [effects.md](effects.md) (every After Effects effect exists since M9.11, M12.5 and M12.6; parameter names, order, twirl-downs, popups, units and defaults were aligned in M9.12) | -| Interface | 74% | 5.0 | more Learn tutorials and pixel-level fidelity of dialogs (the Home ▸ Learn tab with interactive tutorials and a UI fidelity pass landed in M13.5 UI completion; Timeline outline and Project panel columns scroll horizontally, the Layer Style dialog, ROI resize handles, Pan Behind snapping and 3D Reference Axes landed in M13.5; native macOS menu bar, Timeline columns/search/reveal-add, Home screen with recent projects and all AE workspaces landed; viewer rulers/snapping/channels/snapshots landed in M0.13) | +| Interface | 74% | 5.0 | more Learn tutorials and pixel-level fidelity of dialogs (the Home ▸ Learn tab with interactive tutorials and a UI fidelity pass landed in M13.5 UI completion; visual editors for Lumetri RGB / hue-saturation curves, Colorama's output cycle wheel, Glow's colour map and Reshape's correspondence points (viewer handles), a scrolling Preview panel and AE-style Composition / Timeline tabs (close, label swatch, viewer lock) landed in M13.10; Timeline outline and Project panel columns scroll horizontally, the Layer Style dialog, ROI resize handles, Pan Behind snapping and 3D Reference Axes landed in M13.5; native macOS menu bar, Timeline columns/search/reveal-add, Home screen with recent projects and all AE workspaces landed; viewer rulers/snapping/channels/snapshots landed in M0.13) | | Project | ≈ 68% | 5.5 | auto-save, folder moves, OCIO displays beyond the built-in tone map (Color Engine with OCIO/ACES working spaces, HDR compand/tone mapping, Rec. 2100 PQ/HLG output, Feet + Frames, display colour management, Simulate Output and the locked viewer landed in M7.7; proxies and Interpret Footage fields / pixel aspect / alpha guess landed: PRJ-8, PRJ-3) | | Masks & roto | 74% | 5.0 | Roto Brush's learned (3.0) segmentation model (variable-width mask feather points with the Mask Feather tool landed in M13.5; mask tracking and Mask Interpolation landed in M6.6; Roto Brush & Refine Edge with graph-cut segmentation, flow propagation, edge matting, decontamination and Freeze in M6.7) | | Preview | 72% | 3.5 | GPU bokeh depth of field, wireframes and Advanced 3D compositing (the Preview panel's five shortcuts with their own Include / Loop / Cache Before Playback / Range / Play From / Frame Rate / Skip / Resolution / Full Screen / stop options and `playback.settings.get/set` landed in M13.8; Classic 3D runs and adjustment layers composite on the GPU since M12.7; persistent disk cache with the blue cache bar landed; region of interest, snapshots, exposure and Fast Previews landed in M0.13) | @@ -233,7 +233,7 @@ precompose), **Auto-trace** (alpha / RGB / luminance, current frame or work area or a new layer) and **Align Video to Data** (JSON / CSV / TSV time keys) replace their disabled menu entries. Not yet: creating a reference frame by editing a still (After Effects hands it to Photoshop), Content-Aware Fill's learned model (ours is PatchMatch + flow propagation), and Media -Browser in the web app. +Browser in the web app (landed in M13.10: browser storage and File System Access folders). ## Highest-value gaps, in order diff --git a/docs/web.md b/docs/web.md index 72eb75ff..52d3419c 100644 --- a/docs/web.md +++ b/docs/web.md @@ -41,14 +41,15 @@ with every L0–L4 crate and `effectcraft-ui-egui`, for `wasm32-unknown-unknown` URL flags: `?empty` (a blank project; the session is not restored or recorded), `?demo` (the demo project instead of the last session), `?home` (show the start screen), `?storage=indexeddb` / -`?storage=memory` (force a storage backend), `?noworkers` (render and analyse on the page's -thread), `?nosw` (don't register the service worker). +`?storage=memory` (force a storage backend), `?noworkers` (render, analyse and draw viewer +frames on the page's thread), `?frameworkers=N` (frame workers, default 2; 0 = viewer frames on +the page's thread), `?nosw` (don't register the service worker). ## How the desktop pieces map to the browser | Desktop | Web | |---|---| -| frame render thread pool (`ui-egui/src/frames.rs`) | `Frames::pump`: queued frames render on the UI thread after each egui frame, most important first (the viewer's frame, then prefetch), within 40 ms (24 ms while playing) | +| frame render thread pool (`ui-egui/src/frames.rs`) | frame workers (below): `Frames::pump` hands queued frames to them, most important first (the viewer's frame, then prefetch); frames the GPU compositor can keep on the GPU render on the page. Without workers, queued frames render on the UI thread after each egui frame within 40 ms (24 ms while playing) | | Render Queue on a background thread | a Web Worker running a second engine instance (below); progress streams back, the page never waits | | tracker / mask tracker / Warp Stabilizer / 3D Camera Tracker / Roto Brush Freeze threads | the same workers; the analysed property group comes back as one undo step | | `rayon` parallel loops (raster, effects, export batches) | rayon's global pool falls back to the calling thread when threads are unavailable: same code, serial | @@ -59,6 +60,7 @@ thread), `?nosw` (don't register the service worker). | media reads (`MediaPool`, `probe`) | `files::WebImporter`: bytes from the file table, handed to `MediaPool::add_bytes` / `probe_bytes` | | export writes (`effectcraft-export`) | the job's `sink` (`effectcraft_host::FileExporter { sink }`): files land in the table and download after the render; several files (an image sequence) download as one stored `.zip` | | rfd file dialogs | `` for File ▸ Open / Import; drop files anywhere on the page (`.ecproj` opens, everything else imports); "Save As" / "Output To" pick a download name | +| Media Browser on the file system | browser storage and folders opened with the File System Access API (below) | | system fonts | not scanned; the bundled fonts (Inter, Noto Serif, JetBrains Mono) are always there | | TCP control channel / MCP | `window.effectcraft` (below) | | cpal audio output | Web Audio (below) | @@ -109,10 +111,65 @@ compiled twice) and its own memory (`crates/engine/src/offload.rs`, `src/worker. tracker / mask group is replaced; other edits made meanwhile are kept) and the downloads start. Stopping a job terminates its worker (a worker can't be interrupted mid-frame): the item being -rendered becomes "User Stopped", an analysis writes nothing. `renderQueue.render {wait: true}` (the -default for agents) and analysis commands with `wait: true` still run on the page's thread and -return when done. Roto Brush propagation fills the session's segmentation cache rather than the -project, so it runs on the page's thread too, as do viewer frames (`Frames::pump`). +rendered becomes "User Stopped", an analysis writes nothing. Every job shows in Window ▸ Progress +(`jobs.list`) with its progress and a cancel button (`jobs.cancel`). + +**Roto Brush propagation** runs in a job worker too (`WorkerJob::RotoPropagate`): the worker +streams the segmentations it computes back at most every 250 ms (`WorkerReply::Segs`: each +`FrameSeg` in a compact binary form, run-length coded planes, base64), the page puts them into its +segmentation cache and relays them to the frame workers. Stopping it keeps the frames propagated +so far. + +**`wait: true`** (the default of `renderQueue.render` for agents, and an option of the analysis +and Roto Brush commands) no longer blocks the page: over `window.effectcraft` (the control +channel) such a command runs with `wait: false`, and the promise resolves when the job it started +ends, with what the blocking command returns (final render item statuses, the analysis status) +or rejects with the job's error (`ui-egui/src/control.rs`, `JobWaiter`). Engine commands executed +directly on the session (scripts) still block. + +### Viewer frames: frame workers + +Viewer frames (the frame on screen, RAM preview and prefetch) render in long-lived **frame +workers** (`src/frames.rs`, `crates/engine/src/remote.rs`), two by default. Each holds a replica +of the project: + +1. Before a render, the page sends a worker the footage files it lacks, then a sync: the whole + project the first time, afterwards a **diff** of the serialized project from the revision the + worker holds (`remote::diff`: object members added / changed / removed, arrays element-wise + when their length is unchanged; a slider drag sends a few hundred bytes, not the project). +2. Then the render request (`FrameMsg::Render`: revision, comp, time, render options). The worker + renders on its CPU renderer and posts the frame back as premultiplied RGBA8 in a transferred + buffer (`FrameReply::Frame`); the page puts it in the frame cache. +3. One frame per worker at a time, so the page always picks what renders next (the viewer's frame + before prefetch); a request for a revision the worker does not hold is refused, a worker that + lost its replica asks for the whole project again (`FrameReply::Resync`). + +Frames the WebGPU compositor can finish alone still render on the page (they stay on the GPU, no +readback); everything else (CPU-only effects, 3D runs, the Software Only renderer, WebGL2) goes to +the workers. If no worker is usable, frames render on the page's thread as before. +`effectcraft.info().frameWorkers` reports `{workers, alive, busy, rendered, failed, lastMs, +syncs, syncBytes}`. + +**Why workers and not wasm threads:** shared-memory threads (SharedArrayBuffer + atomics, a +`wasm-bindgen-rayon`-style pool) need the standard library rebuilt with atomics (`build-std`, +nightly Rust); the repository builds on stable, so each worker runs its own engine instance with +its own memory. The page is still cross-origin isolated (`--serve` and the service worker send +COOP / COEP), so a threaded build can be added later without changing the deployment. + +### Media Browser + +Window ▸ Media Browser browses a virtual tree (`src/browse.rs`): + +- **Browser Storage**: every file in browser storage (imported media, saved projects, files added + with **Add Files…** or **Upload Folder…**, plain file inputs that work in every browser). This is + the persistent recent list where folders can't be opened. +- **Folders**: **Open Folder…** (File System Access API, `showDirectoryPicker`, Chromium) lists a + folder from the user's disk; only the listing is read, and a file's bytes are read when it is + imported (then kept in browser storage). The folder handles are remembered in IndexedDB; after + a reload the folders come back if the browser still grants access, else **Reconnect ** + asks again. + +`mediaBrowser.list` reports the places and these actions; `mediaBrowser.action {action}` runs one. ### Audio @@ -192,15 +249,24 @@ cargo xtask web --serve 8765 & node apps/effectcraft-web/tests/smoke.mjs --url http://127.0.0.1:8765/ --out target/web/smoke ``` +It also checks the M13.10 paths: viewer frames rendered in frame workers while scrubbing with the +CPU renderer (project synced as diffs, event-loop gaps under 400 ms), a `wait: true` render that +replies when done while the page keeps running, and the Media Browser over browser storage. + Native unit tests cover the storage model (`apps/effectcraft-web/src/store.rs`: write coalescing, file table, `ConfigStore` / `FileOps`, auto-save and crash recovery through the browser store) and the worker protocol (`crates/engine/src/offload.rs`: serde round trips of -every request and reply, a render through an in-process offload). +every request and reply, a render through an in-process offload; `tests_roto.rs`: Roto Brush +propagation through an offload streaming segmentations), the frame-worker protocol +(`crates/engine/src/remote.rs`: diff / patch, a replica following edits renders the same pixels; +`crates/ui-egui/tests/ui_web_offload.rs`: the frame cache through a remote renderer, `wait: true` +deferred until the offloaded job ends) and the Media Browser's virtual tree (`media_browser.rs`, +`tests_panels.rs`). ## Gaps -- Viewer frames, RAM preview and Roto Brush propagation still render on the page's thread (one - frame at a time between UI frames). +- No shared-memory threads: each worker is a separate engine instance (memory per worker: the + project replica and the footage it reads). - GPU effects run only in the desktop GPU path: in the browser, effects run on the CPU (inside workers for renders) because the page's thread can't wait for mid-render readbacks. - No disk cache (Settings ▸ Media & Disk Cache) in the browser: the layer cache stays in memory.