M6.7: refine edge + freeze + UI

Refine Edge tool (Alt+W cycles Roto Brush / Refine Edge), Layer panel
roto strokes with matte overlays (Alpha Boundary in pink, Alpha, Alpha
Overlay, View Search Region), segmentation span bar, Freeze/Propagate in
the Layer panel and Effect Controls, background propagation polling,
docs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-02 22:27:22 -04:00
parent 07330bca6b
commit ed72e67155
13 changed files with 467 additions and 15 deletions

View File

@ -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).

View File

@ -378,14 +378,12 @@ fn status(s: &mut Session, p: &Value) -> Result<Value> {
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::<Vec<_>>());
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<Value> {
}
}
}
let computed = s.roto_computed(cid, lid, uid);
out["computed"] = json!(computed.iter().filter(|(_, c)| **c).map(|(f, _)| *f).collect::<Vec<_>>());
Ok(out)
}

View File

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

View File

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

View File

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

View File

@ -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(&params);
let d = fx::data(&params);
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),
}

View File

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

View File

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

View File

@ -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<usize> {
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<Overlay> = 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<f32> = (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<u8> = 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::<Drag>(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<Pos2> = 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::<Drag>(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(&params);
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(&params);
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::<i32>(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(&params);
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);
}
}
}

View File

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

View File

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

View File

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

View File

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