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:
parent
07330bca6b
commit
ed72e67155
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)]);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(¶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::<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(¶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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
Loading…
Reference in New Issue