M15.2: merge main (disk cache, 3D Camera Tracker); camera analysis in workers; no disk cache on wasm32

The 3D Camera Tracker analysis joins the offloaded jobs. The disk cache stays off in the
browser (std::env::temp_dir panics on wasm32 once a config store exists).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-03 00:03:43 -04:00
co-authored by Claude Opus 5.5
116 changed files with 18084 additions and 82 deletions
+93 -15
View File
@@ -7,6 +7,11 @@
//! wasm32 has no threads: jobs wait in the same priority queue and [`Frames::pump`] renders them
//! on the UI thread between egui frames, within a time budget.
//!
//! With the disk cache on (Settings ▸ Media & Disk Cache), every CPU-rendered frame is also
//! written to disk under a content key (the comp's content hash, frame, scale, view and render
//! options), and a job first looks there: frames survive restarts and project reopenings. The
//! timeline shows disk-only frames as a blue cache bar.
//!
//! With Mercury GPU Acceleration (a [`Gpu`] on egui-wgpu's device and the project's renderer set
//! to the GPU) frames stay on the GPU: the compositor writes an RGBA8 texture that the viewer
//! registers with egui-wgpu and draws directly; pixels are read back only when something needs
@@ -16,6 +21,7 @@ use std::collections::{HashMap, HashSet, VecDeque};
use std::sync::{Arc, Mutex};
use effectcraft_engine::project::{ItemId, Project};
use effectcraft_engine::render::disk_cache::{self, DiskCache};
use effectcraft_engine::render::{ExprHost, FootageSource, LayerCache, RenderOpts, Renderer};
use effectcraft_engine::time::Tick;
use effectcraft_gpu::{DisplayFrame, Gpu};
@@ -124,6 +130,37 @@ pub struct RenderSource {
/// while the viewer draws plainly, because there frames can't be read back synchronously
/// (`EffectcraftApp::gpu_display`).
pub gpu_display: bool,
/// The persistent disk cache, when enabled.
pub disk: Option<Arc<DiskCache>>,
}
/// Content keys of (project revision, comp), computed once per revision.
type ContentKeys = Arc<Mutex<HashMap<(u64, u64), u128>>>;
fn content_key(keys: &ContentKeys, project: &Project, revision: u64, comp: u64) -> u128 {
if let Some(k) = keys.lock().ok().and_then(|m| m.get(&(revision, comp)).copied()) {
return k;
}
let k = disk_cache::comp_content_key(project, ItemId(comp));
if let Ok(mut m) = keys.lock() {
if m.len() > 256 {
m.clear();
}
m.insert((revision, comp), k);
}
k
}
fn opts_hash(o: &RenderOpts) -> u64 {
let mut h = disk_cache::Hash128::default();
h.write(format!("{o:?}").as_bytes());
h.finish() as u64
}
/// Disk key of a viewer frame (render options included: region of interest, draft…).
fn frame_disk_key(keys: &ContentKeys, project: &Project, key: &FrameKey, opts_hash: u64) -> u128 {
let content = content_key(keys, project, key.revision, key.comp);
disk_cache::frame_key(content, key.frame, key.scale, key.view ^ opts_hash)
}
pub struct Frames {
@@ -137,6 +174,7 @@ pub struct Frames {
ctx: Option<egui::Context>,
/// Render time of the last viewer (urgent) frame in ms, for the Info panel / perf readout.
pub last_ms: Arc<Mutex<f64>>,
content_keys: ContentKeys,
}
impl Default for Frames {
@@ -151,6 +189,7 @@ impl Default for Frames {
pool: rayon::ThreadPoolBuilder::new().num_threads(threads).thread_name(|i| format!("ec-frame-{i}")).build().expect("frame pool"),
ctx: None,
last_ms: Arc::new(Mutex::new(0.0)),
content_keys: Arc::default(),
}
}
}
@@ -202,6 +241,21 @@ impl Frames {
v
}
/// Frames of (revision, comp, scale) in the disk cache but not in RAM — the blue cache bar.
/// `opts` are the viewer's render options (part of the disk key).
pub fn disk_frames(&self, src: &RenderSource, revision: u64, comp: u64, scale: u32, view: u64, frames: i64, opts: &RenderOpts) -> Vec<i64> {
let Some(dc) = &src.disk else { return vec![] };
let ram: HashSet<i64> = self.cached_frames(revision, comp, scale).into_iter().collect();
let oh = opts_hash(opts);
(0..frames.clamp(0, 100_000))
.filter(|f| !ram.contains(f))
.filter(|f| {
let key = FrameKey { revision, comp, frame: *f, scale, view };
dc.contains(disk_cache::Kind::Frame, frame_disk_key(&self.content_keys, &src.project, &key, oh))
})
.collect()
}
/// Change the RAM preview cache budget (Settings ▸ Memory & CPU), evicting the oldest
/// frames when it shrinks.
pub fn set_budget(&self, bytes: usize) {
@@ -285,7 +339,14 @@ impl Frames {
}
fn worker(&self) -> Worker {
Worker { queue: self.queue.clone(), cache: self.cache.clone(), inflight: self.inflight.clone(), ctx: self.ctx.clone(), last: self.last_ms.clone() }
Worker {
queue: self.queue.clone(),
cache: self.cache.clone(),
inflight: self.inflight.clone(),
ctx: self.ctx.clone(),
last: self.last_ms.clone(),
content_keys: self.content_keys.clone(),
}
}
/// Render queued frames on this thread, most important first, until the queue is empty or
@@ -313,12 +374,13 @@ struct Worker {
inflight: Arc<Mutex<HashSet<FrameKey>>>,
ctx: Option<egui::Context>,
last: Arc<Mutex<f64>>,
content_keys: ContentKeys,
}
impl Worker {
/// Render the queued job that matters most right now. `false` when the queue was empty.
fn run_next(&self) -> bool {
let Worker { queue, cache, inflight, ctx, last } = self;
let Worker { queue, cache, inflight, ctx, last, content_keys } = self;
let job = {
let Ok(mut q) = queue.lock() else { return false };
// Urgent first (newest), then prefetch in request order.
@@ -332,19 +394,18 @@ impl Worker {
job
};
let t0 = web_time::Instant::now();
let mut r = Renderer::new(&job.src.project, job.src.footage.as_ref(), job.opts);
r.expr = job.src.expr.as_deref();
r.cache = Some(&job.src.layer_cache);
r.accel = job.src.gpu.as_ref().map(|g| g as &dyn effectcraft_engine::render::Accelerator);
// The GPU leaves the frame in a texture for the viewer; otherwise (Software Only, no
// adapter, or a frame the GPU cannot finish here) the CPU renders it.
let gpu_frame = match (&job.src.gpu, r.active_accel()) {
(Some(g), Some(_)) if job.src.gpu_display => g.render_display(&r, job.comp, job.t),
_ => None,
};
let ci = match gpu_frame {
Some(f) => FrameImage::Gpu(Arc::new(f)),
None => FrameImage::Cpu(Arc::new(to_color_image(&r.comp_frame(job.comp, job.t)))),
let disk = job.src.disk.as_ref().map(|dc| (dc.clone(), frame_disk_key(content_keys, &job.src.project, &job.key, opts_hash(&job.opts))));
let from_disk = disk.as_ref().and_then(|(dc, k)| dc.get_frame(*k));
let ci = match from_disk {
Some(f) => FrameImage::Cpu(Arc::new(egui::ColorImage::from_rgba_premultiplied([f.width as usize, f.height as usize], &f.rgba))),
None => {
let ci = self.render_job(&job);
if let (Some((dc, k)), FrameImage::Cpu(img)) = (&disk, &ci) {
let rgba: Vec<u8> = img.pixels.iter().flat_map(|c| c.to_array()).collect();
dc.put_frame(*k, img.size[0] as u32, img.size[1] as u32, &rgba);
}
ci
}
};
if job.urgent
&& let Ok(mut l) = last.lock()
@@ -368,6 +429,23 @@ impl Worker {
}
true
}
fn render_job(&self, job: &Job) -> FrameImage {
let mut r = Renderer::new(&job.src.project, job.src.footage.as_ref(), job.opts);
r.expr = job.src.expr.as_deref();
r.cache = Some(&job.src.layer_cache);
r.accel = job.src.gpu.as_ref().map(|g| g as &dyn effectcraft_engine::render::Accelerator);
// The GPU leaves the frame in a texture for the viewer; otherwise (Software Only, no
// adapter, or a frame the GPU cannot finish here) the CPU renders it.
let gpu_frame = match (&job.src.gpu, r.active_accel()) {
(Some(g), Some(_)) if job.src.gpu_display => g.render_display(&r, job.comp, job.t),
_ => None,
};
match gpu_frame {
Some(f) => FrameImage::Gpu(Arc::new(f)),
None => FrameImage::Cpu(Arc::new(to_color_image(&r.comp_frame(job.comp, job.t)))),
}
}
}
struct Job {
+17 -6
View File
@@ -327,6 +327,7 @@ impl EffectcraftApp {
layer_cache: self.session.layer_cache.clone(),
gpu: self.gpu.clone(),
gpu_display: self.gpu_display(),
disk: self.session.disk_cache.clone(),
}
}
@@ -460,13 +461,11 @@ impl EffectcraftApp {
self.request_frame_with(comp, frame, scale, true);
}
fn request_frame_with(&self, comp: ItemId, frame: i64, scale: f64, urgent: bool) {
let Some(c) = self.session.project.comp(comp) else { return };
let key = self.frame_key(comp, frame, scale);
let t = c.frame_rate.tick_of(frame);
/// Render options of viewer frames of `comp` at `scale`.
pub fn frame_opts(&self, comp: ItemId, scale: f64) -> RenderOpts {
let (draft, _) = self.session.state.viewer.fast_previews.render(self.ui.viewer.interacting);
let roi = self.session.state.region_of_interest.filter(|_| self.session.active_comp_id() == Some(comp));
let opts = RenderOpts {
RenderOpts {
scale,
motion_blur: true,
guides: true,
@@ -474,7 +473,14 @@ impl EffectcraftApp {
view: self.session.view_camera(comp),
roi,
backend: effectcraft_engine::render::Backend::Auto,
};
}
}
fn request_frame_with(&self, comp: ItemId, frame: i64, scale: f64, urgent: bool) {
let Some(c) = self.session.project.comp(comp) else { return };
let key = self.frame_key(comp, frame, scale);
let t = c.frame_rate.tick_of(frame);
let opts = self.frame_opts(comp, scale);
if urgent {
self.frames.request_urgent(&self.render_source(), key, comp, t, opts);
} else {
@@ -789,6 +795,11 @@ impl EffectcraftApp {
self.session.poll_roto(true);
ctx.request_repaint_after(std::time::Duration::from_millis(100));
}
// 3D Camera Tracker: likewise (tracking, then solving).
if self.session.camera_job.is_some() || !self.session.camera_pending.is_empty() {
self.session.poll_camera(true);
ctx.request_repaint_after(std::time::Duration::from_millis(100));
}
self.apply_prefs(&ctx);
self.handle_events(&ctx);
self.tick_autosave(&ctx);
+5 -1
View File
@@ -802,7 +802,7 @@ fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option<Res
let Some(f) = app.hooks.pick_files.as_ref() else { return Some(Err("no file dialog available (pass `paths`)".into())) };
let paths = f(&[
"mp4", "mov", "m4v", "mkv", "webm", "png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "exr", "wav", "aif", "aiff", "mp3", "flac",
"ogg", "opus", "svg", "gltf", "glb", "obj",
"ogg", "opus", "svg", "psd", "psb", "gltf", "glb", "obj",
]);
match (paths.is_empty(), key) {
(true, _) => None,
@@ -825,6 +825,10 @@ fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option<Res
};
let Some(v) = picked else { return Some(Ok(Value::Null)) };
p.insert(key.to_string(), v);
// Photoshop files ask how to import them first.
if id == "file.import" && crate::panels::dialogs::open_form(app, id, &Value::Object(p.clone())) {
return Some(Ok(json!({"dialog": id})));
}
// Save (untitled) becomes Save As.
let id = if id == "file.save" { "file.saveAs" } else { id };
let r = app.session.execute(id, Value::Object(p)).map_err(|e| e.to_string());
@@ -0,0 +1,438 @@
//! 3D Camera Tracker in the Composition viewer and Effect Controls:
//!
//! - while the effect is selected, the solved 3D track points are drawn as coloured targets
//! sized by depth (or the 2D source tracks with Show Track Points ▸ 2D Source);
//! - hovering between three points shows the target plane they span (a disc in perspective);
//! click a point (Shift adds / toggles) or drag a marquee to select points, click inside a
//! triangle to select its three points; the selection's target is drawn;
//! - right-click: Set Ground Plane and Origin, Create Text / Solid / Null / Shadow Catcher …
//! and Camera, Create Multiple …, Delete Selected Points; Delete removes the selection;
//! - the "Analyzing in background (step 1 of 2)" / "Solving camera" banner;
//! - the Effect Controls block with Analyze / Cancel / Create Camera, Method Used and Average
//! Error.
//!
//! Everything is dispatched to the `camera.*` commands.
use std::sync::Arc;
use effectcraft_engine::Session;
use effectcraft_engine::effects::camera_tracker as ct;
use effectcraft_engine::geom::{Mat3, vec2 as gv2};
use effectcraft_engine::keyframe::Value;
use effectcraft_engine::project::{Comp, Layer, LayerId, PropGroup, Uid};
use effectcraft_engine::render::EvalCtx;
use effectcraft_engine::track::camtrack::{CameraSolve, CameraTracks, Target, point_color};
use egui::{Align2, Color32, Pos2, Rect, Sense, Stroke, pos2, vec2};
use serde_json::json;
use super::viewer::ViewerMap;
use crate::theme::Tokens;
use crate::{EffectcraftApp, widgets};
/// The tracker whose points the viewer shows.
struct Active {
layer: LayerId,
uid: Uid,
solve: Option<Arc<CameraSolve>>,
tracks: Option<Arc<CameraTracks>>,
show_3d: bool,
point_size: f64,
target_size: f64,
/// Layer pixels → comp pixels.
l2c: Mat3,
frame: usize,
}
/// A drawn point: (id, screen position, screen radius).
type Hit = (u32, Pos2, f32);
fn tracker_group(l: &Layer, uid: Uid) -> Option<&PropGroup> {
l.props.find_group(uid).filter(|g| matches!(&g.kind, effectcraft_engine::project::GroupKind::Effect { effect } if effect == ct::ID))
}
/// The selected 3D Camera Tracker effect on an active layer (After Effects shows the points
/// while the effect is selected).
fn active(s: &Session, comp: &Comp, ectx: &EvalCtx, l2c: &dyn Fn(&EvalCtx, &Layer) -> Mat3) -> Option<Active> {
let time = ectx.time;
for (lid, uid) in &s.state.selected_props {
let Some(l) = comp.layer(*lid).filter(|l| l.is_active_at(time)) else { continue };
let Some(g) = tracker_group(l, *uid) else { continue };
let params = effectcraft_engine::camera_track::static_params(g);
let val = |id: &str| effectcraft_engine::effects::warp_stab::param(&params, id).cloned();
let solve = ct::solve(&params);
let tracks = ct::tracks(&params);
let lt = l.layer_time(time).seconds();
let frame = solve.as_ref().and_then(|sv| sv.frame_at(lt)).or_else(|| {
let t = tracks.as_ref()?;
(t.frame_duration > 0.0).then(|| ((lt - t.start) / t.frame_duration).round().clamp(0.0, t.frames.saturating_sub(1) as f64) as usize)
});
return Some(Active {
layer: *lid,
uid: *uid,
solve,
tracks,
show_3d: val("showTrackPoints").map(|v| v.as_enum()).unwrap_or(1) == 1,
point_size: val("trackPointSize").map(|v| v.as_f64()).unwrap_or(100.0),
target_size: val("targetSize").map(|v| v.as_f64()).unwrap_or(100.0),
l2c: l2c(ectx, l),
frame: frame.unwrap_or(0),
});
}
None
}
fn to_screen(a: &Active, map: &ViewerMap, q: [f64; 2]) -> Pos2 {
let c = a.l2c.apply(gv2(q[0], q[1]));
map.to_screen([c.x, c.y])
}
fn col(id: u32) -> Color32 {
let c = point_color(id);
Color32::from_rgb((c[0] * 255.0) as u8, (c[1] * 255.0) as u8, (c[2] * 255.0) as u8)
}
/// Comp-pixel scale of the layer → comp mapping.
fn l2c_scale(m: &Mat3) -> f32 {
m.0[0][0].hypot(m.0[1][0]) as f32
}
/// Draw the target disc (concentric rings in perspective) of `t` on frame `k`.
fn draw_target(p: &egui::Painter, a: &Active, map: &ViewerMap, sv: &CameraSolve, t: &Target) {
let Some(cam) = sv.frames.get(a.frame) else { return };
let r = t.size * 0.5 * a.target_size / 100.0;
for (i, frac) in [1.0, 0.66, 0.33].into_iter().enumerate() {
let ring = ct::target_circle(t.center, t.normal, r * frac, 48);
let pts: Vec<Pos2> = ring.iter().filter_map(|x| cam.project(sv.size, *x)).map(|q| to_screen(a, map, q)).collect();
if pts.len() < 3 {
return;
}
let fill = if i % 2 == 0 { Color32::from_rgba_unmultiplied(0xd0, 0x30, 0x30, 70) } else { Color32::from_rgba_unmultiplied(0xff, 0xff, 0xff, 50) };
p.add(egui::Shape::convex_polygon(pts.clone(), fill, Stroke::new(1.0, Color32::from_rgb(0xe0, 0x40, 0x40))));
}
if let Some(c) = cam.project(sv.size, t.center) {
let c = to_screen(a, map, c);
p.circle_filled(c, 3.0, Color32::from_rgb(0xff, 0xd0, 0x40));
}
}
/// The three drawn points forming a triangle around `hp` (nearest first).
fn triangle_at(hits: &[Hit], hp: Pos2) -> Option<[u32; 3]> {
let mut near: Vec<&Hit> = hits.iter().collect();
near.sort_by(|x, y| x.1.distance_sq(hp).total_cmp(&y.1.distance_sq(hp)));
let near: Vec<&Hit> = near.into_iter().take(8).collect();
let inside = |a: Pos2, b: Pos2, c: Pos2| {
let s = |p: Pos2, q: Pos2| (q.x - p.x) * (hp.y - p.y) - (q.y - p.y) * (hp.x - p.x);
let (d1, d2, d3) = (s(a, b), s(b, c), s(c, a));
!((d1 < 0.0 || d2 < 0.0 || d3 < 0.0) && (d1 > 0.0 || d2 > 0.0 || d3 > 0.0))
};
for i in 0..near.len() {
for j in i + 1..near.len() {
for k in j + 1..near.len() {
let (a, b, c) = (near[i], near[j], near[k]);
// Skip slivers.
let area = ((b.1 - a.1).x * (c.1 - a.1).y - (b.1 - a.1).y * (c.1 - a.1).x).abs();
if area > 30.0 && inside(a.1, b.1, c.1) {
return Some([a.0, b.0, c.0]);
}
}
}
}
None
}
fn run(app: &mut EffectcraftApp, ctx: &egui::Context, cmd: &str, p: serde_json::Value) {
if let Err(e) = crate::menus::invoke(app, ctx, cmd, p) {
app.ui.status = e;
}
}
/// The viewer overlay and its interaction (call after the viewer's own gestures so it takes
/// the clicks while a 3D Camera Tracker is selected).
pub fn viewer_hook(
app: &mut EffectcraftApp,
ui: &mut egui::Ui,
painter: &egui::Painter,
map: &ViewerMap,
ectx: &EvalCtx,
l2c: &dyn Fn(&EvalCtx, &Layer) -> Mat3,
) {
let ctx = ui.ctx().clone();
let comp = ectx.comp;
banner(app, painter, map, ectx, l2c);
if !app.ui.viewer.show_layer_controls {
return;
}
let Some(a) = active(&app.session, comp, ectx, l2c) else { return };
let selected = app.session.state.camera_points.clone();
let lscale = l2c_scale(&a.l2c) * map.zoom;
let mut hits: Vec<Hit> = vec![];
match (&a.solve, a.show_3d) {
(Some(sv), true) => {
let pts = ct::projected(sv, a.frame);
let med = ct::median_depth(&pts);
for (id, q, z) in pts {
let sp = to_screen(&a, map, q);
if !map.area.expand(20.0).contains(sp) {
continue;
}
let r = (ct::target_radius(a.point_size, z, med) as f32 * lscale).clamp(2.0, 30.0);
let c = col(id);
let sel = selected.contains(&id);
if sel {
painter.circle_filled(sp, r, c);
painter.circle_stroke(sp, r + 1.5, Stroke::new(1.5, Color32::WHITE));
} else {
painter.circle_stroke(sp, r, Stroke::new((r * 0.3).clamp(1.0, 3.0), c));
painter.circle_filled(sp, (r * 0.25).max(1.0), c);
}
hits.push((id, sp, r));
}
}
_ => {
// 2D source tracks (also shown before the solve finishes).
if let Some(tr) = &a.tracks {
for t in &tr.tracks {
let Some(q) = t.at(a.frame as u32) else { continue };
let sp = to_screen(&a, map, q);
let c = col(t.id);
let r = (3.0 * a.point_size as f32 / 100.0).clamp(1.5, 12.0);
painter.line_segment([sp - vec2(r, r), sp + vec2(r, r)], Stroke::new(1.2, c));
painter.line_segment([sp - vec2(r, -r), sp + vec2(r, -r)], Stroke::new(1.2, c));
hits.push((t.id, sp, r));
}
}
}
}
let Some(sv) = a.solve.clone() else { return };
if app.ui.tool != crate::state::Tool::Selection {
return;
}
// Interaction over the image.
let resp = ui.interact(map.area, egui::Id::new("viewer-camera-tracker"), Sense::click_and_drag());
app.auto.add("viewer.cameraTracker", map.area, "3D Camera Tracker points");
for (id, sp, r) in hits.iter().take(400) {
app.auto.add(&format!("viewer.cameraTracker.point.{id}"), Rect::from_center_size(*sp, vec2(r * 2.0 + 4.0, r * 2.0 + 4.0)), "track point");
}
let hover = resp.hover_pos();
let point_at = |p: Pos2| hits.iter().filter(|h| h.1.distance(p) <= h.2 + 3.0).min_by(|x, y| x.1.distance(p).total_cmp(&y.1.distance(p))).map(|h| h.0);
let tri = hover.filter(|p| point_at(*p).is_none()).and_then(|p| triangle_at(&hits, p));
let mid = egui::Id::new("viewer-camera-tracker-marquee");
let marquee: Option<Pos2> = ui.data(|d| d.get_temp(mid));
// Hover target / the selection's target.
if marquee.is_none() {
if let Some(t3) = tri
&& let Some(t) = effectcraft_engine::camera_track::target_for(&sv, &t3, a.frame)
{
let ps: Vec<Pos2> = t3.iter().filter_map(|i| hits.iter().find(|h| h.0 == *i)).map(|h| h.1).collect();
if ps.len() == 3 {
painter.add(egui::Shape::closed_line(ps, Stroke::new(1.0, Color32::from_white_alpha(200))));
}
draw_target(painter, &a, map, &sv, &t);
} else if !selected.is_empty()
&& let Some(t) = effectcraft_engine::camera_track::target_for(&sv, &selected, a.frame)
{
draw_target(painter, &a, map, &sv, &t);
}
}
let mods = ui.input(|i| i.modifiers);
if resp.drag_started()
&& let Some(p) = ui.input(|i| i.pointer.press_origin())
{
ui.data_mut(|d| d.insert_temp(mid, p));
}
if let (Some(start), Some(p)) = (marquee, resp.interact_pointer_pos()) {
let r = Rect::from_two_pos(start, p);
let t = app.tokens;
painter.rect_filled(r, 0.0, t.accent.gamma_multiply(0.12));
painter.rect_stroke(r, 0.0, Stroke::new(1.0, t.accent), egui::StrokeKind::Middle);
if resp.drag_stopped() {
let ids: Vec<u32> = hits.iter().filter(|h| r.contains(h.1)).map(|h| h.0).collect();
ui.data_mut(|d| d.remove::<Pos2>(mid));
run(app, &ctx, "camera.selectPoints", json!({"points": ids, "add": mods.shift}));
}
} else if resp.drag_stopped() {
ui.data_mut(|d| d.remove::<Pos2>(mid));
}
if resp.clicked()
&& let Some(p) = resp.interact_pointer_pos()
{
match (point_at(p), triangle_at(&hits, p)) {
(Some(id), _) => run(app, &ctx, "camera.selectPoints", json!({"points": [id], "toggle": mods.shift})),
(None, Some(t3)) => run(app, &ctx, "camera.selectPoints", json!({"points": t3, "add": mods.shift})),
(None, None) => run(app, &ctx, "camera.selectPoints", json!({"points": []})),
}
}
// Right-click: the points under the cursor (selection, else the hovered triangle).
let cid = egui::Id::new("viewer-camera-tracker-menu-points");
if resp.secondary_clicked() {
let pts: Vec<u32> = if !selected.is_empty() {
selected.clone()
} else if let Some(p) = resp.interact_pointer_pos() {
point_at(p).map(|i| vec![i]).or_else(|| triangle_at(&hits, p).map(|t| t.to_vec())).unwrap_or_default()
} else {
vec![]
};
ui.data_mut(|d| d.insert_temp(cid, pts));
}
let base = json!({"layer": a.layer.0, "effect": a.uid});
resp.context_menu(|ui| {
let pts: Vec<u32> = ui.data(|d| d.get_temp(cid)).unwrap_or_default();
let with = |kind: Option<&str>, multiple: bool| {
let mut q = base.clone();
q["points"] = json!(pts);
if let Some(k) = kind {
q["kind"] = json!(k);
}
if multiple {
q["multiple"] = json!(true);
}
q
};
let some = !pts.is_empty();
let many = pts.len() > 1;
let mut pick: Option<(&str, serde_json::Value)> = None;
if ui.add_enabled(some, egui::Button::new("Set Ground Plane and Origin")).clicked() {
pick = Some(("camera.setGroundPlane", with(None, false)));
}
ui.separator();
for (label, kind) in [
("Create Text and Camera", "text"),
("Create Solid and Camera", "solid"),
("Create Null and Camera", "null"),
("Create Shadow Catcher, Camera and Light", "shadowCatcher"),
] {
if ui.add_enabled(some, egui::Button::new(label)).clicked() {
pick = Some(("camera.createFromSolve", with(Some(kind), false)));
}
}
ui.separator();
for (label, kind) in
[("Create Multiple Text Layers and Camera", "text"), ("Create Multiple Solids and Camera", "solid"), ("Create Multiple Nulls and Camera", "null")]
{
if ui.add_enabled(many, egui::Button::new(label)).clicked() {
pick = Some(("camera.createFromSolve", with(Some(kind), true)));
}
}
ui.separator();
if ui.add_enabled(some, egui::Button::new("Delete Selected Points")).clicked() {
pick = Some(("camera.deletePoints", with(None, false)));
}
if let Some((cmd, q)) = pick {
ui.close();
run(app, &ctx, cmd, q);
}
});
if resp.hovered()
&& !selected.is_empty()
&& ui.input(|i| i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace))
&& !ctx.egui_wants_keyboard_input()
{
run(app, &ctx, "camera.deletePoints", json!({"layer": a.layer.0, "effect": a.uid, "points": selected}));
}
}
/// "Analyzing in background (step 1 of 2)" / "Solving camera" over the analysed layer, and a
/// warning when the solve failed (unless Hide Warning Banner).
fn banner(app: &mut EffectcraftApp, painter: &egui::Painter, map: &ViewerMap, ectx: &EvalCtx, l2c: &dyn Fn(&EvalCtx, &Layer) -> Mat3) {
let comp = ectx.comp;
let time = ectx.time;
let cid = ectx.comp_id;
let draw = |app: &mut EffectcraftApp, layer: &Layer, text: String, color: Color32, id: &str| {
let m = l2c(ectx, layer);
let (w, h) = effectcraft_engine::render::source_size(&app.session.project, layer);
let c = m.apply(gv2(w as f64 / 2.0, h as f64 / 2.0));
let at = map.to_screen([c.x, c.y]);
let galley = painter.layout_no_wrap(text.clone(), Tokens::medium(13.0), Color32::WHITE);
let r = Rect::from_center_size(at, galley.size() + vec2(28.0, 14.0)).intersect(map.area);
painter.rect_filled(r, 3.0, color);
painter.galley(pos2(r.center().x - galley.size().x / 2.0, r.center().y - galley.size().y / 2.0), galley, Color32::WHITE);
app.auto.add(id, r, &text);
};
if let Some((cc, cl, _)) = app.session.camera_target()
&& cc == cid
&& let Some(pr) = app.session.camera_progress()
&& let Some(layer) = comp.layer(cl)
&& layer.is_active_at(time)
{
draw(app, layer, pr.banner(), Color32::from_rgba_unmultiplied(0x1d, 0x4f, 0x9c, 0xe0), "viewer.cameraBanner");
return;
}
// A failed solve: tracks without a solve while nothing is running or queued.
for l in comp.layers.iter().filter(|l| l.is_active_at(time)) {
let Some(fx) = l.effects() else { continue };
for g in fx.groups().filter(|g| matches!(&g.kind, effectcraft_engine::project::GroupKind::Effect { effect } if effect == ct::ID)) {
let params = effectcraft_engine::camera_track::static_params(g);
let hide = effectcraft_engine::effects::warp_stab::param(&params, "advanced/hideWarningBanner").is_some_and(Value::as_bool);
let solve_key = matches!(g.prop(ct::SOLVE_KEY).map(|p| &p.value), Some(Value::Str(s)) if !s.is_empty());
let failed = solve_key && ct::tracks(&params).is_some() && ct::solve(&params).is_none();
if failed && !hide && !app.session.camera_pending.iter().any(|(_, ll, u)| *ll == l.id && *u == g.uid) {
draw(app, l, "Unable to solve camera".into(), Color32::from_rgba_unmultiplied(0x9c, 0x2a, 0x1d, 0xe0), "viewer.cameraWarning");
return;
}
}
}
}
/// Effect Controls: Analyze / Cancel, Create Camera and the solve report.
#[allow(clippy::too_many_arguments)]
pub fn editor(
app: &mut EffectcraftApp,
ui: &mut egui::Ui,
p: &egui::Painter,
layer: &Layer,
g: &PropGroup,
r: Rect,
actions: &mut Vec<(String, serde_json::Value)>,
) {
let t = app.tokens;
let running = app.session.camera_target().is_some_and(|(_, l, u)| l == layer.id && u == g.uid) && app.session.is_camera_analyzing();
let busy = app.session.is_camera_analyzing();
let me = json!({"layer": layer.id.0, "effect": g.uid});
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 b3 = Rect::from_min_size(pos2(b2.max.x + 8.0, b1.min.y), vec2(110.0, 22.0));
if widgets::text_button(ui, b1, "Analyze", false, &t, egui::Id::new(("ct-analyze", g.uid))).clicked() && !busy {
actions.push(("camera.analyze".into(), me.clone()));
}
app.auto.add(&format!("effectControls.cameraTracker.{}.analyze", g.uid), b1, "Analyze");
if running {
if widgets::text_button(ui, b2, "Cancel", false, &t, egui::Id::new(("ct-cancel", g.uid))).clicked() {
actions.push(("camera.cancel".into(), json!({})));
}
app.auto.add(&format!("effectControls.cameraTracker.{}.cancel", g.uid), b2, "Cancel");
}
let params = effectcraft_engine::camera_track::static_params(g);
let solve = ct::solve(&params);
if solve.is_some() {
if widgets::text_button(ui, b3, "Create Camera", false, &t, egui::Id::new(("ct-camera", g.uid))).clicked() {
actions.push(("camera.create".into(), me.clone()));
}
app.auto.add(&format!("effectControls.cameraTracker.{}.createCamera", g.uid), b3, "Create Camera");
}
let status = match app.session.camera_progress() {
Some(pr) if running => pr.banner(),
_ => match &solve {
Some(sv) => format!("Method Used: {} Average Error: {:.2} pixels", sv.method_used.label(), sv.average_error),
None if ct::tracks(&params).is_some() && app.session.camera_pending.iter().any(|(_, l, u)| *l == layer.id && *u == g.uid) => {
"Waiting to solve".into()
}
None if ct::tracks(&params).is_some() => "Unable to solve camera: try another Solve Method or Shot Type".into(),
None if app.session.camera_pending.iter().any(|(_, l, u)| *l == layer.id && *u == g.uid) => "Waiting to analyze".into(),
None => "Not analyzed: click Analyze".into(),
},
};
p.text(pos2(r.min.x + 24.0, r.min.y + 38.0), Align2::LEFT_CENTER, status, Tokens::ui(11.5), t.text_dim);
if let Some(sv) = &solve {
let f = sv.focal();
p.text(
pos2(r.min.x + 24.0, r.min.y + 56.0),
Align2::LEFT_CENTER,
format!("{} solved points · Horizontal Angle of View {:.1}°", sv.points.len(), sv.hfov(f)),
Tokens::ui(11.5),
t.text_faint,
);
}
}
/// Height of [`editor`].
pub const EDITOR_HEIGHT: f32 = 68.0;
@@ -574,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::camera_tracker::ID => super::camera_tracker_ui::EDITOR_HEIGHT,
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,
@@ -950,6 +951,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::camera_tracker::ID => super::camera_tracker_ui::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),
+20
View File
@@ -108,6 +108,16 @@ fn has(p: &Value, keys: &[&str]) -> bool {
}
/// If `id` is a dialog command invoked without its parameters, open its form and return true.
/// The import includes a Photoshop document.
fn is_layered(p: &Value) -> bool {
let psd = |v: &Value| v.as_str().is_some_and(|s| matches!(s.rsplit('.').next().map(str::to_ascii_lowercase).as_deref(), Some("psd" | "psb")));
match p.get("paths").or(p.get("path")) {
Some(Value::Array(a)) => a.iter().any(psd),
Some(v) => psd(v),
None => false,
}
}
pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool {
let s = &app.session;
let comp = s.active_comp();
@@ -116,6 +126,16 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool {
let lt = layer.map(|l| l.layer_time(t)).unwrap_or(t);
let base = p.clone();
let (title, fields): (String, Vec<Field>) = match id {
// Photoshop files: Import Kind (Footage / Composition / – Retain Layer Sizes).
"file.import" if !has(p, &["importAs"]) && is_layered(p) => (
"Import Photoshop File".into(),
vec![Field::choice(
"importAs",
"Import Kind",
&[("Footage", json!("footage")), ("Composition", json!("composition")), ("Composition - Retain Layer Sizes", json!("compositionLayerSizes"))],
1,
)],
),
"layer.setTransform" if !has(p, &["value"]) => {
let prop = p.get("prop").and_then(Value::as_str).unwrap_or("position");
let cur = layer.and_then(|l| l.transform()).and_then(|tr| tr.get(if prop == "anchorPoint" { "anchor" } else { prop })).map(|pr| pr.value_at(lt));
+1
View File
@@ -1,6 +1,7 @@
//! Panel bodies.
pub mod anim_tools;
pub mod camera_tracker_ui;
pub mod comp_settings;
pub mod dialogs;
pub mod dialogs_3d;
+1 -1
View File
@@ -108,7 +108,7 @@ fn output_menu(it: &RenderQueueItem, available: &[OutputFormat]) -> Vec<(String,
v.push((mark(o.bitrate_kbps == kbps, &format!("Bitrate: {} Mbps", kbps / 1000)), json!({"bitrate": kbps})));
}
}
OutputFormat::JpegSequence => {
OutputFormat::JpegSequence | OutputFormat::WebM => {
v.push(("-".into(), Value::Null));
for q in [60u8, 80, 90, 100] {
v.push((mark(o.quality == q, &format!("Quality: {q}")), json!({"quality": q})));
+25
View File
@@ -849,6 +849,31 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
if let Some((a, b)) = run {
draw_run(a, b);
}
// Disk-cached frames not in RAM (blue), like After Effects.
if app.session.disk_cache.is_some() {
let frames = comp.frame_rate.frame_at(comp.duration);
let opts = app.frame_opts(cid, scale_key as f64 / 1000.0);
let disk = app.frames.disk_frames(&app.render_source(), app.session.revision, cid.0, scale_key, app.view_hash(cid), frames, &opts);
let draw_blue = |a: i64, b: i64| {
let x0 = tm.x(a as f64 * fd);
let x1 = tm.x((b + 1) as f64 * fd);
p.rect_filled(Rect::from_min_max(pos2(x0, cy0), pos2(x1, cy0 + 2.5)), 0.0, t.cache_blue);
};
let mut run: Option<(i64, i64)> = None;
for f in disk {
run = match run {
Some((a, b)) if f == b + 1 => Some((a, f)),
Some((a, b)) => {
draw_blue(a, b);
Some((f, f))
}
None => Some((f, f)),
};
}
if let Some((a, b)) = run {
draw_blue(a, b);
}
}
// Ticks + labels.
// Label spacing in whole frames (AE: `00:15f`-style seconds:frames labels).
let fps_i = fr.as_f64().round().max(1.0) as i64;
+5 -2
View File
@@ -130,11 +130,14 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let cur = current(app);
let can_track = has_comp && source.is_some();
// Track Camera / Warp Stabilizer (not available) and Track Motion / Stabilize Motion.
// Track Camera / Warp Stabilizer and Track Motion / Stabilize Motion.
let r1 = Rect::from_min_size(pos2(x0, y), vec2(half, 22.0));
let r2 = Rect::from_min_size(pos2(x0 + half + 6.0, y), vec2(half, 22.0));
let src_p = source.map(|l| json!({"layer": l.0})).unwrap_or_else(|| json!({}));
button(app, ui, r1, "Track Camera", false, "tracker.trackCamera");
let can_camera = app.session.is_enabled("track.camera") && !app.session.is_camera_analyzing();
if button(app, ui, r1, "Track Camera", can_camera, "tracker.trackCamera") {
run(app, &ctx, "track.camera", json!({}));
}
let can_warp = app.session.is_enabled("track.warpStabilizer") && !app.session.is_warp_analyzing();
if button(app, ui, r2, "Warp Stabilizer", can_warp, "tracker.warpStabilizer") {
run(app, &ctx, "track.warpStabilizer", json!({}));
+3
View File
@@ -1144,6 +1144,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
app.session.open_comp(*item);
}
}
// 3D Camera Tracker points, targets and their menu (on top of the viewer's gestures while
// the effect is selected), and its analysis banner.
super::camera_tracker_ui::viewer_hook(app, ui, &painter, &map, &ectx, &|c, l| l2c(c, l).0);
// Effect point controls and crosshair/eyedropper picks (on top of the viewer's gestures).
crate::panels::effect_controls::viewer_hook(app, ui, &painter, &map, &ectx, &|c, l| l2c(c, l).0);
+3
View File
@@ -60,6 +60,8 @@ pub struct Tokens {
pub cti: Color32,
pub work_area: Color32,
pub cache_green: Color32,
/// Timeline cache bar for frames held in the disk cache (not in RAM).
pub cache_blue: Color32,
pub keyframe: Color32,
pub keyframe_selected: Color32,
pub pasteboard: Color32,
@@ -108,6 +110,7 @@ impl Tokens {
cti: Color32::from_rgb(0x2d, 0x8c, 0xeb),
work_area: Color32::from_rgb(0x4a, 0x4a, 0x4a),
cache_green: Color32::from_rgb(0x3c, 0xa6, 0x4c),
cache_blue: Color32::from_rgb(0x3a, 0x6f, 0xd8),
keyframe: Color32::from_rgb(0xa8, 0xa8, 0xa8),
keyframe_selected: Color32::from_rgb(0x3d, 0x8f, 0xf5),
pasteboard: Color32::from_rgb(0x1a, 0x1a, 0x1a),