mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-24 10:09:32 -06:00
- 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>
366 lines
18 KiB
Rust
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();
|
|
}
|
|
}
|
|
}
|
|
}
|