diff --git a/ROADMAP.md b/ROADMAP.md index a3ed979c..55b05822 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -32,6 +32,6 @@ Measured feature by feature in [docs/parity.md](docs/parity.md) (end of 2 Octobe - **≈ 100 agent-hours of work remain by the audit's estimate.** With five Claude Opus 5.5 agents in parallel that is **≈ 8–10 hours of wall-clock time at the pace measured so far, up to ≈ 25 hours** by the audit's conservative per-feature figures. -- Hardest remaining pieces: Roto Brush, the 3D camera tracker, Advanced 3D, a scripting object model. +- Hardest remaining pieces: the 3D camera tracker, Advanced 3D, a scripting object model (Roto Brush & Refine Edge landed in M6.7). Come tell us what matters most to you on [Discord](https://discord.gg/artcraft). diff --git a/crates/engine/src/commands/roto_cmds.rs b/crates/engine/src/commands/roto_cmds.rs index 5b9db20f..95326b42 100644 --- a/crates/engine/src/commands/roto_cmds.rs +++ b/crates/engine/src/commands/roto_cmds.rs @@ -378,14 +378,12 @@ fn status(s: &mut Session, p: &Value) -> Result { let (layer, g) = group(s, cid, lid, uid)?; let (layer, g) = (layer.clone(), g.clone()); let d = data_of(&g); - let computed = s.roto_computed(cid, lid, uid); out["layer"] = json!(lid.0); out["effect"] = json!(uid); out["base"] = json!(d.base); out["span"] = json!(d.span); out["strokes"] = json!(d.strokes.len()); out["strokeFrames"] = json!(d.stroke_frames()); - out["computed"] = json!(computed.iter().filter(|(_, c)| **c).map(|(f, _)| *f).collect::>()); out["frozen"] = json!(frozen_of(&g)); out["pending"] = json!(s.roto_pending.contains(&(cid, lid, uid))); out["limits"] = json!(rt::frame_limits(&comp, &layer)); @@ -420,5 +418,7 @@ fn status(s: &mut Session, p: &Value) -> Result { } } } + let computed = s.roto_computed(cid, lid, uid); + out["computed"] = json!(computed.iter().filter(|(_, c)| **c).map(|(f, _)| *f).collect::>()); Ok(out) } diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index d28cb950..d2de8fc3 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -33,6 +33,7 @@ pub enum Icon { Clone, Eraser, RotoBrush, + RefineEdge, Puppet, // switches & timeline Eye, @@ -282,6 +283,15 @@ pub fn paint(painter: &Painter, rect: Rect, icon: Icon, color: Color32) { pen.ellipse(6.0, 10.0, 3.6, 3.6); pen.dot(6.0, 10.0, 1.0); } + RefineEdge => { + // A brush over a soft (dashed) edge. + pen.line(&[(13.5, 2.5), (9.0, 7.0)]); + pen.ellipse(7.0, 9.0, 2.6, 2.6); + pen.line(&[(2.0, 14.0), (3.5, 12.5)]); + pen.line(&[(5.0, 14.0), (6.5, 12.5)]); + pen.line(&[(8.0, 14.0), (9.5, 12.5)]); + pen.line(&[(11.0, 14.0), (12.5, 12.5)]); + } Puppet => { pen.circle(8.0, 3.6, 1.6); pen.line(&[(8.0, 5.2), (8.0, 10.0)]); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 071c1948..71cb9f45 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -725,6 +725,7 @@ impl EffectcraftApp { // Warp Stabilizer: finish analyses and start queued (re-)analyses in the background. if self.session.warp_job.is_some() || !self.session.warp_pending.is_empty() { self.session.poll_warp(true); + self.session.poll_roto(true); ctx.request_repaint_after(std::time::Duration::from_millis(100)); } self.apply_prefs(&ctx); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index da5eaec2..b14ade47 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -72,7 +72,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[ uic!("tool.pen", "Pen Tool", [], Some("G")), uic!("tool.type", "Type Tools", [], Some("Cmd+T")), uic!("tool.brush", "Brush Tools", [], Some("Cmd+B")), - uic!("tool.rotoBrush", "Roto Brush Tool", [], Some("Alt+W")), + uic!("tool.rotoBrush", "Roto Brush & Refine Edge Tools", [], Some("Alt+W")), uic!("tool.puppet", "Puppet Tools", [], Some("Cmd+P")), uic!("app.newComp", "New Composition...", [], None), uic!("app.compSettings", "Composition Settings...", [], None), @@ -139,6 +139,7 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V "pen" => Some(9), "type" => Some(10), "brush" => Some(11), + "rotoBrush" => Some(14), "puppet" => Some(15), _ => None, }; diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index 0a19d57f..03afd94c 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -537,6 +537,35 @@ fn warp_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, l p.text(pos2(r.min.x + 24.0, r.min.y + 38.0), Align2::LEFT_CENTER, status, Tokens::ui(11.5), t.text_dim); } +/// Roto Brush & Refine Edge: Freeze / Unfreeze and Propagate buttons and the segmentation status. +fn roto_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, layer: &Layer, g: &PropGroup, r: Rect, actions: &mut Actions) { + use effectcraft_engine::effects::roto as fx; + let t = app.tokens; + let params = effectcraft_engine::effects::flatten_params(g, &mut |pr| pr.value.clone()); + let frozen = fx::is_frozen(¶ms); + let d = fx::data(¶ms); + let running = app.session.roto_target().is_some_and(|(_, l, u, _)| l == layer.id && u == g.uid) && app.session.is_roto_running(); + 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(84.0, 22.0)); + let label = if frozen { "Unfreeze" } else { "Freeze" }; + if widgets::text_button(ui, b1, label, frozen, &t, egui::Id::new(("roto-freeze-ec", g.uid))).clicked() && !running { + let id = if frozen { "roto.unfreeze" } else { "roto.freeze" }; + actions.push((id.into(), json!({"layer": layer.id.0, "effect": g.uid}))); + } + app.auto.add(&format!("effectControls.roto.{}.freeze", g.uid), b1, label); + let (l2, id2) = if running { ("Stop", "roto.cancel") } else { ("Propagate", "roto.propagate") }; + if widgets::text_button(ui, b2, l2, false, &t, egui::Id::new(("roto-prop-ec", g.uid))).clicked() && !frozen { + actions.push((id2.into(), json!({"layer": layer.id.0, "effect": g.uid}))); + } + app.auto.add(&format!("effectControls.roto.{}.propagate", g.uid), b2, l2); + let status = match (app.session.roto_progress(), d.base) { + (Some(pr), _) if running => pr.banner(), + (_, None) => "No strokes: paint with the Roto Brush tool (Alt+W) in the Layer panel".to_string(), + (_, Some(b)) => format!("Base frame {b}, span {}–{}{}", d.span[0], d.span[1], if frozen { " (frozen)" } else { "" }), + }; + p.text(pos2(r.min.x + 24.0, r.min.y + 38.0), Align2::LEFT_CENTER, status, Tokens::ui(11.5), t.text_dim); +} + // --------------------------------------------------------------------------------------------- // Curves / Levels editors @@ -545,6 +574,7 @@ fn editor_height(effect: &str, width: f32) -> f32 { match effect { "ec.color.curves" => 34.0 + curves_size(width) + 26.0, effectcraft_engine::effects::warp_stab::ID => 50.0, + effectcraft_engine::effects::roto::ID => 50.0, "ec.color.levels" | "ec.color.levelsic" => 34.0 + 80.0 + 58.0, "ec.color.autolevels" | "ec.color.autocontrast" | "ec.color.autocolor" => 24.0 + 80.0 + 12.0, _ => 0.0, @@ -920,6 +950,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { match effect.as_str() { "ec.color.curves" => curves_editor(app, ui, &bp, &layer, g, &ectx, er, &mut actions), effectcraft_engine::effects::warp_stab::ID => warp_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), _ => histogram_only(app, ui, &bp, g, er), } diff --git a/crates/ui-egui/src/panels/layer_panel.rs b/crates/ui-egui/src/panels/layer_panel.rs index e53e268e..c20a0fba 100644 --- a/crates/ui-egui/src/panels/layer_panel.rs +++ b/crates/ui-egui/src/panels/layer_panel.rs @@ -3,9 +3,13 @@ //! //! The image is the layer's source and masks followed by its effects up to the View menu's //! choice (by default the last Paint effect, as After Effects shows "Paint"). Dragging with a -//! paint tool records the stroke's points in layer space and, on release, runs `paint.stroke` +//! paint tools records the stroke's points in layer space and, on release, runs `paint.stroke` //! at the current time (the drag's duration feeds Write On). Alt/Option-click with the Clone //! Stamp sets the clone source. +//! +//! With a Roto Brush & Refine Edge effect the panel shows the effect's input with the matte +//! overlay, the segmentation span bar and the view / Freeze buttons; the Roto Brush and Refine +//! Edge tools paint their strokes here (see [`super::roto_tool`]). use effectcraft_engine::effects::paint; use effectcraft_engine::project::{Layer, LayerId}; @@ -46,9 +50,15 @@ pub fn current_layer(app: &EffectcraftApp) -> Option { comp.layer(id).cloned() } -/// Number of effects shown by default: through the last Paint effect (none when there isn't one). -fn default_view(layer: &Layer) -> usize { - layer.effects().and_then(|fx| fx.groups().enumerate().filter(|(_, g)| paint::is_paint(g)).map(|(i, _)| i + 1).last()).unwrap_or(0) +/// Number of effects shown by default: the Roto Brush's input while a Roto Brush tool is active +/// (or when there is no Paint effect), else through the last Paint effect (none when there +/// isn't one). +fn default_view(layer: &Layer, tool: Tool) -> usize { + let paint = layer.effects().and_then(|fx| fx.groups().enumerate().filter(|(_, g)| paint::is_paint(g)).map(|(i, _)| i + 1).last()); + match super::roto_tool::roto_index(layer) { + Some(r) if tool.roto_kind(false).is_some() || paint.is_none() => r, + _ => paint.unwrap_or(0), + } } pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { @@ -64,7 +74,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let comp = app.session.project.comp(cid).cloned().unwrap_or_else(|| effectcraft_engine::project::Comp::new(1, 1, Default::default(), Default::default())); let time = app.session.time(); let fx_count = layer.effects().map(|f| f.groups().count()).unwrap_or(0); - let view = app.ui.layer_view.map(|v| v.min(fx_count)).unwrap_or_else(|| default_view(&layer)); + let view = app.ui.layer_view.map(|v| v.min(fx_count)).unwrap_or_else(|| default_view(&layer, app.ui.tool)); + let roto = super::roto_tool::roto_index(&layer); // Title strip. let top = Rect::from_min_size(rect.min, vec2(rect.width(), 24.0)); @@ -90,9 +101,17 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } let tc = super::timecode(&app.session, &comp, time); p.text(pos2(bar.max.x - 10.0, bar.center().y), Align2::RIGHT_CENTER, tc, Tokens::mono(12.0), t.timecode); + // Roto Brush: view / Freeze buttons and the segmentation span bar. + let mut canvas_bottom = bar.min.y; + if roto.is_some() { + super::roto_tool::bar_buttons(app, ui, &p, bar, vr.max.x + 12.0, &layer); + let sb = Rect::from_min_max(pos2(rect.min.x, bar.min.y - 18.0), pos2(rect.max.x, bar.min.y)); + super::roto_tool::span_bar(app, ui, &p, sb, &comp, &layer); + canvas_bottom = sb.min.y; + } // Canvas. - let area = Rect::from_min_max(pos2(rect.min.x, top.max.y), pos2(rect.max.x, bar.min.y)); + let area = Rect::from_min_max(pos2(rect.min.x, top.max.y), pos2(rect.max.x, canvas_bottom)); p.rect_filled(area, 0.0, t.pasteboard); let (w, h) = effectcraft_engine::render::source_size(&app.session.project, &layer); let (lw, lh) = if w == 0 { (comp.width as f64, comp.height as f64) } else { (w as f64, h as f64) }; @@ -139,10 +158,18 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let r = Rect::from_min_max(to_screen([tx.rect[0], tx.rect[1]]), to_screen([tx.rect[2], tx.rect[3]])); painter.image(tx.tex.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); } + if roto.is_some_and(|r| r == view) { + super::roto_tool::draw_overlay(app, &ctx, &painter, &comp, &layer, scale, &to_screen); + } painter.rect_stroke(frame, 0.0, Stroke::new(1.0, Color32::from_black_alpha(160)), StrokeKind::Outside); app.auto.add("layerPanel.canvas", frame, &layer.name); app.auto.add("layerPanel.area", area, "Layer panel"); + // Interaction: Roto Brush / Refine Edge. + if app.ui.tool.roto_kind(false).is_some() { + super::roto_tool::interact(app, ui, &painter, area, &layer, zoom, &to_screen, &to_layer); + return; + } // Interaction: paint tools. let resp = ui.interact(area, egui::Id::new("layer-panel-interact"), Sense::click_and_drag()); let kind = app.ui.tool.paint_kind(); diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 3f737502..8a8ce0cf 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -21,6 +21,7 @@ pub mod project; pub mod properties; pub mod puppet_tool; pub mod render_queue; +pub mod roto_tool; pub mod settings; pub mod shortcut_editor; pub mod text_panels; diff --git a/crates/ui-egui/src/panels/roto_tool.rs b/crates/ui-egui/src/panels/roto_tool.rs new file mode 100644 index 00000000..25354ee3 --- /dev/null +++ b/crates/ui-egui/src/panels/roto_tool.rs @@ -0,0 +1,331 @@ +//! Roto Brush and Refine Edge tools in the Layer panel: green foreground strokes, Alt/Option red +//! background strokes, Refine Edge strokes along soft edges; the matte overlay (Alpha Boundary in +//! pink, Alpha, Alpha Overlay), the segmentation span bar and the Freeze button. + +use effectcraft_engine::effects::roto as fx; +use effectcraft_engine::project::{Comp, Layer}; +use effectcraft_engine::render::{EvalCtx, RenderOpts, Renderer}; +use egui::{Align2, Color32, Pos2, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; +use serde_json::json; + +use crate::theme::Tokens; +use crate::{EffectcraftApp, widgets}; + +/// Position of the layer's first Roto Brush & Refine Edge effect. +pub fn roto_index(layer: &Layer) -> Option { + layer.effects()?.groups().position(|g| matches!(&g.kind, effectcraft_engine::project::GroupKind::Effect { effect } if effect == fx::ID)) +} + +fn roto_group(layer: &Layer) -> Option<&effectcraft_engine::project::PropGroup> { + layer.effects()?.groups().nth(roto_index(layer)?) +} + +/// A stroke being drawn. +#[derive(Clone, Debug)] +struct Drag { + layer: u64, + kind: &'static str, + points: Vec<[f64; 2]>, +} + +#[derive(Clone)] +struct Overlay { + key: (u64, u64, i64, u32, String), + tex: egui::TextureHandle, + rect: [f64; 4], +} + +fn c32(c: [f64; 3], a: u8) -> Color32 { + Color32::from_rgba_unmultiplied((c[0] * 255.0) as u8, (c[1] * 255.0) as u8, (c[2] * 255.0) as u8, a) +} + +/// Draw the matte overlay of the Roto Brush on `layer` (the panel shows the effect's input). +pub fn draw_overlay( + app: &mut EffectcraftApp, + ctx: &egui::Context, + painter: &egui::Painter, + comp: &Comp, + layer: &Layer, + scale: f64, + to_screen: &dyn Fn([f64; 2]) -> Pos2, +) { + let Some(idx) = roto_index(layer) else { return }; + let o = app.session.state.roto.clone(); + if o.view == "none" { + return; + } + let Some(cid) = app.session.active_comp_id() else { return }; + let time = app.session.time(); + let key = ( + app.session.revision, + layer.id.0, + time.0, + (scale * 1000.0) as u32, + format!("{}{:?}{:?}{}", o.view, o.overlay_color, o.boundary_color, o.overlay_opacity), + ); + let id = egui::Id::new("roto-overlay"); + let cached: Option = ctx.data(|d| d.get_temp(id)); + let ov = match cached { + Some(c) if c.key == key => Some(c), + _ => { + let ectx = EvalCtx { project: &app.session.project, comp_id: cid, comp, time, expr: app.session.expr.as_deref() }; + let mut r = Renderer::new(&app.session.project, &*app.session.footage, RenderOpts { scale, ..Default::default() }); + r.expr = app.session.expr.as_deref(); + r.cache = Some(&app.session.layer_cache); + let (Some(inp), Some(out)) = (r.layer_input(&ectx, layer, idx), r.layer_input(&ectx, layer, idx + 1)) else { return }; + let (w, h) = (inp.img.width as usize, inp.img.height as usize); + let a_at = |x: i64, y: i64| -> f32 { + // Output pixel at the same layer point. + let lx = (x as f64 + 0.5 - inp.offset[0]) / inp.scale; + let ly = (y as f64 + 0.5 - inp.offset[1]) / inp.scale; + let (ox, oy) = out.to_px([lx, ly]); + out.img.get(ox.floor() as i64, oy.floor() as i64)[3] + }; + let alpha: Vec = (0..w * h).map(|i| a_at((i % w) as i64, (i / w) as i64)).collect(); + let inside = |x: i64, y: i64| x >= 0 && y >= 0 && x < w as i64 && y < h as i64 && alpha[y as usize * w + x as usize] >= 0.5; + let mut px = vec![Color32::TRANSPARENT; w * h]; + let bc = c32(o.boundary_color, 255); + let oc = c32(o.overlay_color, (o.overlay_opacity / 100.0 * 255.0).clamp(0.0, 255.0) as u8); + // View Search Region: the band around the boundary (yellow). + let search = roto_group(layer).filter(|g| g.prop("rotoBrushMatte/viewSearchRegion").is_some_and(|p| p.value.as_bool())).map(|g| { + let r = g.prop("rotoBrushMatte/searchRadius").map(|p| p.value.as_f64()).unwrap_or(15.0) * scale; + let bin: Vec = alpha.iter().map(|a| (*a >= 0.5) as u8).collect(); + let sd = effectcraft_engine::track::roto::matting::signed_distance(&bin, w, h); + (sd, r as f32) + }); + for y in 0..h as i64 { + for x in 0..w as i64 { + let i = y as usize * w + x as usize; + let a = alpha[i]; + px[i] = match o.view.as_str() { + "alpha" => { + let v = (a.clamp(0.0, 1.0) * 255.0) as u8; + Color32::from_rgb(v, v, v) + } + "alphaOverlay" if a < 0.5 => oc, + "alphaOverlay" => Color32::TRANSPARENT, + _ => { + let me = a >= 0.5; + let edge = me && [(-1, 0), (1, 0), (0, -1), (0, 1)].iter().any(|(dx, dy)| !inside(x + dx, y + dy)); + if edge { bc } else { Color32::TRANSPARENT } + } + }; + if let Some((sd, r)) = &search { + let d = sd[i].abs(); + if (d - r).abs() < 0.75 { + px[i] = Color32::from_rgb(255, 220, 0); + } + } + } + } + let img = egui::ColorImage { size: [w, h], pixels: px, source_size: egui::Vec2::new(w as f32, h as f32) }; + let x0 = -inp.offset[0] / inp.scale; + let y0 = -inp.offset[1] / inp.scale; + let rect = [x0, y0, x0 + w as f64 / inp.scale, y0 + h as f64 / inp.scale]; + let tex = ctx.load_texture("roto-overlay", img, egui::TextureOptions::NEAREST); + let c = Overlay { key, tex, rect }; + ctx.data_mut(|d| d.insert_temp(id, c.clone())); + Some(c) + } + }; + if let Some(ov) = ov { + let r = Rect::from_min_max(to_screen([ov.rect[0], ov.rect[1]]), to_screen([ov.rect[2], ov.rect[3]])); + painter.image(ov.tex.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + } +} + +/// Pointer handling for the Roto Brush / Refine Edge tools. Returns true when it handled input. +pub fn interact( + app: &mut EffectcraftApp, + ui: &mut egui::Ui, + painter: &egui::Painter, + area: Rect, + layer: &Layer, + zoom: f64, + to_screen: &dyn Fn([f64; 2]) -> Pos2, + to_layer: &dyn Fn(Pos2) -> [f64; 2], +) -> bool { + let ctx = ui.ctx().clone(); + let mods = ui.input(|i| i.modifiers); + let Some(kind) = app.ui.tool.roto_kind(mods.alt) else { return false }; + let resp = ui.interact(area, egui::Id::new("layer-panel-roto"), Sense::click_and_drag()); + let o = app.session.state.roto.clone(); + let refine = kind.starts_with("refine"); + let dia = if refine { o.refine_diameter } else { o.diameter }; + let col = match kind { + "fg" => Color32::from_rgb(40, 220, 60), + "bg" => Color32::from_rgb(235, 50, 40), + "refine" => Color32::from_rgb(110, 200, 255), + _ => Color32::from_rgb(255, 160, 60), + }; + if let Some(hp) = resp.hover_pos() { + let lp = to_layer(hp); + app.pointer_comp = Some([lp[0] as f32, lp[1] as f32]); + ctx.set_cursor_icon(egui::CursorIcon::Crosshair); + let r = (dia * 0.5 * zoom) as f32; + painter.circle_stroke(hp, r, Stroke::new(1.5, col)); + let sign = if kind == "bg" || kind == "refineErase" { "−" } else { "+" }; + painter.text(hp, Align2::CENTER_CENTER, sign, Tokens::semibold(12.0), col); + } + let did = egui::Id::new("layer-panel-roto-drag"); + if resp.drag_started() + && let Some(pos) = ui.input(|i| i.pointer.press_origin()).or(resp.interact_pointer_pos()) + { + ctx.data_mut(|d| d.insert_temp(did, Drag { layer: layer.id.0, kind, points: vec![to_layer(pos)] })); + } + if let Some(mut d) = ctx.data(|d| d.get_temp::(did)) { + if let Some(pos) = resp.interact_pointer_pos() { + let lp = to_layer(pos); + let last = d.points.last().copied().unwrap_or(lp); + if (lp[0] - last[0]).hypot(lp[1] - last[1]) * zoom >= 1.5 { + d.points.push(lp); + ctx.data_mut(|dd| dd.insert_temp(did, d.clone())); + } + } + let width = ((dia * zoom) as f32).max(1.0); + let pts: Vec = d.points.iter().map(|q| to_screen(*q)).collect(); + let c = col.gamma_multiply(0.6); + if pts.len() == 1 { + painter.circle_filled(pts[0], width / 2.0, c); + } else { + painter.add(egui::Shape::line(pts, Stroke::new(width, c))); + } + if resp.drag_stopped() { + ctx.data_mut(|dd| dd.remove::(did)); + run_stroke(app, d.layer, d.kind, &d.points, dia / 2.0); + } + } else if resp.clicked() + && let Some(pos) = resp.interact_pointer_pos() + { + run_stroke(app, layer.id.0, kind, &[to_layer(pos)], dia / 2.0); + } + true +} + +fn run_stroke(app: &mut EffectcraftApp, layer: u64, kind: &str, points: &[[f64; 2]], radius: f64) { + let r = app.session.execute("roto.stroke", json!({"layer": layer, "kind": kind, "points": points, "radius": radius})); + if let Err(e) = r { + app.ui.status = e.to_string(); + } +} + +/// The segmentation span bar (above the panel's bottom bar): the layer's frames, the span, the +/// base frame (yellow), computed frames (green) and stroke frames. Click to go to a frame; drag +/// the span's ends to change it. +pub fn span_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, r: Rect, comp: &Comp, layer: &Layer) { + let t = app.tokens; + let Some(g) = roto_group(layer) else { return }; + let Some(cid) = app.session.active_comp_id() else { return }; + let uid = g.uid; + let params = effectcraft_engine::effects::flatten_params(g, &mut |p| p.value.clone()); + let d = fx::data(¶ms); + p.rect_filled(r, 0.0, t.panel_bg); + let lim = effectcraft_engine::roto::frame_limits(comp, layer); + let n = (lim[1] - lim[0] + 1).max(1) as f32; + let track = Rect::from_min_max(pos2(r.min.x + 10.0, r.min.y + 4.0), pos2(r.max.x - 10.0, r.max.y - 4.0)); + let x_of = |f: f64| track.min.x + ((f - lim[0] as f64) / n as f64) as f32 * track.width(); + let f_of = |x: f32| (lim[0] as f64 + ((x - track.min.x) / track.width()).clamp(0.0, 0.9999) as f64 * n as f64).floor() as i64; + p.rect_filled(track, 2.0, t.pasteboard); + if let Some(base) = d.base { + let sp = Rect::from_min_max(pos2(x_of(d.span[0] as f64), track.min.y), pos2(x_of(d.span[1] as f64 + 1.0), track.max.y)); + let frozen = fx::is_frozen(¶ms); + p.rect_filled(sp, 2.0, if frozen { Color32::from_rgb(60, 110, 190) } else { Color32::from_rgb(90, 90, 96) }); + for (f, done) in app.session.roto_computed(cid, layer.id, uid) { + if done { + let cr = Rect::from_min_max(pos2(x_of(f as f64), track.max.y - 4.0), pos2(x_of(f as f64 + 1.0), track.max.y)); + p.rect_filled(cr, 0.0, Color32::from_rgb(70, 200, 90)); + } + } + for f in d.stroke_frames() { + let x = x_of(f as f64 + 0.5); + p.line_segment([pos2(x, track.min.y), pos2(x, track.min.y + 5.0)], Stroke::new(2.0, Color32::WHITE)); + } + let bx = x_of(base as f64 + 0.5); + p.rect_filled(Rect::from_center_size(pos2(bx, track.center().y), vec2(6.0, track.height())), 1.0, Color32::from_rgb(240, 200, 40)); + } + // Current time. + let fps = comp.frame_rate.as_f64(); + let cur = fx::frame_of(layer.layer_time(app.session.time()).seconds(), fps); + let cx = x_of(cur as f64 + 0.5); + p.line_segment([pos2(cx, r.min.y), pos2(cx, r.max.y)], Stroke::new(1.0, t.timecode)); + p.rect_stroke(track, 2.0, Stroke::new(1.0, Color32::from_black_alpha(140)), StrokeKind::Outside); + app.auto.add("layerPanel.rotoSpan", track, "Segmentation span"); + let resp = ui.interact(track, egui::Id::new("roto-span-bar"), Sense::click_and_drag()); + let hid = egui::Id::new("roto-span-handle"); + if resp.drag_started() + && let (Some(pos), Some(_)) = (resp.interact_pointer_pos(), d.base) + { + let (a, b) = (x_of(d.span[0] as f64), x_of(d.span[1] as f64 + 1.0)); + let h = if (pos.x - a).abs() < 6.0 { + 1 + } else if (pos.x - b).abs() < 6.0 { + 2 + } else { + 0 + }; + ui.ctx().data_mut(|m| m.insert_temp(hid, h)); + } + let handle: i32 = ui.ctx().data(|m| m.get_temp(hid)).unwrap_or(0); + if let Some(pos) = resp.interact_pointer_pos() { + let f = f_of(pos.x); + if handle == 0 && (resp.clicked() || resp.dragged()) { + let t = layer.comp_time(comp.frame_rate.tick_of(f)); + let _ = app.session.execute("time.set", json!({"time": t.seconds()})); + } + if handle != 0 && resp.dragged() { + p.line_segment([pos2(pos.x, track.min.y), pos2(pos.x, track.max.y)], Stroke::new(2.0, Color32::from_rgb(240, 200, 40))); + } + if handle != 0 && resp.drag_stopped() { + let k = if handle == 1 { "start" } else { "end" }; + if let Err(e) = app.session.execute("roto.span", json!({"layer": layer.id.0, "effect": uid, k: f})) { + app.ui.status = e.to_string(); + } + } + } + if resp.drag_stopped() { + ui.ctx().data_mut(|m| m.remove::(hid)); + } +} + +/// View-mode toggles and Freeze / Propagate buttons in the Layer panel's bottom bar, from `x`. +pub fn bar_buttons(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, bar: Rect, x: f32, layer: &Layer) { + let t = app.tokens; + let mut x = x; + let view = app.session.state.roto.view.clone(); + for (id, label) in [("alpha", "Alpha"), ("alphaBoundary", "Boundary"), ("alphaOverlay", "Overlay")] { + let r = Rect::from_min_size(pos2(x, bar.center().y - 10.0), vec2(66.0, 20.0)); + let on = view == id; + if widgets::text_button(ui, r, label, on, &t, egui::Id::new(("roto-view", id))).clicked() { + let v = if on { "none" } else { id }; + let _ = app.session.execute("roto.options", json!({"view": v})); + } + app.auto.add(&format!("layerPanel.roto.view.{id}"), r, label); + x += 70.0; + } + let Some(g) = roto_group(layer) else { return }; + let uid = g.uid; + let params = effectcraft_engine::effects::flatten_params(g, &mut |p| p.value.clone()); + let frozen = fx::is_frozen(¶ms); + let running = app.session.is_roto_running(); + x += 8.0; + let r = Rect::from_min_size(pos2(x, bar.center().y - 10.0), vec2(74.0, 20.0)); + let label = if frozen { "Unfreeze" } else { "Freeze" }; + if widgets::text_button(ui, r, label, frozen, &t, egui::Id::new(("roto-freeze", uid))).clicked() { + let id = if frozen { "roto.unfreeze" } else { "roto.freeze" }; + if let Err(e) = app.session.execute(id, json!({"layer": layer.id.0, "effect": uid})) { + app.ui.status = e.to_string(); + } + } + app.auto.add("layerPanel.roto.freeze", r, label); + x += 80.0; + if running { + let r = Rect::from_min_size(pos2(x, bar.center().y - 10.0), vec2(60.0, 20.0)); + if widgets::text_button(ui, r, "Stop", false, &t, egui::Id::new("roto-stop")).clicked() { + let _ = app.session.execute("roto.cancel", json!({})); + } + app.auto.add("layerPanel.roto.stop", r, "Stop"); + if let Some(pr) = app.session.roto_progress() { + p.text(pos2(r.max.x + 8.0, bar.center().y), Align2::LEFT_CENTER, pr.banner(), Tokens::ui(11.5), t.text_dim); + } + } +} diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index d1350103..b89ddadb 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -34,6 +34,7 @@ pub enum Tool { Clone, Eraser, RotoBrush, + RefineEdge, Puppet, PuppetStarch, PuppetBend, @@ -58,10 +59,10 @@ impl Tool { &[Tool::Brush], &[Tool::Clone], &[Tool::Eraser], - &[Tool::RotoBrush], + &[Tool::RotoBrush, Tool::RefineEdge], &[Tool::Puppet, Tool::PuppetStarch, Tool::PuppetBend, Tool::PuppetAdvanced, Tool::PuppetOverlap], ]; - pub const ALL: [Tool; 29] = [ + pub const ALL: [Tool; 30] = [ Tool::Selection, Tool::Hand, Tool::Zoom, @@ -86,6 +87,7 @@ impl Tool { Tool::Clone, Tool::Eraser, Tool::RotoBrush, + Tool::RefineEdge, Tool::Puppet, Tool::PuppetStarch, Tool::PuppetBend, @@ -118,6 +120,7 @@ impl Tool { Tool::Clone => "Clone Stamp Tool", Tool::Eraser => "Eraser Tool", Tool::RotoBrush => "Roto Brush Tool", + Tool::RefineEdge => "Refine Edge Tool", Tool::Puppet => "Puppet Position Pin Tool", Tool::PuppetStarch => "Puppet Starch Pin Tool", Tool::PuppetBend => "Puppet Bend Pin Tool", @@ -139,7 +142,7 @@ impl Tool { Tool::Pen | Tool::PenAdd | Tool::PenDelete | Tool::PenConvert | Tool::MaskFeather => Some("G"), Tool::Type | Tool::TypeVertical => Some("Cmd+T"), Tool::Brush | Tool::Clone | Tool::Eraser => Some("Cmd+B"), - Tool::RotoBrush => Some("Alt+W"), + Tool::RotoBrush | Tool::RefineEdge => Some("Alt+W"), Tool::Puppet | Tool::PuppetStarch | Tool::PuppetBend | Tool::PuppetAdvanced | Tool::PuppetOverlap => Some("Cmd+P"), } } @@ -169,6 +172,7 @@ impl Tool { Tool::Clone => Icon::Clone, Tool::Eraser => Icon::Eraser, Tool::RotoBrush => Icon::RotoBrush, + Tool::RefineEdge => Icon::RefineEdge, Tool::Puppet | Tool::PuppetStarch | Tool::PuppetBend | Tool::PuppetAdvanced | Tool::PuppetOverlap => Icon::Puppet, } } @@ -187,6 +191,17 @@ impl Tool { _ => return None, }) } + /// Roto Brush / Refine Edge: the stroke kind they paint (`roto.stroke` kind), without and + /// with Alt/Option. + pub fn roto_kind(self, alt: bool) -> Option<&'static str> { + Some(match (self, alt) { + (Tool::RotoBrush, false) => "fg", + (Tool::RotoBrush, true) => "bg", + (Tool::RefineEdge, false) => "refine", + (Tool::RefineEdge, true) => "refineErase", + _ => return None, + }) + } /// Paint tools, with their stroke kind (`paint.stroke` kind). pub fn paint_kind(self) -> Option<&'static str> { Some(match self { diff --git a/docs/agents.md b/docs/agents.md index 510bc356..5c1b4187 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -146,6 +146,28 @@ effectcraft-cli exec warp.analyze '{"layer":"#1","wait":true}' shaky.ecproj --sa effectcraft-cli exec warp.status '{"layer":"#1"}' shaky.ecproj --json ``` +### Roto Brush & Refine Edge + +The Roto Brush tool (Alt+W cycles Roto Brush / Refine Edge) paints in the Layer panel; agents use +`roto.stroke {layer, kind: fg|bg|refine|refineErase, points: [[x, y], …], frame?, radius?}` +(layer pixels, layer frames). The first foreground stroke applies the Roto Brush & Refine Edge +effect and sets the base frame with a span of 20 frames each side. `roto.propagate {layer, +direction?: forward|backward|both, to?, wait?}` segments the span (in the background unless +`wait`); strokes on any other frame correct it and propagation restarts from there. +`roto.span {start?, end?}`, `roto.freeze {wait?}` / `roto.unfreeze`, `roto.clearStrokes {frame?, +kind?}`, `roto.cancel` and `roto.options {diameter?, refineDiameter?, view?: alphaBoundary|alpha| +alphaOverlay|none}` complete the set; every edit is one undo step. `roto.status {layer, frame?, +matte?, compute?, compareTo?}` reports the base frame, span, computed and stroked frames, frozen +state, job progress and, for a frame, the matte's area, centroid, RLE matte and IoU against a +reference. Matte settings are ordinary properties (`effects/#1/rotoBrushMatte/searchRadius`, +`effects/#1/refineEdgeMatte/decontaminateEdgeColors`, …). + +```sh +effectcraft-cli run clip.ecproj roto.stroke '{"layer":"#1","points":[[300,200],[360,230]],"radius":10}' \ + roto.stroke '{"layer":"#1","kind":"bg","points":[[40,40],[600,40]],"radius":12}' \ + roto.propagate '{"layer":"#1","wait":true}' roto.freeze '{"layer":"#1","wait":true}' --save +``` + ### Text: styles and editing Source Text holds character style runs and per-paragraph settings. `layer.setText` changes the diff --git a/docs/architecture.md b/docs/architecture.md index 033ed6cb..9f121608 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -176,6 +176,19 @@ auto-scale; cached per analysis and settings) and warps each frame; Synthesize E borders from neighbouring frames read with `EffectHost::self_at`. The effect lives in the effects crate, hence the `effects → track` edge. +**Roto Brush & Refine Edge** (`effectcraft_track::roto`, `effects::roto`, `engine::roto`): +strokes (foreground, background, Refine Edge) are stored as JSON in the effect's hidden Strokes +parameter with the base frame and segmentation span. Each frame is segmented by graph cut +(Boykov–Jolly hard constraints, GrabCut colour mixtures, our own Boykov–Kolmogorov max-flow, +coarse to fine) and propagated to the next frame by warping the matte with block optical flow and +re-cutting in a Search Radius band; frames with correction strokes are re-cut with the warped +matte as a soft prior. Refine Edge bands get guided-filter + closed-form matting and +foreground-colour decontamination. Segmentations are derived data: each frame's chain key (Input +Key, settings, strokes from the base frame out) indexes a process-wide cache that renders fill on +demand (`self_at`) and `roto.propagate` fills in the background; `Session::edit` keeps the Input +Key in step with the layer's source, masks and upstream effects. Freeze stores final 8-bit mattes +in the effect. + ## 5. Expressions Expressions are JavaScript, run by boa, with After Effects' object model (`thisComp`, `thisLayer`, diff --git a/docs/parity.md b/docs/parity.md index d259fec2..6e926dc7 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -73,7 +73,7 @@ library groundwork for mask tracking and Warp Stabilizer. | Remaining (audit-scale agent-hours) | ≈ 167 | ≈ 100 | | Wall clock, five parallel agents | 42–50 h | **≈ 8–10 h at today's measured pace; ≈ 25 h by the audit's conservative figures** | -What is left is concentrated in large systems: Roto Brush and Refine Edge, the 3D camera tracker, +What is left is concentrated in large systems (Roto Brush and Refine Edge landed in M6.7): the 3D camera tracker, Advanced 3D (models, PBR, image-based lighting), the Warp Stabilizer and mask-tracking UI on top of the new tracking library, a JavaScript scripting object model, SVG/PSD import, WebM, a disk cache, the missing effect categories (3D Channel, Immersive Video, OCIO), native macOS menus, and @@ -97,7 +97,7 @@ wasm threads. | Effects | 58% | 13.6 | Effect Controls widgets (angle dial, point crosshair, eyedropper, curves), GPU versions of more effects, 41 missing effects | | Interface | 55% | 10.4 | drag-to-dock and floating panels, preferences, native macOS menus (viewer rulers/snapping/channels/snapshots landed in M0.13) | | Project | 53% | 8.1 | 8/16/32-bit pipeline and colour management, auto-save, proxies, folder moves | -| Masks & roto | 52% | 10.0 | Roto Brush, variable-width mask feather points (mask tracking and Mask Interpolation landed in M6.6) | +| Masks & roto | 68% | 6.0 | variable-width mask feather points, Roto Brush's learned (3.0) segmentation model (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 | 55% | 6.1 | GPU 3D and adjustment layers, disk cache (region of interest, snapshots, exposure and Fast Previews landed in M0.13) | | Tracking | ≈ 70% | 4.0 | 3D camera tracker, face tracking, Subspace Warp's mesh warp and rolling-shutter repair (point tracker, mask tracking and Warp Stabilizer landed in M6.x / M12.5) | | Paint | 0% | 4.0 | Brush, Clone Stamp, Eraser |