Files
effectcraft/crates/ui-egui/src/panels/layer_panel.rs
T
Brandon ThomasandClaude Opus 5.5 6baff9f5fe Integration fixes for the community batch
- Footage sources: the cancellable wrapper (#784) forwards frame_with (#752 float curves)
  and problem (#887); problem's default lives on the trait.
- Media: a called-off decode (#784) is not a footage problem (#887).
- Effect Controls rename (#953): Escape cancels (egui drops focus on Escape the same
  frame), the Rename item is translated; regression test.
- i18n (#873): drop the /out-i18n/ ignore rule and the note about an uncommitted script.
- Tests: text range selector caps (#926) re-borrow the layer; the RAM preview test (#793)
  sets the comp's own Resolution (#462).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 04:27:59 -04:00

366 lines
18 KiB
Rust

//! The Layer panel: one layer in its own (layer) space, where the Brush, Clone Stamp and Eraser
//! tools paint. Double-clicking a footage or solid layer (or Layer ▸ Open Layer) opens it.
//!
//! 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 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 crate::i18n::{tr, tr_args};
use effectcraft_engine::effects::paint;
use effectcraft_engine::project::{Layer, LayerId};
use effectcraft_engine::render::{EvalCtx, RenderOpts, Renderer};
use egui::{Align2, Color32, Pos2, Rect, Sense, Stroke, StrokeKind, pos2, vec2};
use serde_json::json;
use crate::state::Tool;
use crate::theme::Tokens;
use crate::{EffectcraftApp, widgets};
/// A stroke being drawn.
#[derive(Clone, Debug)]
struct Drag {
layer: u64,
points: Vec<[f64; 2]>,
started: f64,
}
/// Cached texture of the rendered layer: (revision, layer, time, view, scale) → texture and the
/// layer-space rectangle it covers.
#[derive(Clone)]
struct Tex {
key: (u64, u64, i64, usize, u32),
tex: egui::TextureHandle,
/// Layer-space rect of the image `[x0, y0, x1, y1]`.
rect: [f64; 4],
}
fn tex_id() -> egui::Id {
egui::Id::new("layer-panel-tex")
}
fn fit_id() -> egui::Id {
egui::Id::new("layer-panel-fit")
}
/// The Layer panel's last Fit magnification (points per layer pixel), for View ▸ Zoom In / Zoom
/// Out from Fit.
pub fn last_fit(ctx: &egui::Context) -> f32 {
ctx.data(|d| d.get_temp::<f32>(fit_id())).unwrap_or(0.5)
}
/// Zoom to `nz` (points per layer pixel) keeping layer point `q` under screen point `at`.
fn zoom_at(app: &mut EffectcraftApp, area: Rect, size: [f64; 2], at: Pos2, q: [f64; 2], nz: f32) {
let nz = nz.clamp(0.01, 32.0);
let origin = at - vec2(q[0] as f32 * nz, q[1] as f32 * nz);
let pan = origin + vec2(size[0] as f32 * nz / 2.0, size[1] as f32 * nz / 2.0) - area.center();
if pan.x.is_finite() && pan.y.is_finite() {
app.ui.layer_zoom = Some(nz);
app.ui.layer_pan = [pan.x, pan.y];
}
}
/// The layer shown: the opened one, else the first selected layer.
pub fn current_layer(app: &EffectcraftApp) -> Option<Layer> {
let comp = app.session.active_comp()?;
let id = app.ui.layer_panel.map(LayerId).filter(|id| comp.layer(*id).is_some()).or_else(|| app.session.state.selected_layers.first().copied())?;
comp.layer(id).cloned()
}
/// 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) {
let t = app.tokens;
let ctx = ui.ctx().clone();
let p = ui.painter().with_clip_rect(rect);
let (Some(cid), Some(layer)) = (app.session.active_comp_id(), current_layer(app)) else {
p.rect_filled(rect, 0.0, t.pasteboard);
p.text(rect.center(), Align2::CENTER_CENTER, tr("Double-click a layer to open it in the Layer panel"), Tokens::ui(12.0), t.text_faint);
app.auto.add("layerPanel.empty", rect, "Layer panel (empty)");
return;
};
let comp = app.session.project.comp_arc(cid).unwrap_or_else(|| effectcraft_engine::project::Comp::new(1, 1, Default::default(), Default::default()).into());
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, 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));
p.rect_filled(top, 0.0, t.panel_bg);
p.text(pos2(top.min.x + 10.0, top.center().y), Align2::LEFT_CENTER, tr_args("Layer: {}", &[&layer.name]), Tokens::semibold(12.0), t.text);
app.auto.add("layerPanel.title", top, &layer.name);
// Canvas geometry: Fit, or the panel's own magnification and pan.
let bar = Rect::from_min_max(pos2(rect.min.x, rect.max.y - 30.0), rect.max);
let canvas_bottom = if roto.is_some() { bar.min.y - 18.0 } else { bar.min.y };
let area = Rect::from_min_max(pos2(rect.min.x, top.max.y), pos2(rect.max.x, canvas_bottom));
// The layer's effect bounds, in effect space (where paint and Roto Brush strokes are).
let [lw, lh] = effectcraft_engine::render::effect_bounds(&app.session.project, &comp, &layer).0;
let fit = ((area.width() - 40.0) as f64 / lw).min((area.height() - 40.0) as f64 / lh).max(0.01) as f32;
ctx.data_mut(|d| d.insert_temp(fit_id(), fit));
let zoom = app.ui.layer_zoom.filter(|z| z.is_finite() && *z > 0.0).unwrap_or(fit) as f64;
let pan = match app.ui.layer_pan {
[x, y] if x.is_finite() && y.is_finite() && app.ui.layer_zoom.is_some() => vec2(x, y),
_ => vec2(0.0, 0.0),
};
let origin = area.center() + pan - vec2((lw * zoom / 2.0) as f32, (lh * zoom / 2.0) as f32);
// Bottom bar: magnification and View menus.
p.rect_filled(bar, 0.0, t.panel_bg);
let mr = Rect::from_min_size(pos2(bar.min.x + 8.0, bar.center().y - 10.0), vec2(80.0, 20.0));
if let Some((z, up)) = super::viewer_tools::magnification_menu(
app,
ui,
mr,
"layer-mag",
"layerPanel.magnification",
"layerMagnification",
zoom as f32,
app.ui.layer_zoom,
false,
) {
// The Layer panel keeps no fit mode: Fit up to 100% is the fit, at most 100 %.
app.ui.layer_zoom = if up { Some(fit.min(1.0 / ui.ctx().pixels_per_point())) } else { z };
app.ui.layer_pan = [0.0, 0.0];
}
let vx = mr.max.x + 10.0;
p.text(pos2(vx, bar.center().y), Align2::LEFT_CENTER, tr("View:"), Tokens::ui(12.0), t.text_dim);
let mut names = vec!["Masks".to_string()];
if let Some(fx) = layer.effects() {
names.extend(fx.groups().map(|g| g.name.clone()));
}
let vr = Rect::from_min_size(pos2(vx + 36.0, bar.center().y - 10.0), vec2(150.0, 20.0));
let pop = egui::Id::new("layer-view-pop");
if widgets::dropdown(ui, vr, &names[view.min(names.len() - 1)], &t, egui::Id::new("layer-view")).clicked() {
widgets::open_popup(ui, pop);
}
app.auto.add("layerPanel.view", vr, "View");
if let Some(i) = widgets::popup_menu(ui, pop, vr.left_bottom(), &names, Some(view)) {
app.ui.layer_view = Some(i);
}
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.
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, canvas_bottom), pos2(rect.max.x, bar.min.y));
super::roto_tool::span_bar(app, ui, &p, sb, &comp, &layer);
}
// Canvas.
p.rect_filled(area, 0.0, t.pasteboard);
let to_screen = |q: [f64; 2]| pos2(origin.x + (q[0] * zoom) as f32, origin.y + (q[1] * zoom) as f32);
let to_layer = |s: Pos2| [((s.x - origin.x) as f64) / zoom, ((s.y - origin.y) as f64) / zoom];
let frame = Rect::from_min_max(to_screen([0.0, 0.0]), to_screen([lw, lh]));
let painter = p.with_clip_rect(area);
super::viewer::checker(&painter, frame);
// Render (synchronously; the layer cache makes repeats cheap).
let ppp = ctx.pixels_per_point() as f64;
let scale = (zoom * ppp).clamp(0.05, 1.0);
let scale = if scale > 0.75 {
1.0
} else if scale > 0.4 {
0.5
} else {
0.25
};
let key = (app.session.revision, layer.id.0, time.0, view, (scale * 1000.0) as u32);
let cached: Option<Tex> = ctx.data(|d| d.get_temp(tex_id()));
let tex = match cached {
Some(c) if c.key == key => Some(c),
_ => {
let ectx = EvalCtx { project: &app.session.project, comp_id: cid, comp: &comp, time, expr: app.session.expr.as_deref(), footage: None };
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);
r.layer_input(&ectx, &layer, view).map(|buf| {
let img = crate::frames::to_color_image(&buf.img);
let x0 = -buf.offset[0] / buf.scale;
let y0 = -buf.offset[1] / buf.scale;
let rect = [x0, y0, x0 + buf.img.width as f64 / buf.scale, y0 + buf.img.height as f64 / buf.scale];
let tex = crate::frames::load_fitted(&ctx, "layer-panel", img, egui::TextureOptions::LINEAR);
let c = Tex { key, tex, rect };
ctx.data_mut(|d| d.insert_temp(tex_id(), c.clone()));
c
})
}
};
if let Some(tx) = &tex {
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");
// Navigation (as the Composition panel): the wheel / pinch zooms around the pointer with any
// tool; the Hand tool or Space-drag pans and the Zoom tool clicks zoom in (Alt: out).
let hovered = ui.rect_contains_pointer(area);
if let Some(hp) = ui.input(|i| i.pointer.hover_pos()).filter(|_| hovered) {
let scroll = ui.input(|i| i.smooth_scroll_delta.y + i.zoom_delta().ln() * 300.0);
if scroll.is_finite() && scroll.abs() > 0.1 {
zoom_at(app, area, [lw, lh], hp, to_layer(hp), zoom as f32 * (scroll / 300.0).exp());
}
}
// A middle-drag pans with any tool; painting and Roto Brush strokes don't run meanwhile.
if hovered && ui.input(|i| i.pointer.middle_down()) {
// Movement from before the press (in its frame) is not part of the drag.
let d = ui.input(|i| if i.pointer.button_pressed(egui::PointerButton::Middle) { egui::Vec2::ZERO } else { i.pointer.delta() });
if d != egui::Vec2::ZERO && d.x.is_finite() && d.y.is_finite() {
app.ui.layer_zoom.get_or_insert(zoom as f32);
app.ui.layer_pan = [pan.x + d.x, pan.y + d.y];
}
ctx.set_cursor_icon(egui::CursorIcon::Grabbing);
return;
}
let space = super::space_hand(&ctx);
if space || matches!(app.ui.tool, Tool::Hand | Tool::Zoom) {
let resp = ui.interact(area, egui::Id::new("layer-panel-nav"), Sense::click_and_drag());
let alt = ui.input(|i| i.modifiers.alt);
if resp.hovered() {
ctx.set_cursor_icon(match app.ui.tool {
Tool::Zoom if !space && alt => egui::CursorIcon::ZoomOut,
Tool::Zoom if !space => egui::CursorIcon::ZoomIn,
_ if resp.dragged() => egui::CursorIcon::Grabbing,
_ => egui::CursorIcon::Grab,
});
}
if resp.dragged_by(egui::PointerButton::Primary) && (space || app.ui.tool == Tool::Hand) {
let d = resp.drag_delta();
app.ui.layer_zoom.get_or_insert(zoom as f32);
app.ui.layer_pan = [pan.x + d.x, pan.y + d.y];
}
if resp.clicked()
&& !space
&& app.ui.tool == Tool::Zoom
&& let Some(pos) = resp.interact_pointer_pos()
{
zoom_at(app, area, [lw, lh], pos, to_layer(pos), zoom as f32 * if alt { 0.5 } else { 2.0 });
}
app.auto.add("layerPanel.nav", area, "Layer panel navigation");
return;
}
// 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();
let o = app.session.state.paint.clone();
let did = egui::Id::new("layer-panel-drag");
if let Some(hp) = resp.hover_pos() {
let lp = to_layer(hp);
app.pointer_comp = Some([lp[0] as f32, lp[1] as f32]);
if kind.is_some() {
ctx.set_cursor_icon(egui::CursorIcon::Crosshair);
// Brush tip outline.
let rad = (o.diameter * 0.5 * zoom) as f32;
let ang = (o.angle as f32).to_radians();
let rnd = (o.roundness / 100.0).clamp(0.01, 1.0) as f32;
let pts: Vec<Pos2> = (0..48)
.map(|i| {
let a = i as f32 / 48.0 * std::f32::consts::TAU;
let (x, y) = (a.cos() * rad, a.sin() * rad * rnd);
hp + vec2(x * ang.cos() - y * ang.sin(), x * ang.sin() + y * ang.cos())
})
.collect();
painter.add(egui::Shape::closed_line(pts, Stroke::new(1.0, Color32::from_white_alpha(200))));
// Clone source overlay.
if app.ui.tool == Tool::Clone
&& o.show_overlay
&& let (Some(tx), Some(src)) = (&tex, o.clone_point)
{
let off = o.clone_offset.unwrap_or([src[0] - lp[0], src[1] - lp[1]]);
let r = Rect::from_min_max(to_screen([tx.rect[0] - off[0], tx.rect[1] - off[1]]), to_screen([tx.rect[2] - off[0], tx.rect[3] - off[1]]));
let a = (o.overlay_opacity / 100.0 * 255.0).clamp(0.0, 255.0) as u8;
painter.with_clip_rect(frame).image(tx.tex.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::from_white_alpha(a));
}
}
}
if let Some(src) = o.clone_point.filter(|_| app.ui.tool == Tool::Clone && o.clone_source.is_none_or(|l| l == layer.id.0)) {
let s = to_screen(src);
painter.line_segment([s - vec2(6.0, 0.0), s + vec2(6.0, 0.0)], Stroke::new(1.0, Color32::WHITE));
painter.line_segment([s - vec2(0.0, 6.0), s + vec2(0.0, 6.0)], Stroke::new(1.0, Color32::WHITE));
}
let mods = ui.input(|i| i.modifiers);
let now = ui.input(|i| i.time);
// Alt/Option-click with the Clone Stamp sets the source.
if app.ui.tool == Tool::Clone
&& mods.alt
&& resp.clicked()
&& let Some(pos) = resp.interact_pointer_pos()
{
let lp = to_layer(pos);
let _ = app.session.execute("paint.setCloneSource", json!({"layer": layer.id.0, "point": lp}));
return;
}
if let Some(kind) = kind
&& !mods.alt
{
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, points: vec![to_layer(pos)], started: now }));
}
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.0 {
d.points.push(lp);
ctx.data_mut(|dd| dd.insert_temp(did, d.clone()));
}
}
// Live preview of the stroke.
let col = if kind == "eraser" {
Color32::from_white_alpha(140)
} else {
Color32::from_rgba_unmultiplied((o.color[0] * 255.0) as u8, (o.color[1] * 255.0) as u8, (o.color[2] * 255.0) as u8, 200)
};
let width = ((o.diameter * zoom) as f32).max(1.0);
let pts: Vec<Pos2> = d.points.iter().map(|q| to_screen(*q)).collect();
if pts.len() == 1 {
painter.circle_filled(pts[0], width / 2.0, col);
} else {
painter.add(egui::Shape::line(pts, Stroke::new(width, col)));
}
if resp.drag_stopped() {
ctx.data_mut(|dd| dd.remove::<Drag>(did));
let pts: Vec<[f64; 2]> = d.points.clone();
let r = app.session.execute("paint.stroke", json!({"layer": d.layer, "kind": kind, "points": pts, "duration": (now - d.started).max(0.0)}));
if let Err(e) = r {
app.ui.status = e.to_string();
}
}
}
if resp.clicked()
&& let Some(pos) = resp.interact_pointer_pos()
{
let r = app.session.execute("paint.stroke", json!({"layer": layer.id.0, "kind": kind, "points": [to_layer(pos)], "duration": 0.0}));
if let Err(e) = r {
app.ui.status = e.to_string();
}
}
}
}