diff --git a/CLAUDE.md b/CLAUDE.md index e52386942..21f2df36b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,6 +22,7 @@ EffectCraft is a clean-room, open-source, pure-Rust motion graphics and visual e - **Everything is agent-drivable:** every interactive widget registers an automation id; UI state is serde. - **Community links** (Help menu, About, header Discord button): https://discord.gg/artcraft, https://getartcraft.com, https://getartcraft.com/apps/effectcraft, https://github.com/storytold/effectcraft. - **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, layers, assets, wasm). +- **New workspace crates** must include `[lints] workspace = true` in `Cargo.toml` to inherit `[workspace.lints]`. Production crate roots must declare the required crate-level lint attributes before modules or other crate items; crate-level `//!` documentation may precede them. - **Commits:** one task id per commit (`M6.3: trim paths`). Only green states. ## Running and looking at the app diff --git a/Cargo.toml b/Cargo.toml index 79ec24655..8c82c7281 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -135,9 +135,11 @@ sys-locale = "0.3" unsafe_code = "forbid" [workspace.lints.clippy] +cognitive_complexity = "deny" +too_many_arguments = "deny" +wildcard_imports = "deny" dbg_macro = "warn" todo = "warn" -too_many_arguments = "allow" needless_range_loop = "allow" type_complexity = "allow" diff --git a/apps/effectcraft-cli/tests/it/qa_template.rs b/apps/effectcraft-cli/tests/it/qa_template.rs index 8cb533fda..ffbdb94e9 100644 --- a/apps/effectcraft-cli/tests/it/qa_template.rs +++ b/apps/effectcraft-cli/tests/it/qa_template.rs @@ -152,6 +152,13 @@ fn essential_graphics_template_via_script_and_commands() { assert_eq!(image::open(&frames[0]).unwrap().to_rgba8(), edit); assert_eq!(std::fs::read(dir.join("lt.ectemplate")).unwrap(), original_template); + invalid_template_values_fail_cleanly(&dir); + template_usage_errors(&dir); + rational_template_keeps_exact_rate(&dir, &original_template); + let _ = std::fs::remove_dir_all(&dir); +} + +fn invalid_template_values_fail_cleanly(dir: &PathBuf) { // Invalid values fail before the queue creates an output directory, or frame rendering // overwrites an existing output. A JSON object and existing control names are required. for (name, values, expected) in [ @@ -164,25 +171,27 @@ fn essential_graphics_template_via_script_and_commands() { std::fs::write(dir.join(&values_file), values).unwrap(); let output_dir = dir.join(format!("failed-{name}")); let out = output_dir.join("frame_[#####].png"); - let (code, error) = cli(&dir, &["render", "--template", "lt.ectemplate", "--values", &values_file, "--out", out.to_str().unwrap()]); + let (code, error) = cli(dir, &["render", "--template", "lt.ectemplate", "--values", &values_file, "--out", out.to_str().unwrap()]); assert_eq!(code, 1, "{name}: {error}"); assert!(error["error"].as_str().unwrap().contains(expected), "{name}: {error}"); assert!(!output_dir.exists(), "{name}: invalid input created render output"); std::fs::write(dir.join("untouched.png"), b"existing output").unwrap(); - let (code, error) = cli(&dir, &["render-frame", "--template", "lt.ectemplate", "--values", &values_file, "--out", "untouched.png"]); + let (code, error) = cli(dir, &["render-frame", "--template", "lt.ectemplate", "--values", &values_file, "--out", "untouched.png"]); assert_eq!(code, 1, "{name}: {error}"); assert_eq!(std::fs::read(dir.join("untouched.png")).unwrap(), b"existing output"); } - let (code, error) = cli(&dir, &["render-frame", "--template", "lt.ectemplate", "--values", "missing-values.json", "--out", "missing.png"]); + let (code, error) = cli(dir, &["render-frame", "--template", "lt.ectemplate", "--values", "missing-values.json", "--out", "missing.png"]); assert_eq!(code, 1, "{error}"); assert!(error["error"].as_str().unwrap().contains("cannot read values"), "{error}"); assert!(!dir.join("missing.png").exists()); std::fs::write(dir.join("large-values.json"), vec![b' '; 1024 * 1024 + 1]).unwrap(); - let (code, error) = cli(&dir, &["render-frame", "--template", "lt.ectemplate", "--values", "large-values.json", "--out", "large.png"]); + let (code, error) = cli(dir, &["render-frame", "--template", "lt.ectemplate", "--values", "large-values.json", "--out", "large.png"]); assert_eq!(code, 1, "{error}"); assert!(error["error"].as_str().unwrap().contains("exceeds 1 MiB"), "{error}"); assert!(!dir.join("large.png").exists()); +} +fn template_usage_errors(dir: &PathBuf) { for args in [ vec!["render-frame", "--values", "values.json"], vec!["info", "--template", "lt.ectemplate"], @@ -191,10 +200,12 @@ fn essential_graphics_template_via_script_and_commands() { vec!["render", "--template", "lt.ectemplate", "--queue"], vec!["render-frame", "--template", "lt.ectemplate", "--bridge", "1"], ] { - let output = Command::new(crate::binary()).current_dir(&dir).args(&args).output().unwrap(); + let output = Command::new(crate::binary()).current_dir(dir).args(&args).output().unwrap(); assert_eq!(output.status.code(), Some(2), "{args:?}: {}", String::from_utf8_lossy(&output.stderr)); } +} +fn rational_template_keeps_exact_rate(dir: &PathBuf, original_template: &[u8]) { // File-loaded templates can use exact rational rates that comp.new's float parser rounds. // Preserve that rate, Tick duration, work area, background and locked source layers. let mut project = effectcraft_engine::project::Project::from_json(&std::fs::read_to_string(dir.join("t.ecproj")).unwrap()).unwrap(); @@ -207,13 +218,13 @@ fn essential_graphics_template_via_script_and_commands() { comp.background = [0.08, 0.16, 0.24]; comp.layers.first_mut().unwrap().switches.locked = true; std::fs::write(dir.join("rational.ecproj"), project.to_json()).unwrap(); - ok(&dir, &["exec", "essential.exportTemplate", r#"{"comp":"Lower Third","path":"rational.ectemplate"}"#, "rational.ecproj"]); + ok(dir, &["exec", "essential.exportTemplate", r#"{"comp":"Lower Third","path":"rational.ectemplate"}"#, "rational.ecproj"]); let rational_template = std::fs::read(dir.join("rational.ectemplate")).unwrap(); - let rational = ok(&dir, &["render-frame", "--template", "rational.ectemplate", "--frame", "5", "--out", "rational.png"]); + let rational = ok(dir, &["render-frame", "--template", "rational.ectemplate", "--frame", "5", "--out", "rational.png"]); assert!((rational["time"].as_f64().unwrap() - 5.0 / rate.as_f64()).abs() < 1e-9, "{rational}"); assert_eq!((rational["width"].as_u64().unwrap(), rational["height"].as_u64().unwrap()), (320, 90)); let output = dir.join("rational-work-area/frame_[#####].png"); - ok(&dir, &["render", "--template", "rational.ectemplate", "--work-area", "--out", output.to_str().unwrap(), "--format", "png", "--resolution", "0.05"]); + ok(dir, &["render", "--template", "rational.ectemplate", "--work-area", "--out", output.to_str().unwrap(), "--format", "png", "--resolution", "0.05"]); let count = std::fs::read_dir(dir.join("rational-work-area")) .unwrap() .filter_map(Result::ok) @@ -222,8 +233,8 @@ fn essential_graphics_template_via_script_and_commands() { assert_eq!(count, 4, "the template's exact work area contains frames 3 through 6"); assert_eq!(std::fs::read(dir.join("rational.ectemplate")).unwrap(), rational_template); assert_eq!(std::fs::read(dir.join("lt.ectemplate")).unwrap(), original_template); - let _ = std::fs::remove_dir_all(&dir); } + fn template_output_alias_fixture(case: &str) -> PathBuf { let dir = std::env::temp_dir().join(format!("ec-qa-template-output-{case}-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); diff --git a/apps/effectcraft-web/src/frames.rs b/apps/effectcraft-web/src/frames.rs index c7f350526..69089e308 100644 --- a/apps/effectcraft-web/src/frames.rs +++ b/apps/effectcraft-web/src/frames.rs @@ -311,10 +311,11 @@ pub fn stats() -> Value { }) } +type PendingFrameCheck = (js_sys::Function, js_sys::Function, (u32, u32, Vec), f64); + thread_local! { /// `workerFrameCheck` promises waiting for their frame: id → (resolve, reject, local frame). - #[allow(clippy::type_complexity)] - static CHECKS: RefCell), f64)>> = RefCell::new(Default::default()); + static CHECKS: RefCell> = RefCell::new(Default::default()); } /// `effectcraft.workerFrameCheck({time?, scale?, backend?})`: render the active comp's frame in diff --git a/apps/effectcraft/src/main.rs b/apps/effectcraft/src/main.rs index 6df015290..17bbb7e1b 100644 --- a/apps/effectcraft/src/main.rs +++ b/apps/effectcraft/src/main.rs @@ -9,8 +9,8 @@ //! //! `--control ` (or `EFFECTCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server; //! see `effectcraft_ui_egui::control` for the methods. -#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")] // Built everywhere so its tests run on every platform; only Linux AppImages use it. mod appearance; diff --git a/apps/effectcraft/tests/args.rs b/apps/effectcraft/tests/args.rs index 41281eb23..62c591b45 100644 --- a/apps/effectcraft/tests/args.rs +++ b/apps/effectcraft/tests/args.rs @@ -1,6 +1,4 @@ //! The desktop binary rejects a bad command line before it starts anything (#997). -// Integration tests: unwrapping and panicking on failure is fine here. -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] use std::path::PathBuf; use std::process::{Command, Stdio}; diff --git a/clippy.toml b/clippy.toml index 287e14edc..3522a15b6 100644 --- a/clippy.toml +++ b/clippy.toml @@ -5,3 +5,4 @@ allow-indexing-slicing-in-tests = true disallowed-types = [ { path = "egui::ComboBox", reason = "use craft_ui::ComboBox: egui's combo box menu can't grow back while open (egui#5225)" }, ] +cognitive-complexity-threshold = 30 diff --git a/crates/automation/tests/qa/compositing.rs b/crates/automation/tests/qa/compositing.rs index 52d5e4fb2..da78ae116 100644 --- a/crates/automation/tests/qa/compositing.rs +++ b/crates/automation/tests/qa/compositing.rs @@ -7,6 +7,10 @@ use serde_json::{Value, json}; use crate::harness::{Img, Qa, coverage, luma, mean_diff}; +fn lum(i: &Img, x: u32, y: u32) -> f64 { + luma(i.get_pixel(x, y)) +} + fn px(img: &Img, x: u32, y: u32) -> [u8; 4] { img.get_pixel(x, y).0 } @@ -25,13 +29,12 @@ fn write_sequence(qa: &Qa) -> String { qa.path("seq/ball_000.png") } -#[test] -fn compositing_matte_blend_adjustment_3d_and_alpha_renders() { - let mut qa = Qa::new("compositing"); - +/// Import the sequence (reinterpreted at 12 fps) and a PSD written by the app; returns the +/// sequence item and the PSD's composition. +fn import_footage(qa: &mut Qa) -> (Value, Value) { // Footage: the sequence, and a layered PSD written by the app itself (Save Frame As ▸ // Photoshop Layers) from a small comp. - let seq = write_sequence(&qa); + let seq = write_sequence(qa); let imp = qa.exec("file.import", json!({"paths": [seq]})); let seq_item = imp["items"][0].clone(); let project = qa.tool("get_project", json!({})); @@ -55,10 +58,14 @@ fn compositing_matte_blend_adjustment_3d_and_alpha_renders() { let pc = qa.tool("get_comp", json!({"comp": psd_comp.clone()})); let names: Vec<&str> = pc["layers"].as_array().unwrap().iter().map(|l| l["name"].as_str().unwrap()).collect(); assert_eq!(names, ["Bar", "Gray"], "the PSD's layers, top first: {pc}"); + (seq_item, psd_comp) +} +/// The main comp: the sequence plays, then a Screen-blended PSD; returns the Normal-mode frame. +fn main_comp_and_blend(qa: &mut Qa, seq_item: &Value, psd_comp: &Value) -> Img { // The main comp. - let main = qa.exec("comp.new", json!({"name": "Main", "width": 160, "height": 90, "frameRate": 12, "duration": 1}))["comp"].clone(); - qa.exec("layer.addItem", json!({"item": seq_item})); + qa.exec("comp.new", json!({"name": "Main", "width": 160, "height": 90, "frameRate": 12, "duration": 1})); + qa.exec("layer.addItem", json!({"item": seq_item.clone()})); let a = qa.frame(None, 0.0); let b = qa.frame(None, 0.5); assert!(mean_diff(&a, &b) > 1.0, "the sequence plays"); @@ -75,7 +82,10 @@ fn compositing_matte_blend_adjustment_3d_and_alpha_renders() { for c in 0..3 { assert!(s[c] > n[c] && s[c] > [32, 64, 96][c], "Screen brightens channel {c}: {s:?}"); } + normal +} +fn track_matte(qa: &mut Qa) { // Track matte: a magenta solid seen only through text. qa.exec("layer.newText", json!({"text": "MATTE", "name": "Matte", "size": 40, "fill": "#ffffff"})); qa.exec("layer.newSolid", json!({"name": "Fill", "color": "#ff00ff", "width": 160, "height": 90})); @@ -91,7 +101,9 @@ fn compositing_matte_blend_adjustment_3d_and_alpha_renders() { let fill = info["layers"].as_array().unwrap().iter().find(|l| l["name"] == "Fill").unwrap().clone(); assert_eq!(fill["trackMatte"]["kind"], "Alpha Matte"); qa.exec("layer.setSwitch", json!({"layers": ["Fill", "Matte"], "switch": "video", "value": false})); +} +fn adjustment_layer(qa: &mut Qa, normal: &Img) { // Adjustment layer: Lumetri exposure, inside a feathered, expanded elliptical mask; then Glow. qa.exec("layer.newAdjustment", json!({"name": "Grade"})); let fx = qa.exec("effect.apply", json!({"effect": "Lumetri Color", "layers": ["Grade"]})); @@ -103,7 +115,6 @@ fn compositing_matte_blend_adjustment_3d_and_alpha_renders() { let mk = qa.exec("layer.addMask", json!({"layer": "Grade", "shape": "ellipse", "rect": [40, 20, 80, 50]})); assert!(mk["mask"].is_u64(), "{mk}"); let hard = qa.frame(None, 0.5); - let lum = |i: &Img, x, y| luma(i.get_pixel(x, y)); assert!(lum(&hard, 80, 45) > lum(&before, 80, 45) - 1.0 && lum(&hard, 5, 5) < lum(&before, 5, 5) - 10.0, "the mask limits the grade to the ellipse"); qa.exec("layer.mask.set", json!({"layer": "Grade", "field": "expansion", "value": 8})); let grown = qa.frame(None, 0.5); @@ -116,7 +127,7 @@ fn compositing_matte_blend_adjustment_3d_and_alpha_renders() { } n }; - assert!(bright(&grown, &normal) > bright(&hard, &normal) + 100, "expansion grows the graded area"); + assert!(bright(&grown, normal) > bright(&hard, normal) + 100, "expansion grows the graded area"); qa.exec("layer.mask.set", json!({"layer": "Grade", "field": "feather", "value": 20})); let soft = qa.frame(None, 0.5); // At the ellipse's edge (x = 40 − 8): feathered is in between none and full. @@ -125,7 +136,10 @@ fn compositing_matte_blend_adjustment_3d_and_alpha_renders() { qa.exec("effect.apply", json!({"effect": "Glow", "layers": ["Grade"]})); let glow = qa.frame(None, 0.5); assert!(mean_diff(&soft, &glow) > 0.5, "Glow changes the frame"); +} +/// The 3D stage with depth of field and a light; returns the stage comp. +fn stage_3d(qa: &mut Qa) -> Value { // 3D stage: a near and a far card, a camera with depth of field, a light. let stage = qa.exec("comp.new", json!({"name": "Stage", "width": 160, "height": 90, "frameRate": 12, "duration": 1}))["comp"].clone(); qa.exec("layer.newSolid", json!({"name": "Near", "color": "#ff4040", "width": 40, "height": 40})); @@ -155,7 +169,10 @@ fn compositing_matte_blend_adjustment_3d_and_alpha_renders() { let advanced = qa.frame(None, 0.0); assert!(mean_diff(&classic, &advanced) > 0.2, "Advanced 3D renders the stage its own way"); assert!(lum(&advanced, 50, 45) > 60.0, "Advanced 3D still lights the card"); + stage +} +fn alpha_renders(qa: &mut Qa, stage: &Value) { // Renders with alpha: the stage has no background layer, so most of it is transparent. let mov = qa.path("stage.mov"); let webm = qa.path("stage.webm"); @@ -165,7 +182,7 @@ fn compositing_matte_blend_adjustment_3d_and_alpha_renders() { assert!(done["items"].as_array().unwrap().iter().all(|i| i["status"] == "Done"), "{done}"); let reference = qa.tool("render_frame", json!({"comp": stage.clone(), "time": 0, "transparent": true, "inline": false})); assert_eq!(reference["width"], 160); - let refa = alpha_frame(&mut qa, stage.clone()); + let refa = alpha_frame(qa, stage.clone()); assert!(px(&refa, 2, 2)[3] < 40 && px(&refa, 50, 45)[3] == 255, "render_frame transparent keeps alpha: {:?} {:?}", px(&refa, 2, 2), px(&refa, 50, 45)); // ProRes 4444 carries alpha in the frame; WebM VP9 as a second VP9 stream in BlockAdditions // (AlphaMode 1), which FilmCraft's Matroska reader decodes into the frame's alpha plane. @@ -174,14 +191,24 @@ fn compositing_matte_blend_adjustment_3d_and_alpha_renders() { assert_eq!(r["errors"], json!([])); assert_eq!(r["unlabeledAlpha"], r["items"], "{path}: the importer sees an alpha channel"); let c = qa.exec("file.newCompFromSelection", json!({}))["comps"][0].clone(); - let f = alpha_frame(&mut qa, c.clone()); + let f = alpha_frame(qa, c.clone()); // The opaque card decodes to the stage's colours (lossy codecs, so loosely). let (got, want) = (px(&f, 50, 45), px(&refa, 50, 45)); assert!((0..3).all(|c| got[c].abs_diff(want[c]) < 12), "{path} decodes to the stage: {got:?} vs {want:?}"); assert!(mean_diff(&qa.frame(Some(c.clone()), 0.0), &qa.frame(Some(stage.clone()), 0.0)) < 8.0, "{path} composites like the stage"); assert!(px(&f, 2, 2)[3] < 40 && px(&f, 50, 45)[3] > 250, "{path}: transparent corners, opaque card: {:?} {:?}", px(&f, 2, 2), px(&f, 50, 45)); } - let _ = main; +} + +#[test] +fn compositing_matte_blend_adjustment_3d_and_alpha_renders() { + let mut qa = Qa::new("compositing"); + let (seq_item, psd_comp) = import_footage(&mut qa); + let normal = main_comp_and_blend(&mut qa, &seq_item, &psd_comp); + track_matte(&mut qa); + adjustment_layer(&mut qa, &normal); + let stage = stage_3d(&mut qa); + alpha_renders(&mut qa, &stage); } fn alpha_frame(qa: &mut Qa, comp: Value) -> Img { diff --git a/crates/av1enc/src/cdf.rs b/crates/av1enc/src/cdf.rs index 0466a1592..777164d1a 100644 --- a/crates/av1enc/src/cdf.rs +++ b/crates/av1enc/src/cdf.rs @@ -2,7 +2,15 @@ //! init_coeff_cdfs that its syntax uses). Layout as in filmcraft-av1: each innermost array //! holds N cumulative values (the last is 32768) plus the adaptation counter. -use crate::tables::*; +use crate::tables::{ + DEFAULT_ANGLE_DELTA_CDF, DEFAULT_COEFF_BASE_CDF, DEFAULT_COEFF_BASE_EOB_CDF, DEFAULT_COEFF_BR_CDF, DEFAULT_DC_SIGN_CDF, DEFAULT_DRL_MODE_CDF, + DEFAULT_EOB_EXTRA_CDF, DEFAULT_EOB_PT_16_CDF, DEFAULT_EOB_PT_32_CDF, DEFAULT_EOB_PT_64_CDF, DEFAULT_EOB_PT_128_CDF, DEFAULT_EOB_PT_256_CDF, + DEFAULT_EOB_PT_512_CDF, DEFAULT_EOB_PT_1024_CDF, DEFAULT_INTER_TX_TYPE_SET3_CDF, DEFAULT_INTRA_TX_TYPE_SET2_CDF, DEFAULT_IS_INTER_CDF, DEFAULT_MV_BIT_CDF, + DEFAULT_MV_CLASS_CDF, DEFAULT_MV_CLASS0_BIT_CDF, DEFAULT_MV_CLASS0_FR_CDF, DEFAULT_MV_FR_CDF, DEFAULT_MV_JOINT_CDF, DEFAULT_MV_SIGN_CDF, + DEFAULT_NEW_MV_CDF, DEFAULT_PARTITION_W8_CDF, DEFAULT_PARTITION_W16_CDF, DEFAULT_PARTITION_W32_CDF, DEFAULT_PARTITION_W64_CDF, DEFAULT_REF_MV_CDF, + DEFAULT_SINGLE_REF_CDF, DEFAULT_SKIP_CDF, DEFAULT_TXB_SKIP_CDF, DEFAULT_UV_MODE_CFL_ALLOWED_CDF, DEFAULT_UV_MODE_CFL_NOT_ALLOWED_CDF, DEFAULT_Y_MODE_CDF, + DEFAULT_ZERO_MV_CDF, +}; #[derive(Clone)] pub(crate) struct Cdfs { diff --git a/crates/av1enc/src/decide.rs b/crates/av1enc/src/decide.rs index 494e914c0..db18c61c3 100644 --- a/crates/av1enc/src/decide.rs +++ b/crates/av1enc/src/decide.rs @@ -3,8 +3,12 @@ //! processes, so the reconstruction built here is exactly what a decoder produces; the syntax //! is written afterwards by `writer.rs`. -use crate::pred::*; -use crate::tables::*; +use crate::pred::{INTRA_MODES_USED, InterBlock, IntraEdge, Plane, edges, predict_inter, predict_intra, subpel_plane}; +use crate::tables::{ + AC_QLOOKUP, BLOCK_8X8, BLOCK_64X64, DC_PRED, DC_QLOOKUP, DCT_DCT, DEFAULT_SCAN_4X4, DEFAULT_SCAN_8X8, DEFAULT_SCAN_16X16, DEFAULT_SCAN_32X32, MODE_TO_TXFM, + NEWMV, NUM_4X4_BLOCKS_WIDE, PARTITION_SPLIT, PARTITION_SUBSIZE, TX_4X4, TX_8X8, TX_16X16, TX_32X32, TX_64X64, TX_SET_DCTONLY, TX_SET_INTER_3, + TX_SET_INTRA_2, TX_SIZE_SQR_UP, TX_TYPE_IN_SET_INTER, TX_TYPE_IN_SET_INTRA, +}; use crate::transform::{forward_2d, inverse_2d}; /// Decisions for one coded block. @@ -100,6 +104,17 @@ pub(crate) fn intra_uv_tx_type(uv_mode: usize, uv_tx: usize) -> usize { if tx_in_set(tx_set(uv_tx, false), false, t) { t } else { DCT_DCT } } +/// Geometry and coding mode of one square transform block. +#[derive(Clone, Copy)] +struct TransformBlock { + plane: usize, + x: usize, + y: usize, + log2: u32, + tx_type: usize, + intra: bool, +} + struct TxResult { levels: Vec, nonzero: bool, @@ -362,7 +377,7 @@ impl<'a> Decider<'a> { types.push(alt); } for &t in &types { - let (res, recon) = self.try_tx(0, c * 4, r * 4, log2, t, &pred[..n * n], true); + let (res, recon) = self.try_tx(TransformBlock { plane: 0, x: c * 4, y: r * 4, log2, tx_type: t, intra: true }, &pred[..n * n]); let type_bits = if !res.nonzero || tx_set(tx_y, false) == TX_SET_DCTONLY { 0.0 } else if t == DCT_DCT { @@ -422,7 +437,7 @@ impl<'a> Decider<'a> { let uv_type = intra_uv_tx_type(uv_mode, uv_tx); let mut code_uv = |i: usize| { predict_intra(&ced[i], &ce[i], uv_mode, &mut pred); - let t = self.code_tx(1 + i, c * 2, r * 2, clog2, uv_type, &pred[..cn * cn], true); + let t = self.code_tx(TransformBlock { plane: 1 + i, x: c * 2, y: r * 2, log2: clog2, tx_type: uv_type, intra: true }, &pred[..cn * cn]); self.mark_decoded(1 + i, r, c, n4); t }; @@ -450,13 +465,17 @@ impl<'a> Decider<'a> { let (vw, vh) = self.g.visible(p); let pn = n >> s; let (x, y) = ((c * 4) >> s, (r * 4) >> s); - predict_inter(&refr.planes[p], vw as i32 - 1, vh as i32 - 1, x, y, pn, pn, mv, s, self.bd, &mut pred[..pn * pn]); + predict_inter( + &refr.planes[p], + &InterBlock { last_x: vw as i32 - 1, last_y: vh as i32 - 1, x, y, width: pn, height: pn, mv, subsampling: s, bit_depth: self.bd }, + &mut pred[..pn * pn], + ); if bsize == BLOCK_64X64 { // coded as a skip block: the prediction is the reconstruction dist += self.sse(p, x, y, pn, &pred[..pn * pn]); self.put(p, x, y, pn, &pred[..pn * pn]); } else { - let t = self.code_tx(p, x, y, log2 - s as u32, DCT_DCT, &pred[..pn * pn], false); + let t = self.code_tx(TransformBlock { plane: p, x, y, log2: log2 - s as u32, tx_type: DCT_DCT, intra: false }, &pred[..pn * pn]); dist += t.dist; tx.push(t); } @@ -771,8 +790,9 @@ impl<'a> Decider<'a> { /// Transform, quantise and reconstruct one square transform block of `plane` at (x, y) /// over the prediction `pred`; the reconstruction is written to `rec`. - fn code_tx(&mut self, p: usize, x: usize, y: usize, log2: u32, tx_type: usize, pred: &[u16], intra: bool) -> TxResult { - let (res, recon) = self.try_tx(p, x, y, log2, tx_type, pred, intra); + fn code_tx(&mut self, block: TransformBlock, pred: &[u16]) -> TxResult { + let TransformBlock { plane: p, x, y, log2, .. } = block; + let (res, recon) = self.try_tx(block, pred); let n = 1usize << log2; self.put(p, x, y, n, recon.as_deref().unwrap_or(pred)); res @@ -780,7 +800,8 @@ impl<'a> Decider<'a> { /// Transform and quantise one block; returns the result and its reconstruction (None: the /// prediction) without writing it. - fn try_tx(&self, p: usize, x: usize, y: usize, log2: u32, tx_type: usize, pred: &[u16], intra: bool) -> (TxResult, Option>) { + fn try_tx(&self, block: TransformBlock, pred: &[u16]) -> (TxResult, Option>) { + let TransformBlock { plane: p, x, y, log2, tx_type, intra } = block; let n = 1usize << log2; let tx_sz = tx_size_for(log2); let bd = self.bd; diff --git a/crates/av1enc/src/loopfilter.rs b/crates/av1enc/src/loopfilter.rs index 2add41edc..cf350fbd2 100644 --- a/crates/av1enc/src/loopfilter.rs +++ b/crates/av1enc/src/loopfilter.rs @@ -3,7 +3,7 @@ //! is 0 in the frames this encoder writes. use crate::pred::Plane; -use crate::tables::*; +use crate::tables::{MAX_LOOP_FILTER, NUM_4X4_BLOCKS_HIGH, NUM_4X4_BLOCKS_WIDE, SUBSAMPLED_SIZE, TX_HEIGHT, TX_WIDTH}; /// Mode info the loop filter reads (per 4x4 luma unit, row stride `mi_cols`), and the /// transform size per 4x4 unit of each plane (`LoopfilterTxSizes`, row stride `mi_cols + 32`). @@ -39,7 +39,7 @@ pub(crate) fn loop_filter(planes: &mut [Plane; 3], info: &LfInfo, level: [u32; 4 } let mut col = 0; while col < info.mi_cols { - edge(pl, info, level, bit_depth, plane, pass, row, col); + edge(pl, info, level, bit_depth, plane, pass, (row, col)); col += step; } row += step; @@ -48,7 +48,8 @@ pub(crate) fn loop_filter(planes: &mut [Plane; 3], info: &LfInfo, level: [u32; 4 } } -fn edge(pl: &mut Plane, info: &LfInfo, level: [u32; 4], bd: u32, plane: usize, pass: usize, row: usize, col: usize) { +fn edge(pl: &mut Plane, info: &LfInfo, level: [u32; 4], bd: u32, plane: usize, pass: usize, position: (usize, usize)) { + let (row, col) = position; let s = (plane > 0) as usize; let (dx, dy) = if pass == 0 { (1usize, 0usize) } else { (0, 1) }; let x = col * 4; @@ -88,13 +89,24 @@ fn edge(pl: &mut Plane, info: &LfInfo, level: [u32; 4], bd: u32, plane: usize, p let limit = lvl.max(1); let blimit = 2 * (lvl + 2) + limit; let thresh = lvl >> 4; + let strength = FilterStrength { limit, blimit, thresh }; for k in 0..4 { - sample_filter(pl, xp + dy * k, yp + dx * k, plane, limit, blimit, thresh, dx as isize, dy as isize, filter_size, bd); + sample_filter(pl, (xp + dy * k, yp + dx * k), plane, &strength, (dx as isize, dy as isize), filter_size, bd); } } +/// Thresholds derived from the loop-filter level (spec 7.14.4). +struct FilterStrength { + limit: i32, + blimit: i32, + thresh: i32, +} + #[inline] -fn sample_filter(pl: &mut Plane, x: usize, y: usize, plane: usize, limit: i32, blimit: i32, thresh: i32, dx: isize, dy: isize, filter_size: usize, bd: u32) { +fn sample_filter(pl: &mut Plane, position: (usize, usize), plane: usize, strength: &FilterStrength, direction: (isize, isize), filter_size: usize, bd: u32) { + let (x, y) = position; + let (dx, dy) = direction; + let FilterStrength { limit, blimit, thresh } = *strength; let stride = pl.stride as isize; let step = dy * stride + dx; let base = y as isize * stride + x as isize; diff --git a/crates/av1enc/src/pred.rs b/crates/av1enc/src/pred.rs index d61a6fa94..7b354af1f 100644 --- a/crates/av1enc/src/pred.rs +++ b/crates/av1enc/src/pred.rs @@ -2,7 +2,10 @@ //! modes plus V_PRED / H_PRED without edge filtering, 7.11.3 block inter prediction without //! reference scaling) so that the encoder predicts exactly like a decoder. -use crate::tables::*; +use crate::tables::{ + DC_PRED, EIGHTTAP, H_PRED, PAETH_PRED, SM_WEIGHTS_TX_4X4, SM_WEIGHTS_TX_8X8, SM_WEIGHTS_TX_16X16, SM_WEIGHTS_TX_32X32, SMOOTH_H_PRED, SMOOTH_PRED, + SMOOTH_V_PRED, SUBPEL_FILTERS, V_PRED, +}; use crate::transform::round2; /// A plane buffer (padded to whole superblocks). @@ -184,19 +187,20 @@ pub(crate) fn predict_intra(ed: &Edges, e: &IntraEdge, mode: usize, pred: &mut [ /// final rounding is the identity) of the `w x h` block at plane position (x, y) displaced by /// `mv` (1/8 luma sample units, [row, col]). `last_x` / `last_y` are the largest sample /// coordinates of the reference plane; the result goes to `pred` (row stride `w`). -pub(crate) fn predict_inter( - refp: &Plane, - last_x: i32, - last_y: i32, - x: usize, - y: usize, - w: usize, - h: usize, - mv: [i32; 2], - ss: usize, - bit_depth: u32, - pred: &mut [u16], -) { +pub(crate) struct InterBlock { + pub last_x: i32, + pub last_y: i32, + pub x: usize, + pub y: usize, + pub width: usize, + pub height: usize, + pub mv: [i32; 2], + pub subsampling: usize, + pub bit_depth: u32, +} + +pub(crate) fn predict_inter(refp: &Plane, block: &InterBlock, pred: &mut [u16]) { + let InterBlock { last_x, last_y, x, y, width: w, height: h, mv, subsampling: ss, bit_depth } = *block; // positions in 1/16 sample units ((startX >> 6) & 15 is the filter phase) let px = ((x as i32) << 4) + ((2 * mv[1]) >> ss); let py = ((y as i32) << 4) + ((2 * mv[0]) >> ss); @@ -340,7 +344,11 @@ mod tests { for (bx, by) in [(0i32, 0i32), (8, 8), (24, 16), (-5, 20), (30, -3)] { let mut pred = vec![0u16; 64]; let mv = [by * 8 + fy as i32 / 2, bx * 8 + fx as i32 / 2]; - predict_inter(&p, w as i32 - 1, h as i32 - 1, 0, 0, 8, 8, mv, 0, 8, &mut pred); + predict_inter( + &p, + &InterBlock { last_x: w as i32 - 1, last_y: h as i32 - 1, x: 0, y: 0, width: 8, height: 8, mv, subsampling: 0, bit_depth: 8 }, + &mut pred, + ); for r in 0..8 { for c in 0..8 { let (x, y) = ((bx + c).clamp(0, w as i32 - 1), (by + r).clamp(0, h as i32 - 1)); diff --git a/crates/av1enc/src/transform.rs b/crates/av1enc/src/transform.rs index 90042e26a..048bfc0db 100644 --- a/crates/av1enc/src/transform.rs +++ b/crates/av1enc/src/transform.rs @@ -4,7 +4,7 @@ use std::sync::OnceLock; -use crate::tables::*; +use crate::tables::{ADST_ADST, ADST_DCT, COS128_LOOKUP, DCT_ADST, DCT_DCT, TRANSFORM_ROW_SHIFT, TX_WIDTH_LOG2}; #[inline(always)] fn round2_64(x: i64, n: u32) -> i32 { @@ -71,6 +71,12 @@ fn inverse_dct(t: &mut [i32], n: u32, r: u32) { for i in 0..len { t[i] = copy[brev(n, i)]; } + inverse_dct_initial_stages(t, n, r); + inverse_dct_final_stages(t, n, r); +} + +/// Rotate the high-frequency branches before the four-point core. +fn inverse_dct_initial_stages(t: &mut [i32], n: u32, r: u32) { if n >= 5 { for i in 0..8 { bf(t, 16 + i, 31 - i, 6 + ((brev(3, 7 - i) as i32) << 3), false); @@ -103,6 +109,10 @@ fn inverse_dct(t: &mut [i32], n: u32, r: u32) { } } } +} + +/// Evaluate the four-point core and merge progressively larger branches. +fn inverse_dct_final_stages(t: &mut [i32], n: u32, r: u32) { for i in 0..2 { bf(t, 2 * i, 2 * i + 1, 32 + 16 * i as i32, i == 0); } @@ -454,6 +464,7 @@ pub(crate) fn forward_2d(residual: &[i32], out: &mut [f32], n: usize, tx_type: u #[cfg(test)] mod tests { use super::*; + use crate::tables::{TX_4X4, TX_8X8, TX_16X16, TX_32X32}; /// forward (float) then the normative inverse reproduces the residual: the forward basis /// and scale match the decoder's inverse transforms for every supported size and type. diff --git a/crates/av1enc/src/writer.rs b/crates/av1enc/src/writer.rs index 54c545fa4..ed9bca1c4 100644 --- a/crates/av1enc/src/writer.rs +++ b/crates/av1enc/src/writer.rs @@ -8,7 +8,15 @@ use crate::cdf::Cdfs; use crate::decide::{FrameGeom, Leaf, Node, intra_uv_tx_type, scan_for, tx_in_set, tx_set, tx_size_for}; use crate::loopfilter::LfInfo; use crate::symbol::SymbolWriter; -use crate::tables::*; +use crate::tables::{ + ADST_ADST, ADST_DCT, ALTREF_FRAME, ALTREF2_FRAME, BLOCK_8X8, BLOCK_64X64, BR_CDF_SIZE, BWDREF_FRAME, CLASS0_SIZE, COEFF_BASE_CTX_OFFSET, COEFF_BASE_RANGE, + D67_PRED, DC_PRED, DCT_ADST, DCT_DCT, DEFAULT_INTRA_FRAME_Y_MODE_CDF, GLOBALMV, GOLDEN_FRAME, INTRA_FRAME, INTRA_MODE_CONTEXT, LAST_FRAME, LAST2_FRAME, + LAST3_FRAME, MAG_REF_OFFSET_WITH_TX_CLASS, MAX_ANGLE_DELTA, MAX_REF_MV_STACK_SIZE, MI_HEIGHT_LOG2, MI_WIDTH_LOG2, MV_BORDER, MV_CLASS_0, NEARESTMV, NEARMV, + NEWMV, NUM_4X4_BLOCKS_HIGH, NUM_4X4_BLOCKS_WIDE, NUM_BASE_LEVELS, PARTITION_HORZ, PARTITION_HORZ_4, PARTITION_HORZ_A, PARTITION_HORZ_B, PARTITION_NONE, + PARTITION_SPLIT, PARTITION_SUBSIZE, PARTITION_VERT, PARTITION_VERT_4, PARTITION_VERT_A, PARTITION_VERT_B, REF_CAT_LEVEL, SIG_REF_DIFF_OFFSET, SIZE_GROUP, + SUBSAMPLED_SIZE, TX_32X32, TX_CLASS_2D, TX_HEIGHT, TX_HEIGHT_LOG2, TX_SIZE_SQR, TX_SIZE_SQR_UP, TX_TYPE_INTER_INV_SET3, TX_TYPE_INTRA_INV_SET2, TX_WIDTH, + TX_WIDTH_LOG2, V_PRED, +}; type MvP = [i32; 2]; @@ -455,23 +463,7 @@ impl<'a> TileWriter<'a> { } } else { if plane == 0 { - // transform_type( ) - let set = tx_set(tx_sz, b.is_inter); - let t = b.tx_type_y; - if set > 0 && self.qidx > 0 { - let sqr = TX_SIZE_SQR[tx_sz] as usize; - if b.is_inter { - // The decision only picks types of this set (DCT_DCT otherwise). - let s = TX_TYPE_INTER_INV_SET3.iter().position(|&v| v as usize == t).unwrap_or_default(); - self.sw.symbol(s, &mut self.cdf.inter_tx_type_set3[sqr]); - } else { - let s = TX_TYPE_INTRA_INV_SET2.iter().position(|&v| v as usize == t).unwrap_or_default(); - self.sw.symbol(s, &mut self.cdf.intra_tx_type_set2[sqr][b.y_mode]); - } - } else { - assert_eq!(t, DCT_DCT); - } - self.set_tx_types(x4, y4, tx_sz, t); + self.write_transform_type(x4, y4, tx_sz, b); } let plane_tx_type = self.compute_tx_type(plane, tx_sz, x4, y4, b); debug_assert!(plane_tx_type == DCT_DCT || matches!(plane_tx_type, ADST_DCT | DCT_ADST | ADST_ADST)); @@ -571,6 +563,26 @@ impl<'a> TileWriter<'a> { } } + fn write_transform_type(&mut self, x4: usize, y4: usize, tx_sz: usize, b: &Leaf) { + // transform_type( ) + let set = tx_set(tx_sz, b.is_inter); + let t = b.tx_type_y; + if set > 0 && self.qidx > 0 { + let sqr = TX_SIZE_SQR[tx_sz] as usize; + if b.is_inter { + // The decision only picks types of this set (DCT_DCT otherwise). + let s = TX_TYPE_INTER_INV_SET3.iter().position(|&v| v as usize == t).unwrap_or_default(); + self.sw.symbol(s, &mut self.cdf.inter_tx_type_set3[sqr]); + } else { + let s = TX_TYPE_INTRA_INV_SET2.iter().position(|&v| v as usize == t).unwrap_or_default(); + self.sw.symbol(s, &mut self.cdf.intra_tx_type_set2[sqr][b.y_mode]); + } + } else { + assert_eq!(t, DCT_DCT); + } + self.set_tx_types(x4, y4, tx_sz, t); + } + fn set_tx_types(&mut self, x4: usize, y4: usize, tx_sz: usize, t: usize) { for i in 0..(TX_WIDTH[tx_sz] as usize >> 2) { for j in 0..(TX_HEIGHT[tx_sz] as usize >> 2) { diff --git a/crates/effects/src/generate2.rs b/crates/effects/src/generate2.rs index 62bce1706..b4781b793 100644 --- a/crates/effects/src/generate2.rs +++ b/crates/effects/src/generate2.rs @@ -692,18 +692,33 @@ impl BoltCfg<'_> { } } -/// Grow a bolt from `a` to `z` by midpoint displacement (`depth` levels), forking at random. -/// Segments go to `out` with their intensity; `mains` marks the main core's segments. -#[allow(clippy::too_many_arguments)] -fn bolt(cfg: &BoltCfg, a: (f64, f64), z: (f64, f64), depth: u32, inten: f32, main: bool, rng: &mut Rng, out: &mut Vec, mains: &mut Vec) { - if out.len() > 20_000 { +/// One branch's geometry and recursive growth state. +struct BoltBranch { + start: (f64, f64), + end: (f64, f64), + depth: u32, + intensity: f32, + main: bool, +} + +/// Segments and their matching main-core flags, kept in the same traversal order. +#[derive(Default)] +struct BoltOutput { + segments: Vec, + mains: Vec, +} + +/// Grow a branch by midpoint displacement, recursively appending its segments and forks. +fn bolt(cfg: &BoltCfg, branch: BoltBranch, rng: &mut Rng, output: &mut BoltOutput) { + let BoltBranch { start: a, end: z, depth, intensity: inten, main } = branch; + if output.segments.len() > 20_000 { return; } let (dx, dy) = (z.0 - a.0, z.1 - a.1); let len = (dx * dx + dy * dy).sqrt(); if depth == 0 || len < 1.0 { - out.push((a, z, inten)); - mains.push(main); + output.segments.push((a, z, inten)); + output.mains.push(main); return; } let (nx, ny) = (-dy / len.max(1e-9), dx / len.max(1e-9)); @@ -725,8 +740,8 @@ fn bolt(cfg: &BoltCfg, a: (f64, f64), z: (f64, f64), depth: u32, inten: f32, mai if cfg.obstacle > 0.0 && !main && cfg.alpha_at(m) > 0.5 && rng.f() < cfg.obstacle / 10.0 { return; } - bolt(cfg, a, m, depth - 1, inten, main, rng, out, mains); - bolt(cfg, m, z, depth - 1, inten, main, rng, out, mains); + bolt(cfg, BoltBranch { start: a, end: m, depth: depth - 1, intensity: inten, main }, rng, output); + bolt(cfg, BoltBranch { start: m, end: z, depth: depth - 1, intensity: inten, main }, rng, output); if rng.f() < cfg.fork && inten > cfg.term && len >= cfg.min_fork { let ang = rng.s() * 0.7; let (s, c) = ang.sin_cos(); @@ -734,7 +749,7 @@ fn bolt(cfg: &BoltCfg, a: (f64, f64), z: (f64, f64), depth: u32, inten: f32, mai let k = 1.2; // Fork Strength 50 % keeps the decayed intensity; Fork Variation randomises it. let fi = inten * (1.0 - cfg.decay) * cfg.fork_strength * 2.0 * if cfg.fork_var > 0.0 { 1.0 - cfg.fork_var * rng.f() as f32 } else { 1.0 }; - bolt(cfg, m, (m.0 + fx * k, m.1 + fy * k), depth.saturating_sub(1), fi.min(1.0), false, rng, out, mains); + bolt(cfg, BoltBranch { start: m, end: (m.0 + fx * k, m.1 + fy * k), depth: depth.saturating_sub(1), intensity: fi.min(1.0), main: false }, rng, output); } } @@ -772,10 +787,9 @@ pub fn lightning_segments(ctx: &EffectCtx, b: &Buf) -> Vec { fork_strength: (g("expertSettings/forkStrength", 50.0) / 100.0).clamp(0.0, 1.0) as f32, fork_var: (g("expertSettings/forkVariation", 0.0) / 100.0).clamp(0.0, 1.0) as f32, }; - let mut segs = Vec::new(); - let mut mains = Vec::new(); + let mut output = BoltOutput::default(); let (h, w) = (b.img.height as f64, b.img.width as f64); - let mut go = |a, z, depth, cfg: &BoltCfg, rng: &mut Rng| bolt(cfg, a, z, depth, 1.0, true, rng, &mut segs, &mut mains); + let mut go = |a, z, depth, cfg: &BoltCfg, rng: &mut Rng| bolt(cfg, BoltBranch { start: a, end: z, depth, intensity: 1.0, main: true }, rng, &mut output); // LIGHTNING_TYPES order. match kind { // Direction: travels toward the Direction point and beyond. @@ -811,6 +825,7 @@ pub fn lightning_segments(ctx: &EffectCtx, b: &Buf) -> Vec { // Strike. _ => go(o, d, depth, &cfg, &mut rng), } + let BoltOutput { segments: mut segs, mains } = output; let dist = |p: (f64, f64)| ((p.0 - o.0).powi(2) + (p.1 - o.1).powi(2)).sqrt(); let far = segs.iter().map(|(_, z, _)| dist(*z)).fold(1e-9, f64::max); // Decay Main Core: the whole bolt fades with distance from the origin. diff --git a/crates/effects/src/generate3/audio.rs b/crates/effects/src/generate3/audio.rs index f1ac54db8..6c8d66e4a 100644 --- a/crates/effects/src/generate3/audio.rs +++ b/crates/effects/src/generate3/audio.rs @@ -1,5 +1,5 @@ use super::raster::{Seg, mask_px, over, poly_segs, raster_segs}; -use super::*; +use super::{Buf, EffectCtx, IndexedParallelIterator, IntoParallelRefMutIterator, PI, ParallelIterator, poly_length, premul}; // ---------------------------------------------------------------- Audio Spectrum / Waveform diff --git a/crates/effects/src/generate3/common.rs b/crates/effects/src/generate3/common.rs index b996cdc99..7e341ad8d 100644 --- a/crates/effects/src/generate3/common.rs +++ b/crates/effects/src/generate3/common.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{EffectSpec, ParamUi, Value, slider}; pub(super) fn spec(id: &'static str, name: &'static str, params: Vec, render: crate::RenderFn) -> EffectSpec { EffectSpec { id, name, category: "Generate", params, render, gpu: false, float: true } diff --git a/crates/effects/src/generate3/fill.rs b/crates/effects/src/generate3/fill.rs index a86f123ee..5df782a89 100644 --- a/crates/effects/src/generate3/fill.rs +++ b/crates/effects/src/generate3/fill.rs @@ -1,5 +1,8 @@ use super::raster::over; -use super::*; +use super::{ + Buf, EffectCtx, IndexedParallelIterator, IntoParallelRefIterator, IntoParallelRefMutIterator, PI, ParallelIterator, Plane, Px, gauss_plane, hash1, + hsl_to_rgb, morph_frac, premul, rgb_to_hsl, smoothstep, unpremul, +}; pub(super) fn glue_gun(ctx: &EffectCtx, mut b: Buf) -> Buf { let pr = ctx.params; diff --git a/crates/effects/src/generate3/fractal.rs b/crates/effects/src/generate3/fractal.rs index 519bec640..05a76d5f0 100644 --- a/crates/effects/src/generate3/fractal.rs +++ b/crates/effects/src/generate3/fractal.rs @@ -1,5 +1,5 @@ use super::raster::over; -use super::*; +use super::{Buf, EffectCtx, IndexedParallelIterator, ParallelIterator, ParallelSliceMut, Px, hsl_to_rgb}; // ---------------------------------------------------------------- Fractal diff --git a/crates/effects/src/generate3/paths.rs b/crates/effects/src/generate3/paths.rs index 779892cc7..319368a82 100644 --- a/crates/effects/src/generate3/paths.rs +++ b/crates/effects/src/generate3/paths.rs @@ -1,5 +1,8 @@ use super::raster::{Seg, all_masks_px, chaikin, mask_px, over, poly_segs, raster_convex, raster_segs, scan_runs, stroke_outline, trim_poly}; -use super::*; +use super::{ + Buf, EffectCtx, Image, IndexedParallelIterator, IntoParallelRefIterator, IntoParallelRefMutIterator, ParallelIterator, Params, Plane, gauss_plane, hash1, + layer_or_self, luminance, poly_length, smoothstep, unpremul, +}; /// How painted coverage meets the layer: 0 On Original Image, 1 On Transparent, 2 Reveal /// Original Image. diff --git a/crates/effects/src/generate3/raster.rs b/crates/effects/src/generate3/raster.rs index f9acd88a3..1b85818d0 100644 --- a/crates/effects/src/generate3/raster.rs +++ b/crates/effects/src/generate3/raster.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Buf, EffectCtx, IndexedParallelIterator, ParallelIterator, ParallelSliceMut, Plane, Px, smoothstep}; // ---------------------------------------------------------------- shared rasterisation @@ -147,7 +147,6 @@ pub(super) fn chaikin(pts: &[[f64; 2]], iters: usize) -> Vec<[f64; 2]> { // ---------------------------------------------------------------- Scribble /// Scan runs (start, end along direction `d`) of the fill region on the line `n·p = s`. -#[allow(clippy::too_many_arguments)] pub(super) fn scan_runs(polys: &[(Vec<[f64; 2]>, bool, bool)], fill: u32, ew: f64, d: [f64; 2], n: [f64; 2], s: f64, out: &mut Vec<(f64, f64)>) { out.clear(); for (pts, _closed, inverted) in polys { diff --git a/crates/effects/src/generate3/registry.rs b/crates/effects/src/generate3/registry.rs index f7f6b040f..06c2b35fb 100644 --- a/crates/effects/src/generate3/registry.rs +++ b/crates/effects/src/generate3/registry.rs @@ -4,7 +4,7 @@ use super::fill::{eyedropper_fill, glue_gun, paint_bucket, threads}; use super::fractal::{FRACTAL_OVERSAMPLE, FRACTAL_PALETTES, FRACTAL_SETS, fractal}; use super::paths::{scribble, stroke, vegas, write_on}; use super::raster::{END_CAPS, JOINS}; -use super::*; +use super::{EffectSpec, ParamUi, Value, col, num, p, popup, slider}; // ---------------------------------------------------------------- registry diff --git a/crates/effects/src/object_matte.rs b/crates/effects/src/object_matte.rs index d98ec73ad..e61357c9f 100644 --- a/crates/effects/src/object_matte.rs +++ b/crates/effects/src/object_matte.rs @@ -358,20 +358,24 @@ pub enum FrameMatte { Outside, } -/// The matte of layer frame `f` on the layer grid of `img` (the effect's input on that frame at -/// `scale`), computing what is missing. `frame(g)`: the input on frame g on the same grid. -#[allow(clippy::too_many_arguments)] +/// The layer grid and timing used to compute one object matte. +pub struct MatteFrame<'a> { + pub layer_size: [f64; 2], + pub fps: f64, + pub frame: i64, + pub scale: f64, + pub img: &'a Image, +} + +/// Compute the object's alpha for one layer frame, loading other frames on the same grid as needed. pub fn frame_matte( params: &Params, - layer_size: [f64; 2], - fps: f64, - f: i64, - scale: f64, - img: &Image, + input: &MatteFrame<'_>, model: Option>, frame: &mut dyn FnMut(i64) -> Option>, cancel: &dyn Fn() -> bool, ) -> FrameMatte { + let MatteFrame { layer_size, fps, frame: f, scale, img } = *input; let Some(chain) = chain(params, layer_size, fps, f, scale, model) else { return FrameMatte::NoPrompt }; if !chain.data.in_span(f) { return FrameMatte::Outside; @@ -408,7 +412,7 @@ fn render(ctx: &EffectCtx, buf: Buf) -> Buf { if let Some(a) = frozen_alpha(ctx.params, layer_size, fps, f, w, h, model.as_deref()) { return compose(&buf, Some(&a), w, h, b(ctx.params, "invert"), param(ctx.params, "view").map(Value::as_enum).unwrap_or(0) == 1); } - let alpha = match frame_matte(ctx.params, layer_size, fps, f, sc, &img, model, &mut frame, &|| false) { + let alpha = match frame_matte(ctx.params, &MatteFrame { layer_size, fps, frame: f, scale: sc, img: &img }, model, &mut frame, &|| false) { FrameMatte::Alpha(a) if a.len() == w * h => Some(a), FrameMatte::NoPrompt => return buf, _ => None, @@ -608,7 +612,11 @@ mod tests { assert_ne!(seed(&p, [100.0, 80.0], 25.0, Some(stub.as_ref())), seed(&p, [100.0, 80.0], 25.0, None)); let img = frame_at(0); let mut frame = |g: i64| Some(Arc::new(frame_at(g))); - let FrameMatte::Alpha(a) = frame_matte(&p, [100.0, 80.0], 25.0, 0, 1.0, &img, Some(stub.clone()), &mut frame, &|| false) else { panic!("no matte") }; + let FrameMatte::Alpha(a) = + frame_matte(&p, &MatteFrame { layer_size: [100.0, 80.0], fps: 25.0, frame: 0, scale: 1.0, img: &img }, Some(stub.clone()), &mut frame, &|| false) + else { + panic!("no matte") + }; assert!(a[40 * 100 + 40] > 0.5 && a[5 * 100 + 5] < 0.5); assert_eq!(stub.calls.load(std::sync::atomic::Ordering::Relaxed), 1); assert_eq!(*stub.bbox.lock().unwrap(), None); @@ -634,7 +642,11 @@ mod tests { let stub = Arc::new(Stub::default()); let img = frame_at(0); let mut frame = |g: i64| Some(Arc::new(frame_at(g))); - let FrameMatte::Alpha(a) = frame_matte(&p, [100.0, 80.0], 25.0, 0, 1.0, &img, Some(stub.clone()), &mut frame, &|| false) else { panic!("no matte") }; + let FrameMatte::Alpha(a) = + frame_matte(&p, &MatteFrame { layer_size: [100.0, 80.0], fps: 25.0, frame: 0, scale: 1.0, img: &img }, Some(stub.clone()), &mut frame, &|| false) + else { + panic!("no matte") + }; assert_eq!(*stub.bbox.lock().unwrap(), Some([74.0, 54.0, 100.0, 80.0])); assert!(a[70 * 100 + 90] > 0.5 && a[40 * 100 + 40] < 0.5); // At half resolution the box is scaled with the frame (another Input Key, so the @@ -643,7 +655,8 @@ mod tests { p.values.insert(INPUT_KEY.into(), Value::Str("half".into())); let half = effectcraft_raster::resample(&img, 50, 40); let mut frame = |g: i64| Some(Arc::new(effectcraft_raster::resample(&frame_at(g), 50, 40))); - let _ = frame_matte(&p, [100.0, 80.0], 25.0, 0, 0.5, &half, Some(stub.clone()), &mut frame, &|| false); + let _ = + frame_matte(&p, &MatteFrame { layer_size: [100.0, 80.0], fps: 25.0, frame: 0, scale: 0.5, img: &half }, Some(stub.clone()), &mut frame, &|| false); assert_eq!(*stub.bbox.lock().unwrap(), Some([37.0, 27.0, 50.0, 40.0])); } diff --git a/crates/effects/src/ocio/cdl.rs b/crates/effects/src/ocio/cdl.rs index 1feab04a2..dba8c68fe 100644 --- a/crates/effects/src/ocio/cdl.rs +++ b/crates/effects/src/ocio/cdl.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{ParamUi, slider}; // ---------------------------------------------------------------- ASC CDL diff --git a/crates/effects/src/ocio/effects.rs b/crates/effects/src/ocio/effects.rs index 7d07011a4..5df24096d 100644 --- a/crates/effects/src/ocio/effects.rs +++ b/crates/effects/src/ocio/effects.rs @@ -1,7 +1,7 @@ use super::cdl::Cdl; use super::lut::load_file; use super::space::{ACES_CCT, ACES_CG, ACES2065_1, D65, IDENTITY, P3, REC709, REC2020, SPACES, Space, Tf, Xform, matrix, sp, space_names}; -use super::*; +use super::{Buf, ColorSpace, EffectCtx, EffectSpec, IntoParallelRefMutIterator, ParallelIterator, Value, invert, mul, mul_vec, p, popup, unpremul}; // ---------------------------------------------------------------- displays, views and looks diff --git a/crates/effects/src/ocio/lut.rs b/crates/effects/src/ocio/lut.rs index ff4cbd935..8a6481eb6 100644 --- a/crates/effects/src/ocio/lut.rs +++ b/crates/effects/src/ocio/lut.rs @@ -1,5 +1,5 @@ use super::cdl::{Cdl, parse_cdl_xml}; -use super::*; +use super::{Arc, HashMap, Lut, Mutex, OnceLock, parse_cube}; // ---------------------------------------------------------------- LUT files diff --git a/crates/effects/src/ocio/program.rs b/crates/effects/src/ocio/program.rs index ab9c4ea78..a223dc7b4 100644 --- a/crates/effects/src/ocio/program.rs +++ b/crates/effects/src/ocio/program.rs @@ -2,7 +2,7 @@ use super::cdl::Cdl; use super::effects::{DISPLAYS, cdl_from_params, custom_xf, looks, parse_looks, profile, space_at}; use super::lut::{FileXform, load_file}; use super::space::{ACES_CCT, D65, IDENTITY, Space, Tf, Xform, matrix}; -use super::*; +use super::{Arc, EffectCtx, Lut, invert, mul}; // ---- Colour programs (the GPU compositor's colour-management kernels) diff --git a/crates/effects/src/ocio/registry.rs b/crates/effects/src/ocio/registry.rs index b020b053b..cd3bd02a7 100644 --- a/crates/effects/src/ocio/registry.rs +++ b/crates/effects/src/ocio/registry.rs @@ -4,7 +4,7 @@ use super::effects::{ space_param, spec, }; use super::space::SPACES; -use super::*; +use super::{EffectSpec, ParamUi, Value, num, p, popup, slider}; pub fn specs() -> Vec { let cc = "Color Correction"; diff --git a/crates/effects/src/ocio/space.rs b/crates/effects/src/ocio/space.rs index 44fcf84d0..1a1d8f69b 100644 --- a/crates/effects/src/ocio/space.rs +++ b/crates/effects/src/ocio/space.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{invert, mul, mul_vec, rgb_to_xyz}; pub(super) type M3 = [[f64; 3]; 3]; diff --git a/crates/effects/src/roto.rs b/crates/effects/src/roto.rs index 4917cc231..2467a719d 100644 --- a/crates/effects/src/roto.rs +++ b/crates/effects/src/roto.rs @@ -634,7 +634,7 @@ fn render(ctx: &EffectCtx, buf: Buf) -> Buf { let (w, h) = (img.width as usize, img.height as usize); let mp = matte_params(ctx.params); let mut rgb = rb::rgb_of(&img); - let map = refine::decontaminate(&mut rgb, &alpha, &band, w, h, &mp, rr * sc, sc); + let map = refine::decontaminate(&mut rgb, &alpha, &band, (w, h), &mp, rr * sc, sc); let view_map = b(ctx.params, "refineEdgeMatte/decontamination/viewDecontaminationMap"); let (ox, oy) = (buf.offset[0].round() as i64, buf.offset[1].round() as i64); for y in 0..h { diff --git a/crates/effects/src/sim/common.rs b/crates/effects/src/sim/common.rs index c11c3f886..294d787f1 100644 --- a/crates/effects/src/sim/common.rs +++ b/crates/effects/src/sim/common.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{EffectSpec, ParamUi, Value, hash1, slider}; pub(crate) fn spec(id: &'static str, name: &'static str, params: Vec, render: crate::RenderFn) -> EffectSpec { EffectSpec { id, name, category: "Simulation", params, render, gpu: false, float: true } diff --git a/crates/effects/src/sim/engine.rs b/crates/effects/src/sim/engine.rs index 973b7b5e8..b379636ce 100644 --- a/crates/effects/src/sim/engine.rs +++ b/crates/effects/src/sim/engine.rs @@ -1,7 +1,7 @@ use super::common::{h, hs}; use super::particle_fx::{PS2_CACHE, PW_CACHE, ps2_phys, pw_phys}; use super::sprites::{Shape, Sprite}; -use super::*; +use super::{Buf, EffectCtx, Image, IntoParallelRefMutIterator, ParallelIterator, SimCache, params_key_except, value_noise}; // ------------------------------------------------------------------ particle engine @@ -263,7 +263,8 @@ pub(super) fn particle_look(q: &Particle, birth: [f32; 4], death: [f32; 4], bsiz (c, size.max(0.0)) } -pub(super) fn type_sprite(kind: u32, x: f32, y: f32, r: f32, c: [f32; 4], vx: f32, vy: f32, rot: f32) -> Sprite { +pub(super) fn type_sprite(kind: u32, x: f32, y: f32, r: f32, c: [f32; 4], velocity: [f32; 2], rot: f32) -> Sprite { + let [vx, vy] = velocity; let vl = (vx * vx + vy * vy).sqrt().max(1e-6); let shape = match kind { 0 => Shape::Line { dx: -vx / vl * r * 2.0, dy: -vy / vl * r * 2.0 }, diff --git a/crates/effects/src/sim/particle_fx.rs b/crates/effects/src/sim/particle_fx.rs index f8af6cba0..6b6c27b01 100644 --- a/crates/effects/src/sim/particle_fx.rs +++ b/crates/effects/src/sim/particle_fx.rs @@ -1,6 +1,6 @@ use super::engine::{PState, PW_EFFECT_CAMERA, Phys, accelerated, norm3, particle_look, sim_key, simulate, type_sprite}; use super::sprites::{Acc, Post, Sprite, SpritePlan, raster, splat}; -use super::*; +use super::{Buf, EffectCtx, Image, IntoParallelRefIterator, ParallelIterator, SimCache, Value, params_key, unpremul}; // ------------------------------------------------------------------ CC Particle World @@ -169,7 +169,7 @@ pub(super) fn particle_world_plan(ctx: &EffectCtx, b: &Buf) -> Option SpritePlan { let (bx, by) = b.to_px([(q.p[0] * unit) as f64, (q.p[1] * unit) as f64]); let (c, size) = particle_look(q, birth, death, bs, ds, svar, max_op); let r = size * unit * 0.02 * s; - type_sprite(kind, bx as f32, by as f32, r, c, q.v[0] * unit * s, q.v[1] * unit * s, (q.id % 628) as f32 * 0.01 + q.age * 2.0) + type_sprite(kind, bx as f32, by as f32, r, c, [q.v[0] * unit * s, q.v[1] * unit * s], (q.id % 628) as f32 * 0.01 + q.age * 2.0) }) .collect(); particle_sprites(sprites, pr.e("particle/transferMode")) diff --git a/crates/effects/src/sim/registry.rs b/crates/effects/src/sim/registry.rs index a688f1046..f29eb2653 100644 --- a/crates/effects/src/sim/registry.rs +++ b/crates/effects/src/sim/registry.rs @@ -3,7 +3,7 @@ use super::engine::{PARTICLE_TYPES, PW_ANIMS}; use super::particle_fx::{PW_FOV, mr_mercury, particle_systems2, particle_systems2_plan, particle_world, particle_world_plan}; use super::sprites::SpritePlan; use super::weather::{bubbles, drizzle, rainfall, rainfall_plan, snowfall, snowfall_plan, star_burst, star_burst_plan}; -use super::*; +use super::{Buf, EffectCtx, EffectSpec, ParamUi, Value, col, num, p, popup, slider}; /// The sprite plan of a sprite-drawing simulation effect for buffer geometry `b` (its pixels /// are not read), `None` for other effects or settings drawn otherwise (the GPU compositor's diff --git a/crates/effects/src/sim/sprites.rs b/crates/effects/src/sim/sprites.rs index 982579a5c..78c405abd 100644 --- a/crates/effects/src/sim/sprites.rs +++ b/crates/effects/src/sim/sprites.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Buf, Image, IndexedParallelIterator, IntoParallelRefIterator, IntoParallelRefMutIterator, ParallelIterator, ParallelSliceMut, Px, unpremul}; // ------------------------------------------------------------------ band rasteriser diff --git a/crates/effects/src/sim/weather.rs b/crates/effects/src/sim/weather.rs index cac0b023c..b93c0e48b 100644 --- a/crates/effects/src/sim/weather.rs +++ b/crates/effects/src/sim/weather.rs @@ -1,6 +1,6 @@ use super::common::{h, hs}; use super::sprites::{Acc, Post, Shape, Sprite, SpritePlan, Tint, aa, raster}; -use super::*; +use super::{Buf, EffectCtx, Image, IntoParallelIterator, ParallelIterator, unpremul}; // ------------------------------------------------------------------ CC Rainfall / CC Snowfall diff --git a/crates/effects/src/sim2/foam.rs b/crates/effects/src/sim2/foam.rs index cf9009492..fc187d191 100644 --- a/crates/effects/src/sim2/foam.rs +++ b/crates/effects/src/sim2/foam.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + Acc, Buf, EffectCtx, Image, IndexedParallelIterator, IntoParallelRefIterator, IntoParallelRefMutIterator, ParallelIterator, Plane, Px, Shape, SimCache, + Sprite, Value, hs, params_key, splat, unpremul, value_noise, +}; // ------------------------------------------------------------------ Foam diff --git a/crates/effects/src/sim2/pieces.rs b/crates/effects/src/sim2/pieces.rs index 3aa3a68f6..bff5943c0 100644 --- a/crates/effects/src/sim2/pieces.rs +++ b/crates/effects/src/sim2/pieces.rs @@ -1,5 +1,5 @@ use super::shatter::{ShatterPlan, card_dance_plan, shatter_impl}; -use super::*; +use super::{Acc, Buf, EffectCtx, Image, Lighting, Proj, raster, unpremul}; // ------------------------------------------------------------------ 3D pieces diff --git a/crates/effects/src/sim2/pixels.rs b/crates/effects/src/sim2/pixels.rs index 2079f03c3..2cda428db 100644 --- a/crates/effects/src/sim2/pixels.rs +++ b/crates/effects/src/sim2/pixels.rs @@ -1,6 +1,6 @@ use super::pieces::{Cam, Piece, PiecePlan, PieceTex, rot_axis, sort_far_first}; use super::shatter::{ShatterPlan, shatter_impl}; -use super::*; +use super::{Acc, Buf, EffectCtx, IntoParallelIterator, ParallelIterator, Post, Shape, Sprite, SpritePlan, h, hs, unpremul}; // ------------------------------------------------------------------ CC Ball Action diff --git a/crates/effects/src/sim2/registry.rs b/crates/effects/src/sim2/registry.rs index 93508e7b5..333916a16 100644 --- a/crates/effects/src/sim2/registry.rs +++ b/crates/effects/src/sim2/registry.rs @@ -2,7 +2,7 @@ use super::foam::{FOAM_TEXTURES, foam}; use super::pixels::{ball_action, pixel_polly, scatterize}; use super::shatter::{CD_PROPS, CD_SOURCES, SHATTER_PATTERNS, SHATTER_TEXTURE_MODES, card_dance, shatter}; use super::surfaces::{caustics, wave_world}; -use super::*; +use super::{EffectSpec, ParamUi, Value, col, num, p, pct, popup, pt, slider, spec}; // ------------------------------------------------------------------ specs diff --git a/crates/effects/src/sim2/shatter.rs b/crates/effects/src/sim2/shatter.rs index 90b5163f1..844fbb4b3 100644 --- a/crates/effects/src/sim2/shatter.rs +++ b/crates/effects/src/sim2/shatter.rs @@ -1,5 +1,8 @@ use super::pieces::{M3, Piece, PiecePlan, PieceTex, inv3, rot_axis, rot_xyz, sort_far_first}; -use super::*; +use super::{ + Acc, Buf, EffectCtx, Image, IndexedParallelIterator, IntoParallelIterator, IntoParallelRefIterator, Lighting, ParallelIterator, Post, Shape, Sprite, + SpritePlan, Value, h, hs, layer_or_self, unpremul, +}; // ------------------------------------------------------------------ Card Dance diff --git a/crates/effects/src/sim2/surfaces.rs b/crates/effects/src/sim2/surfaces.rs index 0bcc639c4..7c3c032f0 100644 --- a/crates/effects/src/sim2/surfaces.rs +++ b/crates/effects/src/sim2/surfaces.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Acc, Buf, EffectCtx, Image, IntoParallelIterator, ParallelIterator, Plane, Px, Shape, SimCache, Sprite, Value, gauss_plane, params_key, unpremul}; // ------------------------------------------------------------------ Caustics diff --git a/crates/effects/src/sim3.rs b/crates/effects/src/sim3.rs index 688b45950..88a16d981 100644 --- a/crates/effects/src/sim3.rs +++ b/crates/effects/src/sim3.rs @@ -575,7 +575,8 @@ fn mapper_from(ctx: &EffectCtx, group: &str, ephemeral: bool) -> Option /// Draw a map layer frame as a particle: centred on (x, y) buffer px, rotated `angle` degrees /// and scaled, over `out`. -fn blit_layer(out: &mut Image, lp: &crate::LayerPixels, x: f64, y: f64, angle: f64, scale: [f64; 2], buf_scale: f64, alpha: f32) { +fn blit_layer(out: &mut Image, lp: &crate::LayerPixels, position: [f64; 2], angle: f64, scale: [f64; 2], buf_scale: f64, alpha: f32) { + let [x, y] = position; let (w, h) = (lp.size[0] * scale[0].abs() * buf_scale, lp.size[1] * scale[1].abs() * buf_scale); let r = (w * w + h * h).sqrt() * 0.5 + 1.0; let (x0, x1) = ((x - r).floor().max(0.0) as i64, (x + r).ceil().min(out.width as f64) as i64); @@ -925,7 +926,7 @@ impl PgPlan { pub fn finish(&self, mut b: Buf) -> Buf { let mut fx = splat(b.img.width, b.img.height, &self.sprites.sprites, self.sprites.acc); for q in &self.blits { - blit_layer(&mut fx, &self.frames[q.frame], q.x, q.y, q.angle, q.scale, b.scale, q.alpha); + blit_layer(&mut fx, &self.frames[q.frame], [q.x, q.y], q.angle, q.scale, b.scale, q.alpha); } // Particles replace the layer's own pixels. b.img = combine(&b.img, &fx, 5); diff --git a/crates/effects/src/warp_stab.rs b/crates/effects/src/warp_stab.rs index f850bdcf3..226654a04 100644 --- a/crates/effects/src/warp_stab.rs +++ b/crates/effects/src/warp_stab.rs @@ -453,7 +453,7 @@ fn render(ctx: &EffectCtx, buf: Buf) -> Buf { }); let need = need_m.into_inner().unwrap_or_else(std::sync::PoisonError::into_inner); if synth && need.iter().any(|w| *w > 0.0) { - synthesize(ctx, &a, k, &src_of, &crop, sc, &mut out, &need); + synthesize(ctx, &a, k, &SynthesisGrid { src_of: &src_of, crop: &crop, scale: sc }, &mut out, &need); } if b(ctx.params, "advanced/showTrackPoints") { let r = (2.5 * f(ctx.params, "advanced/trackPointSize") / 100.0 * sc).max(0.5); @@ -465,19 +465,16 @@ fn render(ctx: &EffectCtx, buf: Buf) -> Buf { Buf { img: out, offset: [0.0; 2], scale: sc } } -/// Fill the pixels flagged in `need` (weight = how much synthesized content they take) from -/// neighbouring frames, nearest first. -#[allow(clippy::too_many_arguments)] -fn synthesize( - ctx: &EffectCtx, - a: &WarpAnalysis, - k: usize, - src_of: &(dyn Fn([f64; 2]) -> [f64; 2] + Sync), - crop: &[f64; 4], - sc: f64, - out: &mut Image, - need: &[f32], -) { +/// Maps an output sample into the uncropped source grid shared by neighbouring frames. +struct SynthesisGrid<'a> { + src_of: &'a (dyn Fn([f64; 2]) -> [f64; 2] + Sync), + crop: &'a [f64; 4], + scale: f64, +} + +/// Fill the pixels flagged in `need` from neighbouring frames, nearest first. +fn synthesize(ctx: &EffectCtx, a: &WarpAnalysis, k: usize, grid: &SynthesisGrid<'_>, out: &mut Image, need: &[f32]) { + let SynthesisGrid { src_of, crop, scale: sc } = *grid; let Some(host) = ctx.env.host else { return }; let n = a.frames.len(); let range = ((f(ctx.params, "advanced/synthesizeInputRange").max(0.0) / a.frame_duration.max(1e-6)).round() as usize).min(60); diff --git a/crates/engine/src/analysis/roto.rs b/crates/engine/src/analysis/roto.rs index d9ce6e5af..813561969 100644 --- a/crates/engine/src/analysis/roto.rs +++ b/crates/engine/src/analysis/roto.rs @@ -450,7 +450,18 @@ async fn run_work(w: Work, shared: &RotoShared) { (RotoTask::Propagate, true) => om::ensure(&w.params, w.size, w.fps, *f, model.clone(), &mut get, &cancel).is_some(), (RotoTask::Freeze, true) => { let img = get(*f); - match img.map(|img| (om::frame_matte(&w.params, w.size, w.fps, *f, 1.0, &img, model.clone(), &mut get, &cancel), img)) { + match img.map(|img| { + ( + om::frame_matte( + &w.params, + &om::MatteFrame { layer_size: w.size, fps: w.fps, frame: *f, scale: 1.0, img: &img }, + model.clone(), + &mut get, + &cancel, + ), + img, + ) + }) { Some((om::FrameMatte::Alpha(a), img)) => { frozen.size = [img.width as usize, img.height as usize]; frozen.frames.insert(*f, fx::freeze_frame(&a, &[])); diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index 47b3cbde9..bceef85fb 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -156,7 +156,7 @@ mod enabled; mod params; mod schema; -use super::*; +use super::commands; pub use enabled::always; pub(crate) use enabled::{has_comp, has_keys, has_layers, has_project_selection, not_yet, not_yet_run}; pub use params::match_path_of; diff --git a/crates/engine/src/cmd/enabled.rs b/crates/engine/src/cmd/enabled.rs index ba2631b20..fb28b60c4 100644 --- a/crates/engine/src/cmd/enabled.rs +++ b/crates/engine/src/cmd/enabled.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{EngineError, Result, Session, Value}; // ---------- enablement ---------- diff --git a/crates/engine/src/cmd/layer/contents.rs b/crates/engine/src/cmd/layer/contents.rs index 884d0df5c..d8824fc9e 100644 --- a/crates/engine/src/cmd/layer/contents.rs +++ b/crates/engine/src/cmd/layer/contents.rs @@ -1,5 +1,8 @@ use super::create::{apply_text_params, text_range_p}; -use super::*; +use super::{ + EngineError, GroupKind, Ids, ItemId, KV, LayerId, MaskMode, Node, Project, PropGroup, Result, Session, TextDoc, Value, b_p, bad, build, json, layer_mut, + layer_p, merge_p, str_p, +}; pub(super) fn add_mask(s: &mut Session, p: &Value) -> Result { let (cid, lid) = layer_p(s, p, "layer.addMask")?; diff --git a/crates/engine/src/cmd/layer/create.rs b/crates/engine/src/cmd/layer/create.rs index 7a01aa538..7962c2485 100644 --- a/crates/engine/src/cmd/layer/create.rs +++ b/crates/engine/src/cmd/layer/create.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + EngineError, Ids, ItemId, ItemKind, Justify, KV, Label, Layer, LayerId, LayerSource, Project, Result, Session, Solid, TextDoc, Tick, Value, b_p, bad, + build, comp_id, f_p, json, limits, str_p, +}; pub(crate) fn color_p(p: &Value, k: &str) -> Option<[f32; 3]> { match p.get(k)? { diff --git a/crates/engine/src/cmd/layer/parent.rs b/crates/engine/src/cmd/layer/parent.rs index 8565cbbb1..fbaec14e4 100644 --- a/crates/engine/src/cmd/layer/parent.rs +++ b/crates/engine/src/cmd/layer/parent.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Comp, EngineError, ItemId, KV, Layer, LayerId, PropGroup, Result, Session, Tick, Value, b_p, bad, layers_p, resolve_layer}; /// Re-express a layer in its new parent space without moving its anchor. pub(crate) struct ParentFix { @@ -336,6 +336,7 @@ pub(super) fn set_parent(s: &mut Session, p: &Value) -> Result { #[cfg(test)] mod parent_chain_tests { + use super::super::json; use super::*; use std::sync::Arc; diff --git a/crates/engine/src/cmd/layer/replace.rs b/crates/engine/src/cmd/layer/replace.rs index 3fa208d15..80e5be807 100644 --- a/crates/engine/src/cmd/layer/replace.rs +++ b/crates/engine/src/cmd/layer/replace.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + EngineError, FootageKind, Ids, ItemId, ItemKind, Layer, LayerSource, Project, Result, Session, Value, bad, build, has_comp, json, layer_mut, layers_p, + unlocked, +}; /// A layer whose source is an item (footage, precomposition or solid), as in After Effects' /// Replace Source. Text, shape, null, camera, light and 3D model layers have no AVItem source to diff --git a/crates/engine/src/cmd/layer/select.rs b/crates/engine/src/cmd/layer/select.rs index b7fded70d..0edf7a72c 100644 --- a/crates/engine/src/cmd/layer/select.rs +++ b/crates/engine/src/cmd/layer/select.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{EngineError, LayerId, Result, Session, Value, b_p, comp_id, json, resolve_layer}; pub(super) fn select(s: &mut Session, p: &Value) -> Result { let cid = comp_id(s, p)?; diff --git a/crates/engine/src/cmd/layer/switches.rs b/crates/engine/src/cmd/layer/switches.rs index 046cc37a4..38689afbb 100644 --- a/crates/engine/src/cmd/layer/switches.rs +++ b/crates/engine/src/cmd/layer/switches.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + BlendMode, EngineError, FrameBlend, ItemId, KV, Layer, LayerId, MatteKind, Quality, Result, Session, TrackMatte, Value, b_p, bad, json, layer_mut, layer_p, + layers_p, merge_p, resolve_layer, str_p, +}; pub(super) fn set_switch(s: &mut Session, p: &Value) -> Result { let (cid, ids) = layers_p(s, p)?; diff --git a/crates/engine/src/cmd/layer/timing.rs b/crates/engine/src/cmd/layer/timing.rs index a1f83dc8f..711eefee2 100644 --- a/crates/engine/src/cmd/layer/timing.rs +++ b/crates/engine/src/cmd/layer/timing.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + EngineError, ItemKind, Label, Layer, LayerId, LayerSource, Result, Session, Tick, Value, b_p, bad, build, f_p, json, layer_mut, layers_p, merge_p, + resolve_layer, str_p, unlocked, with, +}; pub(super) fn timing(s: &mut Session, p: &Value) -> Result { let (cid, ids) = layers_p(s, p)?; diff --git a/crates/engine/src/cmd/layer/transform.rs b/crates/engine/src/cmd/layer/transform.rs index 6612a2d07..8b75dcbc8 100644 --- a/crates/engine/src/cmd/layer/transform.rs +++ b/crates/engine/src/cmd/layer/transform.rs @@ -1,5 +1,8 @@ use super::create::{color_p, pixel_aspect_p}; -use super::*; +use super::{ + EngineError, ItemKind, KV, LayerId, LayerSource, PropGroup, Result, Session, Tick, Value, bad, has_layers, json, layer_mut, layer_p, layers_p, limits, + str_p, unlocked, +}; /// Move Position by `(dx, dy)`, including every key. Separated dimensions move X and Y as well /// as the combined property the channels were split from. diff --git a/crates/engine/src/cmd/params.rs b/crates/engine/src/cmd/params.rs index 296ab6f6f..7c4e1c292 100644 --- a/crates/engine/src/cmd/params.rs +++ b/crates/engine/src/cmd/params.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Comp, EngineError, ItemId, Layer, LayerId, Result, Session, Tick, Value}; /// Hand a frontend-only command to the UI ([`crate::Event::Frontend`]). pub(crate) fn frontend(s: &mut Session, id: &str, p: &Value) -> Result { diff --git a/crates/engine/src/cmd/paths.rs b/crates/engine/src/cmd/paths.rs index 96767d967..afd7aba12 100644 --- a/crates/engine/src/cmd/paths.rs +++ b/crates/engine/src/cmd/paths.rs @@ -121,16 +121,16 @@ fn parametric_path(g: &PropGroup, lt: effectcraft_time::Tick) -> Option effectcraft_path::rect(v2("size", [100.0; 2]), v2("position", [0.0; 2]), f("roundness", 0.0)), "ellipse" => effectcraft_path::ellipse(v2("size", [100.0; 2]), v2("position", [0.0; 2])), - "star" => effectcraft_path::polystar( - g.prop("type").map_or(0, |pr| pr.value_at(lt).as_enum()) == 0, - f("points", 5.0), - v2("position", [0.0; 2]), - f("rotation", 0.0), - f("innerRadius", 50.0), - f("outerRadius", 100.0), - f("innerRoundness", 0.0), - f("outerRoundness", 0.0), - ), + "star" => effectcraft_path::polystar(effectcraft_path::Polystar { + star: g.prop("type").map_or(0, |pr| pr.value_at(lt).as_enum()) == 0, + points: f("points", 5.0), + pos: v2("position", [0.0; 2]), + rotation: f("rotation", 0.0), + inner: f("innerRadius", 50.0), + outer: f("outerRadius", 100.0), + inner_round: f("innerRoundness", 0.0), + outer_round: f("outerRoundness", 0.0), + }), _ => return None, }; // A closed parametric shape is one subpath; anything else would not be this shape. diff --git a/crates/engine/src/cmd/render_queue.rs b/crates/engine/src/cmd/render_queue.rs index 09a7c7d2f..1cda510e0 100644 --- a/crates/engine/src/cmd/render_queue.rs +++ b/crates/engine/src/cmd/render_queue.rs @@ -205,23 +205,8 @@ fn apply_om_template(om: &mut OutputModule, t: &OutputModule) { /// The `format` parameter's values. const FORMATS: &str = "format: h264|hevc|av1|prores|uncompressed|webm|png|jpeg|tiff|exr|gif|wav|aiff"; -/// Apply Output Module parameters (`template` first). `roi`: the viewer's region of interest -/// (captured by `crop.useRoi`). -fn apply_output(om: &mut OutputModule, templates: &RenderTemplates, roi: Option<[f64; 4]>, p: &Value, cmd: &str) -> Result { - let mut any = false; - if let Some(t) = str_p(p, "template") { - let t = templates.output_module(t).ok_or_else(|| bad(cmd, format!("no Output Module template “{t}”")))?; - apply_om_template(om, &t); - any = true; - } - let before = om.clone(); - if let Some(f) = str_p(p, "format") { - let f = OutputFormat::from_name(f).ok_or_else(|| bad(cmd, FORMATS))?; - om.set_format(f); - } - // Before Channels: the WebM codec decides whether alpha can be written, so an explicit - // `channels: rgba` with `webmCodec: av1` is refused rather than dropped (#166). - any |= apply_codec_options(om, p, cmd)?; +/// `channels` (refusing RGBA where the format has no alpha) and `color`/`alphaMode`. +fn apply_channels_and_alpha(om: &mut OutputModule, p: &Value, cmd: &str) -> Result<()> { if let Some(c) = str_p(p, "channels") { om.channels = match c.to_ascii_lowercase().replace([' ', '+'], "").as_str() { "rgb" => Channels::Rgb, @@ -244,21 +229,11 @@ fn apply_output(om: &mut OutputModule, templates: &RenderTemplates, roi: Option< _ => return Err(bad(cmd, "color: straight|premultiplied")), }; } - if let Some(q) = f_p(p, "quality") { - om.quality = q.clamp(1.0, 100.0) as u8; - } - if let Some(b) = f_p(p, "bitrate") { - om.bitrate_kbps = b.clamp(50.0, 500_000.0) as u32; - } - if let Some(b) = b_p(p, "webmBitrate") { - om.webm_bitrate = b; - } - if let Some(k) = p.get("keyframeInterval").and_then(Value::as_u64) { - om.keyframe_interval = k.min(100_000) as u32; - } - if let Some(pp) = str_p(p, "proresProfile") { - om.prores_profile = ProResProfile::from_name(pp).ok_or_else(|| bad(cmd, "proresProfile: proxy|lt|standard|hq|4444|4444xq"))?; - } + Ok(()) +} + +/// `crop` plus the flat dialog keys (`cropTop`… and `resizeWidth`/`resizeHeight`). +fn apply_crop(om: &mut OutputModule, roi: Option<[f64; 4]>, p: &Value, cmd: &str) -> Result<()> { match p.get("crop") { Some(Value::Bool(b)) => om.crop.enabled = *b, Some(Value::Object(c)) => { @@ -298,6 +273,11 @@ fn apply_output(om: &mut OutputModule, templates: &RenderTemplates, roi: Option< om.resize.enabled = true; } } + Ok(()) +} + +/// `resize` (bool or object). +fn apply_resize(om: &mut OutputModule, p: &Value, cmd: &str) -> Result<()> { match p.get("resize") { Some(Value::Bool(b)) => om.resize.enabled = *b, Some(Value::Object(r)) => { @@ -328,6 +308,44 @@ fn apply_output(om: &mut OutputModule, templates: &RenderTemplates, roi: Option< Some(Value::Null) | None => {} Some(_) => return Err(bad(cmd, "resize: bool or {enabled?, preset?, width?, height?, lockAspect?, quality?: low|high}")), } + Ok(()) +} + +/// Apply Output Module parameters (`template` first). `roi`: the viewer's region of interest +/// (captured by `crop.useRoi`). +fn apply_output(om: &mut OutputModule, templates: &RenderTemplates, roi: Option<[f64; 4]>, p: &Value, cmd: &str) -> Result { + let mut any = false; + if let Some(t) = str_p(p, "template") { + let t = templates.output_module(t).ok_or_else(|| bad(cmd, format!("no Output Module template “{t}”")))?; + apply_om_template(om, &t); + any = true; + } + let before = om.clone(); + if let Some(f) = str_p(p, "format") { + let f = OutputFormat::from_name(f).ok_or_else(|| bad(cmd, FORMATS))?; + om.set_format(f); + } + // Before Channels: the WebM codec decides whether alpha can be written, so an explicit + // `channels: rgba` with `webmCodec: av1` is refused rather than dropped (#166). + any |= apply_codec_options(om, p, cmd)?; + apply_channels_and_alpha(om, p, cmd)?; + if let Some(q) = f_p(p, "quality") { + om.quality = q.clamp(1.0, 100.0) as u8; + } + if let Some(b) = f_p(p, "bitrate") { + om.bitrate_kbps = b.clamp(50.0, 500_000.0) as u32; + } + if let Some(b) = b_p(p, "webmBitrate") { + om.webm_bitrate = b; + } + if let Some(k) = p.get("keyframeInterval").and_then(Value::as_u64) { + om.keyframe_interval = k.min(100_000) as u32; + } + if let Some(pp) = str_p(p, "proresProfile") { + om.prores_profile = ProResProfile::from_name(pp).ok_or_else(|| bad(cmd, "proresProfile: proxy|lt|standard|hq|4444|4444xq"))?; + } + apply_crop(om, roi, p, cmd)?; + apply_resize(om, p, cmd)?; if let Some(a) = p.get("audio") { om.audio = match a { Value::Bool(true) => AudioOutput::On, diff --git a/crates/engine/src/cmd/schema.rs b/crates/engine/src/cmd/schema.rs index 11e2ef9f2..c74ab277e 100644 --- a/crates/engine/src/cmd/schema.rs +++ b/crates/engine/src/cmd/schema.rs @@ -1,5 +1,5 @@ use super::params::bad; -use super::*; +use super::{CommandSpec, Result, Value}; // ---------- parameter validation (agents) ---------- diff --git a/crates/engine/src/cmd/shape_tool.rs b/crates/engine/src/cmd/shape_tool.rs index a48c32b7f..e1f3607b1 100644 --- a/crates/engine/src/cmd/shape_tool.rs +++ b/crates/engine/src/cmd/shape_tool.rs @@ -221,7 +221,16 @@ pub fn mask_path(kind: &str, centre: [f64; 2], size: [f64; 2]) -> Option effectcraft_path::rect(size, centre, roundness(size)), k => { let (star, points, outer, inner) = polystar_of(k, size)?; - effectcraft_path::polystar(star, points, centre, 0.0, inner, outer, 0.0, 0.0) + effectcraft_path::polystar(effectcraft_path::Polystar { + star, + points, + pos: centre, + rotation: 0.0, + inner, + outer, + inner_round: 0.0, + outer_round: 0.0, + }) } }; effectcraft_path::from_kurbo(&bez).into_iter().next() diff --git a/crates/engine/src/doc_state.rs b/crates/engine/src/doc_state.rs index e5fb1791b..954a7d24e 100644 --- a/crates/engine/src/doc_state.rs +++ b/crates/engine/src/doc_state.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Deserialize, ItemId, Layer, LayerId, Serialize, Tick, Uid, commands, mask_track, media_browser, roto, viewer}; /// A keyframe reference: layer, property uid, key time (layer time). #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] diff --git a/crates/engine/src/event.rs b/crates/engine/src/event.rs index 0e60575de..e1f5e0535 100644 --- a/crates/engine/src/event.rs +++ b/crates/engine/src/event.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{ItemId, Serialize, Value}; /// Events for frontends (drained each frame). #[derive(Clone, Debug, PartialEq, Serialize)] diff --git a/crates/engine/src/fonts.rs b/crates/engine/src/fonts.rs index 039fd2bad..6b06d6f5b 100644 --- a/crates/engine/src/fonts.rs +++ b/crates/engine/src/fonts.rs @@ -1,4 +1,4 @@ -use super::*; +use super::prefs; pub fn text_families() -> Vec { effectcraft_text::families().into_iter().map(|(f, _)| f).collect() diff --git a/crates/engine/src/home/demo.rs b/crates/engine/src/home/demo.rs index df07f5204..656bd7708 100644 --- a/crates/engine/src/home/demo.rs +++ b/crates/engine/src/home/demo.rs @@ -130,8 +130,20 @@ fn solid_sized(p: &mut Project, comp: &Comp, folder: ItemId, name: &str, color: build::layer(p, comp, name, LayerSource::Solid { item: sid }, (w, h), None) } +/// What `card` draws: name, gradient end colors (hex), size, position and Y rotation. +#[derive(Clone, Copy)] +struct CardSpec<'a> { + name: &'a str, + a: &'a str, + b: &'a str, + size: (u32, u32), + pos: [f64; 3], + rot_y: f64, +} + /// A 3D card: a solid with a diagonal gradient and a thin border grid. -fn card(p: &mut Project, comp: &Comp, folder: ItemId, name: &str, a: &str, b: &str, size: (u32, u32), pos: [f64; 3], rot_y: f64) -> Layer { +fn card(p: &mut Project, comp: &Comp, folder: ItemId, spec: &CardSpec) -> Layer { + let CardSpec { name, a, b, size, pos, rot_y } = *spec; let mut l = solid_sized(p, comp, folder, name, [1.0, 1.0, 1.0], size.0, size.1); let (w, h) = (size.0 as f64, size.1 as f64); effect( @@ -192,11 +204,11 @@ fn showcase_3d(p: &mut Project, solids: ItemId) -> Comp { set(&mut floor, "materialOptions/specularIntensity", Value::Scalar(10.0)); // Cards at different depths and angles; the centre pair intersects. - let left = card(p, &c, solids, "Card Blue", "#2E7BF0", "#7A4DFF", (520, 340), [520.0, 560.0, 120.0], 28.0); - let right = card(p, &c, solids, "Card Coral", "#FF6A5C", "#FFB347", (520, 340), [1420.0, 560.0, 260.0], -32.0); - let mut x1 = card(p, &c, solids, "Cross A", "#1FD1A5", "#2E7BF0", (460, 460), [960.0, 470.0, 520.0], 40.0); + let left = card(p, &c, solids, &CardSpec { name: "Card Blue", a: "#2E7BF0", b: "#7A4DFF", size: (520, 340), pos: [520.0, 560.0, 120.0], rot_y: 28.0 }); + let right = card(p, &c, solids, &CardSpec { name: "Card Coral", a: "#FF6A5C", b: "#FFB347", size: (520, 340), pos: [1420.0, 560.0, 260.0], rot_y: -32.0 }); + let mut x1 = card(p, &c, solids, &CardSpec { name: "Cross A", a: "#1FD1A5", b: "#2E7BF0", size: (460, 460), pos: [960.0, 470.0, 520.0], rot_y: 40.0 }); set(&mut x1, "materialOptions/lightTransmission", Value::Scalar(30.0)); - let x2 = card(p, &c, solids, "Cross B", "#F0E14A", "#FF6A5C", (460, 460), [960.0, 470.0, 520.0], -40.0); + let x2 = card(p, &c, solids, &CardSpec { name: "Cross B", a: "#F0E14A", b: "#FF6A5C", size: (460, 460), pos: [960.0, 470.0, 520.0], rot_y: -40.0 }); // Hero title in 3D space, gently turning. let mut title = text_layer( p, diff --git a/crates/engine/src/services.rs b/crates/engine/src/services.rs index 01a2102ec..d962bf62d 100644 --- a/crates/engine/src/services.rs +++ b/crates/engine/src/services.rs @@ -1,4 +1,4 @@ -use super::*; +use super::config; /// Host services (file access) injected by the frontend. pub trait Services: Send + Sync { diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index d4feb6d67..18b0ff4cf 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -1,7 +1,9 @@ use super::doc_state::{CompSelection, EditorState}; use super::error::{EngineError, Result}; use super::event::Event; -use super::*; +use super::{ + Arc, Comp, History, Image, ItemId, LayerId, Project, RenderOpts, Renderer, Session, ThumbnailJob, Tick, Value, camera_track, commands, guard, roto, warp, +}; mod comps; mod dispatch; diff --git a/crates/engine/src/session/comps.rs b/crates/engine/src/session/comps.rs index 3c663e22f..ed3810e79 100644 --- a/crates/engine/src/session/comps.rs +++ b/crates/engine/src/session/comps.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Comp, CompSelection, Event, ItemId, LayerId, Result, Session, Tick, Value, commands}; /// `t` as a current time of `comp`: a frame within its duration. fn comp_frame_at(comp: &Comp, t: Tick) -> Tick { diff --git a/crates/engine/src/session/dispatch.rs b/crates/engine/src/session/dispatch.rs index 815de866b..36c7f0a51 100644 --- a/crates/engine/src/session/dispatch.rs +++ b/crates/engine/src/session/dispatch.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{EngineError, Event, Result, Session, Value, commands, guard}; impl Session { pub fn new() -> Session { diff --git a/crates/engine/src/session/edit.rs b/crates/engine/src/session/edit.rs index f9df9ef20..a8b03461a 100644 --- a/crates/engine/src/session/edit.rs +++ b/crates/engine/src/session/edit.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Arc, EditorState, EngineError, Event, History, ItemId, Project, Result, Session, camera_track, commands, roto, warp}; impl Session { /// Apply an undoable edit. `merge`: consecutive edits with the same key fold into one step. diff --git a/crates/engine/src/session/render.rs b/crates/engine/src/session/render.rs index 855c9c401..53cd6d753 100644 --- a/crates/engine/src/session/render.rs +++ b/crates/engine/src/session/render.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{EngineError, Image, ItemId, RenderOpts, Renderer, Result, Session, ThumbnailJob, Tick}; impl Session { /// Render a comp frame with the session's footage + expression hosts. diff --git a/crates/engine/src/settings/prefs/dialog.rs b/crates/engine/src/settings/prefs/dialog.rs index e0f49a8e9..e3f2006d8 100644 --- a/crates/engine/src/settings/prefs/dialog.rs +++ b/crates/engine/src/settings/prefs/dialog.rs @@ -1,5 +1,5 @@ use super::choices::{APPEARANCE_MODES, DARK_THEMES, LANGUAGES, LIGHT_THEMES, WINDOW_GRAPHICS}; -use super::*; +use super::{Value, json}; // ---------------------------------------------------------------- schema (Settings dialog) diff --git a/crates/engine/src/settings/prefs/document.rs b/crates/engine/src/settings/prefs/document.rs index 49f2e5e9d..9e991da46 100644 --- a/crates/engine/src/settings/prefs/document.rs +++ b/crates/engine/src/settings/prefs/document.rs @@ -1,7 +1,7 @@ use super::choices::{APPEARANCE_MODES, DARK_THEMES, LANGUAGES, LIGHT_THEMES, THEMES, WINDOW_GRAPHICS, choices, is_choice, theme_family}; use super::recent::MAX_EFFECTS_FAVORITES; use super::sections::{CompPreset, default_labels, parse_hex}; -use super::*; +use super::{Appearance, General, Map, PREFS_VERSION, Prefs, Startup, Value, json}; /// Bring an older settings document up to [`PREFS_VERSION`]. /// diff --git a/crates/engine/src/settings/prefs/queries.rs b/crates/engine/src/settings/prefs/queries.rs index e55f086ed..74be30f8f 100644 --- a/crates/engine/src/settings/prefs/queries.rs +++ b/crates/engine/src/settings/prefs/queries.rs @@ -1,5 +1,5 @@ use super::sections::parse_hex; -use super::*; +use super::{Label, Prefs, Serialize, Tick}; impl Prefs { /// Display name of a label (Labels settings). diff --git a/crates/engine/src/settings/prefs/recent.rs b/crates/engine/src/settings/prefs/recent.rs index c5321490d..2a77a61c2 100644 --- a/crates/engine/src/settings/prefs/recent.rs +++ b/crates/engine/src/settings/prefs/recent.rs @@ -1,4 +1,4 @@ -use super::*; +use super::Prefs; impl Prefs { /// Remember a project in File ▸ Open Recent. diff --git a/crates/engine/src/settings/prefs/sections.rs b/crates/engine/src/settings/prefs/sections.rs index 2b8d8adf6..6f2a1923c 100644 --- a/crates/engine/src/settings/prefs/sections.rs +++ b/crates/engine/src/settings/prefs/sections.rs @@ -1,5 +1,5 @@ use super::choices::theme_family; -use super::*; +use super::{BTreeMap, Deserialize, Label, Serialize, Value}; /// Unknown keys inside a page, kept so a newer version's settings survive a round trip. pub(super) type Extra = BTreeMap; diff --git a/crates/engine/src/tests/camera_track.rs b/crates/engine/src/tests/camera_track.rs index cc6378c4e..3ca8578a9 100644 --- a/crates/engine/src/tests/camera_track.rs +++ b/crates/engine/src/tests/camera_track.rs @@ -3,7 +3,7 @@ use effectcraft_effects::camera_tracker as ct; use effectcraft_geom::vec3; -use effectcraft_project::LayerId; +use effectcraft_project::{Comp, Layer, LayerId, PropGroup}; use effectcraft_raster::Image; use effectcraft_render::EvalCtx; use effectcraft_render::three_d::camera::active_camera; @@ -11,6 +11,7 @@ use effectcraft_track::camtrack::linalg::{self, M3, V3}; use serde_json::{Value, json}; use crate::Session; +use crate::camera_track::Placed; use crate::tests::track::{H, W, frame_time, setup, texture}; const F: f64 = 260.0; @@ -77,10 +78,9 @@ fn status(s: &mut Session) -> Value { s.execute("camera.solveStatus", json!({})).unwrap() } -#[test] -fn track_camera_solves_and_creates_layers_on_the_plane() { - let (mut s, clip) = tracked(json!({})); - let st = status(&mut s); +/// Solved with a sane error and focal length; returns the Camera Tracker effect group. +fn assert_solved(s: &mut Session, clip: LayerId) -> PropGroup { + let st = status(s); assert_eq!(st["solved"], true, "{st}"); let err = st["averageError"].as_f64().unwrap(); assert!(err < 0.5, "average error {err}"); @@ -90,9 +90,12 @@ fn track_camera_solves_and_creates_layers_on_the_plane() { // Method Used / Average Error are reported in the effect. let g = s.active_comp().unwrap().layer(clip).unwrap().effects().unwrap().groups().next().unwrap().clone(); assert!(matches!(g.prop(ct::METHOD_USED).map(|p| &p.value), Some(effectcraft_keyframe::Value::Str(m)) if !m.is_empty())); + g +} - // Pick floor points on frame 12 (their 3D positions lie on y = 1.5 in the true scene: the - // ones seen below the horizon at the bottom of the frame). +/// Pick floor points on frame 12 (their 3D positions lie on y = 1.5 in the true scene: the +/// ones seen below the horizon at the bottom of the frame). +fn select_floor_points(s: &mut Session) -> Vec { s.execute("time.set", json!({"time": frame_time(12).seconds()})).unwrap(); let pts = s.execute("camera.points", json!({})).unwrap(); let list = pts["points"].as_array().unwrap(); @@ -101,32 +104,19 @@ fn track_camera_solves_and_creates_layers_on_the_plane() { assert!(floor.len() >= 3, "{floor:?}"); s.execute("camera.selectPoints", json!({"points": floor})).unwrap(); assert_eq!(s.state.camera_points.len(), floor.len()); + floor +} - // Create Solid and Camera: one undo step, camera + solid. - let r = s.execute_checked("camera.createFromSolve", json!({"kind": "solid"})).unwrap(); - let cam = LayerId(r["camera"].as_u64().unwrap()); - let solid = LayerId(r["layers"][0].as_u64().unwrap()); - let comp = s.active_comp().unwrap().clone(); - assert!(comp.layer(cam).unwrap().is_camera()); - assert_eq!(comp.layer(cam).unwrap().name, "3D Tracker Camera"); - assert_eq!(comp.layers[0].id, cam); - let sl = comp.layer(solid).unwrap(); - assert!(sl.switches.three_d); - assert!(sl.name.starts_with("Track Solid")); - // The camera is keyed on every frame. - assert_eq!(comp.layer(cam).unwrap().props.prop("transform/position").unwrap().keys.len(), 25); - - // Every solved point projects through the created camera exactly where the solve puts it, - // and onto its tracked position (the footage fills the comp, so layer pixels are comp - // pixels): within 1 px for 95 % of the observations (the rest are the solve's outliers: - // features on occlusion edges), 0.5 px on average. +/// Every solved point projects through the created camera exactly where the solve puts it, +/// and onto its tracked position (the footage fills the comp, so layer pixels are comp +/// pixels): within 1 px for 95 % of the observations (the rest are the solve's outliers: +/// features on occlusion edges), 0.5 px on average. +fn assert_reprojection(s: &Session, comp: &Comp, pl: &Placed, g: &PropGroup) { let cid = s.active_comp_id().unwrap(); - let uid = g.uid; - let pl = crate::camera_track::placed(&s.project, cid, clip, uid).unwrap(); - let tracks = ct::tracks(&crate::camera_track::static_params(&g)).unwrap(); + let tracks = ct::tracks(&crate::camera_track::static_params(g)).unwrap(); let mut errs = vec![]; for k in [0u32, 6, 12, 18, 24] { - let ctx = EvalCtx::new(&s.project, cid, &comp, frame_time(k)); + let ctx = EvalCtx::new(&s.project, cid, comp, frame_time(k)); let camst = active_camera(&ctx); for sp in pl.solve.visible(k as usize) { let Some(obs) = tracks.track(sp.id).and_then(|t| t.at(k)) else { continue }; @@ -142,34 +132,31 @@ fn track_camera_solves_and_creates_layers_on_the_plane() { let p95 = errs[errs.len() * 95 / 100]; let mean = errs.iter().sum::() / errs.len() as f64; assert!(p95 < 1.0 && mean < 0.5, "p95 {p95} px, mean {mean} px"); +} - // The solid lies on the selected points' plane. +/// The solid lies on the selected points' plane and faces the camera. +fn assert_solid_on_plane(s: &Session, comp: &Comp, sl: &Layer, pl: &Placed, size: f64) { + let cid = s.active_comp_id().unwrap(); let tr = sl.props.sub("transform").unwrap(); let pos = tr.get("position").unwrap().value.as_vec3(); let ori = tr.get("orientation").unwrap().value.as_vec3(); let normal = linalg::mv(&linalg::from_euler_xyz(ori), [0.0, 0.0, -1.0]); - let size = r["target"]["size"].as_f64().unwrap(); for id in &s.state.camera_points { let w = pl.to_world(pl.solve.point(*id).unwrap().pos); let d = linalg::dot(linalg::sub(w, pos), normal).abs(); assert!(d < 0.05 * size.max(1.0) + 1.0, "point {id} is {d} px off the plane"); } - // The solid faces the camera. - let ctx = EvalCtx::new(&s.project, cid, &comp, frame_time(12)); + let ctx = EvalCtx::new(&s.project, cid, comp, frame_time(12)); let eye = active_camera(&ctx).eye; assert!(linalg::dot(normal, linalg::sub([eye.x, eye.y, eye.z], pos)) > 0.0); +} - // Undo removes both layers; redo brings them back. - let before = comp.layers.len(); - assert!(s.undo()); - assert_eq!(s.active_comp().unwrap().layers.len(), before - 2); - assert!(s.redo()); - assert_eq!(s.active_comp().unwrap().layers.len(), before); - - // A second create reuses the camera: Create Multiple Nulls and Camera. +/// A second create reuses the camera (Create Multiple Nulls and Camera), then Text and +/// Shadow Catcher (+ light). +fn assert_create_kinds(s: &mut Session, cam: LayerId, n_floor: usize) { let r = s.execute_checked("camera.createFromSolve", json!({"kind": "null", "multiple": true})).unwrap(); assert_eq!(r["camera"].as_u64().unwrap(), cam.0); - assert_eq!(r["layers"].as_array().unwrap().len(), floor.len()); + assert_eq!(r["layers"].as_array().unwrap().len(), n_floor); // Text and Shadow Catcher (+ light). let r = s.execute_checked("camera.createFromSolve", json!({"kind": "text"})).unwrap(); assert_eq!(r["layers"].as_array().unwrap().len(), 1); @@ -180,11 +167,12 @@ fn track_camera_solves_and_creates_layers_on_the_plane() { assert_eq!(comp.layer(ids[0]).unwrap().props.prop("materialOptions/acceptsShadows").unwrap().value.as_enum(), 2); assert!(comp.layer(ids[1]).unwrap().is_light()); assert!(s.execute_checked("camera.createFromSolve", json!({"kind": "teapot"})).is_err()); +} - // Set Ground Plane and Origin: the target becomes the origin; a null created there sits at - // (0, 0, 0) lying on the X-Z plane (its front facing up, −y). +/// Set Ground Plane and Origin, then an explicit target. +fn assert_ground_and_target(s: &mut Session) { s.execute_checked("camera.setGroundPlane", json!({})).unwrap(); - assert_eq!(status(&mut s)["groundPlane"], true); + assert_eq!(status(s)["groundPlane"], true); let r = s.execute_checked("camera.createFromSolve", json!({"kind": "null"})).unwrap(); let null = LayerId(r["layers"][0].as_u64().unwrap()); let tr = s.active_comp().unwrap().layer(null).unwrap().props.sub("transform").unwrap().clone(); @@ -203,6 +191,44 @@ fn track_camera_solves_and_creates_layers_on_the_plane() { assert!(linalg::norm(linalg::sub(pos, [10.0, 0.0, 20.0])) < 1e-6, "{pos:?}"); let front = linalg::mv(&linalg::from_euler_xyz(tr.get("orientation").unwrap().value.as_vec3()), [0.0, 0.0, -1.0]); assert!(linalg::norm(linalg::sub(front, [0.0, 0.0, -1.0])) < 1e-6, "{front:?}"); +} + +#[test] +fn track_camera_solves_and_creates_layers_on_the_plane() { + let (mut s, clip) = tracked(json!({})); + let g = assert_solved(&mut s, clip); + let floor = select_floor_points(&mut s); + + // Create Solid and Camera: one undo step, camera + solid. + let r = s.execute_checked("camera.createFromSolve", json!({"kind": "solid"})).unwrap(); + let cam = LayerId(r["camera"].as_u64().unwrap()); + let solid = LayerId(r["layers"][0].as_u64().unwrap()); + let comp = s.active_comp().unwrap().clone(); + assert!(comp.layer(cam).unwrap().is_camera()); + assert_eq!(comp.layer(cam).unwrap().name, "3D Tracker Camera"); + assert_eq!(comp.layers[0].id, cam); + let sl = comp.layer(solid).unwrap(); + assert!(sl.switches.three_d); + assert!(sl.name.starts_with("Track Solid")); + // The camera is keyed on every frame. + assert_eq!(comp.layer(cam).unwrap().props.prop("transform/position").unwrap().keys.len(), 25); + + let cid = s.active_comp_id().unwrap(); + let uid = g.uid; + let pl = crate::camera_track::placed(&s.project, cid, clip, uid).unwrap(); + assert_reprojection(&s, &comp, &pl, &g); + assert_solid_on_plane(&s, &comp, sl, &pl, r["target"]["size"].as_f64().unwrap()); + + // Undo removes both layers; redo brings them back. + let before = comp.layers.len(); + assert!(s.undo()); + assert_eq!(s.active_comp().unwrap().layers.len(), before - 2); + assert!(s.redo()); + assert_eq!(s.active_comp().unwrap().layers.len(), before); + + assert_create_kinds(&mut s, cam, floor.len()); + + assert_ground_and_target(&mut s); // Serde keeps the analysis. let back = effectcraft_project::Project::from_json(&s.project.to_json()).unwrap(); diff --git a/crates/engine/src/tests/mask_warp.rs b/crates/engine/src/tests/mask_warp.rs index e20ec7cb6..adcea44be 100644 --- a/crates/engine/src/tests/mask_warp.rs +++ b/crates/engine/src/tests/mask_warp.rs @@ -239,6 +239,28 @@ fn set_param(s: &mut Session, layer: LayerId, path: &str, v: serde_json::Value) s.execute("prop.set", json!({"layer": layer.0, "path": format!("effects/#1/{path}"), "value": v})).unwrap(); } +/// Stabilize, Crop, Auto-scale (the default) hides the borders; Crop leaves an opaque centre. +fn assert_crop_framing(s: &mut Session, clip: LayerId) { + set_param(s, clip, "borders/framing", json!(2)); + let st = s.execute("warp.status", json!({})).unwrap(); + assert!(st["autoScale"].as_f64().unwrap() > 100.5, "{st}"); + for f in 0..25 { + assert_eq!(transparent(&render(s, f)), 0, "frame {f}"); + } + set_param(s, clip, "borders/framing", json!(1)); + let st = s.execute("warp.status", json!({})).unwrap(); + let crop: [f64; 4] = serde_json::from_value(st["crop"].clone()).unwrap(); + for f in [0, 9, 24] { + let img = render(s, f); + for y in (crop[1].ceil() as u32 + 1)..(crop[3].floor() as u32 - 1) { + for x in (crop[0].ceil() as u32 + 1)..(crop[2].floor() as u32 - 1) { + assert!(img.get(x as i64, y as i64)[3] > 0.99, "frame {f} ({x}, {y})"); + } + } + assert!(img.get(0, 0)[3] < 0.01); + } +} + #[test] fn warp_stabilizer_analyzes_and_stabilizes() { let (mut s, clip, solid) = setup(shaky); @@ -289,25 +311,7 @@ fn warp_stabilizer_analyzes_and_stabilizes() { } set_param(&mut s, clip, "stabilization/result", json!(0)); - // Stabilize, Crop, Auto-scale (the default) hides the borders; Crop leaves an opaque centre. - set_param(&mut s, clip, "borders/framing", json!(2)); - let st = s.execute("warp.status", json!({})).unwrap(); - assert!(st["autoScale"].as_f64().unwrap() > 100.5, "{st}"); - for f in 0..25 { - assert_eq!(transparent(&render(&s, f)), 0, "frame {f}"); - } - set_param(&mut s, clip, "borders/framing", json!(1)); - let st = s.execute("warp.status", json!({})).unwrap(); - let crop: [f64; 4] = serde_json::from_value(st["crop"].clone()).unwrap(); - for f in [0, 9, 24] { - let img = render(&s, f); - for y in (crop[1].ceil() as u32 + 1)..(crop[3].floor() as u32 - 1) { - for x in (crop[0].ceil() as u32 + 1)..(crop[2].floor() as u32 - 1) { - assert!(img.get(x as i64, y as i64)[3] > 0.99, "frame {f} ({x}, {y})"); - } - } - assert!(img.get(0, 0)[3] < 0.01); - } + assert_crop_framing(&mut s, clip); // Synthesize Edges fills most of the borders from neighbouring frames. set_param(&mut s, clip, "borders/framing", json!(3)); let synth: usize = (0..25).map(|f| transparent(&render(&s, f))).sum(); diff --git a/crates/engine/src/tests/rig3d.rs b/crates/engine/src/tests/rig3d.rs index d3b965107..d801a743a 100644 --- a/crates/engine/src/tests/rig3d.rs +++ b/crates/engine/src/tests/rig3d.rs @@ -49,45 +49,38 @@ fn camera(s: &mut Session) -> u64 { s.execute("layer.newCamera", json!({"name": "Cam", "position": [320, 180, -800], "poi": [320, 180, 0], "zoom": 900})).unwrap()["layer"].as_u64().unwrap() } -#[test] -fn stereo_rig_builds_eye_comps_and_glasses() { - let mut s = session(); - let src = cid(&s); - let cam = camera(&mut s); - let items_before = s.project.items.len(); - let r = s.execute("camera.stereoRig", json!({"sceneDepth": 4})).unwrap(); - assert_eq!(s.project.items.len(), items_before + 3, "left, right and stereo comps"); - assert_eq!(r["master"].as_u64(), Some(cam)); - // Controls on a hidden null in the source comp. - let ctl = layer_in(&s, src, r["controls"].as_u64().unwrap()); +/// Controls on a hidden null in the source comp. +fn assert_stereo_controls(ctl: &Layer) { assert_eq!(ctl.name, "Stereo 3D Controls"); assert!(!ctl.switches.video); let fx = ctl.effects().unwrap().groups().next().unwrap().clone(); assert!(matches!(&fx.kind, GroupKind::Effect { effect } if effect == effectcraft_effects::controls::STEREO_CONTROLS)); assert_eq!(fx.get("sceneDepth").unwrap().value, KV::Scalar(4.0)); - // Eye comps: an eye camera (two-node, linked by expressions) and the collapsed source comp. - let d = 0.04 * 640.0; - for (key, name, x) in [("leftComp", "Scene Left Eye", 320.0 - d / 2.0), ("rightComp", "Scene Right Eye", 320.0 + d / 2.0)] { - let ec = ItemId(r[key].as_u64().unwrap()); - assert_eq!(s.project.item(ec).unwrap().name, name); - let c = s.project.comp(ec).unwrap(); - assert_eq!((c.width, c.height), (640, 360)); - assert_eq!(c.layers.len(), 2); - let cam = &c.layers[0]; - assert!(cam.is_camera() && effectcraft_render::three_d::camera::is_two_node(cam)); - assert!(close3(v3(cam, "transform/position"), [x, 180.0, -800.0], 1e-6), "{:?}", v3(cam, "transform/position")); - // Parallel eyes (no convergence): each looks straight ahead. - assert!(close3(v3(cam, "transform/poi"), [x, 180.0, 0.0], 1e-6), "{:?}", v3(cam, "transform/poi")); - assert_eq!(cam.props.prop("cameraOptions/zoom").unwrap().value, KV::Scalar(900.0)); - for p in ["transform/position", "transform/poi", "cameraOptions/zoom"] { - let e = cam.props.prop(p).unwrap().expr.clone().unwrap(); - assert!(e.text.contains("comp(\"Scene\")") && e.text.contains("Cam"), "{p}: {}", e.text); - } - let nest = &c.layers[1]; - assert_eq!(nest.source, LayerSource::Comp { item: src }); - assert!(nest.switches.collapse && nest.switches.three_d); +} + +/// An eye comp: an eye camera (two-node, linked by expressions) at `x` and the collapsed source comp. +fn assert_eye_comp(s: &Session, src: ItemId, ec: ItemId, name: &str, x: f64) { + assert_eq!(s.project.item(ec).unwrap().name, name); + let c = s.project.comp(ec).unwrap(); + assert_eq!((c.width, c.height), (640, 360)); + assert_eq!(c.layers.len(), 2); + let cam = &c.layers[0]; + assert!(cam.is_camera() && effectcraft_render::three_d::camera::is_two_node(cam)); + assert!(close3(v3(cam, "transform/position"), [x, 180.0, -800.0], 1e-6), "{:?}", v3(cam, "transform/position")); + // Parallel eyes (no convergence): each looks straight ahead. + assert!(close3(v3(cam, "transform/poi"), [x, 180.0, 0.0], 1e-6), "{:?}", v3(cam, "transform/poi")); + assert_eq!(cam.props.prop("cameraOptions/zoom").unwrap().value, KV::Scalar(900.0)); + for p in ["transform/position", "transform/poi", "cameraOptions/zoom"] { + let e = cam.props.prop(p).unwrap().expr.clone().unwrap(); + assert!(e.text.contains("comp(\"Scene\")") && e.text.contains("Cam"), "{p}: {}", e.text); } - // The stereo comp: left eye on top with 3D Glasses fed by both eyes; right eye hidden. + let nest = &c.layers[1]; + assert_eq!(nest.source, LayerSource::Comp { item: src }); + assert!(nest.switches.collapse && nest.switches.three_d); +} + +/// The stereo comp: left eye on top with 3D Glasses fed by both eyes; right eye hidden. +fn assert_stereo_comp(s: &Session, r: &serde_json::Value) { let oc = ItemId(r["stereoComp"].as_u64().unwrap()); let out = s.project.comp(oc).unwrap(); assert_eq!(s.project.item(oc).unwrap().name, "Scene Stereo 3D"); @@ -98,6 +91,23 @@ fn stereo_rig_builds_eye_comps_and_glasses() { assert!(matches!(&g.kind, GroupKind::Effect { effect } if effect == "ec.perspective.3dglasses")); assert_eq!(g.get("leftView").unwrap().value, KV::Layer(Some(l))); assert_eq!(g.get("rightView").unwrap().value, KV::Layer(Some(rr))); +} + +#[test] +fn stereo_rig_builds_eye_comps_and_glasses() { + let mut s = session(); + let src = cid(&s); + let cam = camera(&mut s); + let items_before = s.project.items.len(); + let r = s.execute("camera.stereoRig", json!({"sceneDepth": 4})).unwrap(); + assert_eq!(s.project.items.len(), items_before + 3, "left, right and stereo comps"); + assert_eq!(r["master"].as_u64(), Some(cam)); + assert_stereo_controls(&layer_in(&s, src, r["controls"].as_u64().unwrap())); + let d = 0.04 * 640.0; + for (key, name, x) in [("leftComp", "Scene Left Eye", 320.0 - d / 2.0), ("rightComp", "Scene Right Eye", 320.0 + d / 2.0)] { + assert_eye_comp(&s, src, ItemId(r[key].as_u64().unwrap()), name, x); + } + assert_stereo_comp(&s, &r); // One undo step; redo; serde. let json = serde_json::to_string(&*s.project).unwrap(); let back: Project = serde_json::from_str(&json).unwrap(); diff --git a/crates/engine/src/tests/vector_import.rs b/crates/engine/src/tests/vector_import.rs index 63513b910..9cfb94ecc 100644 --- a/crates/engine/src/tests/vector_import.rs +++ b/crates/engine/src/tests/vector_import.rs @@ -2,7 +2,7 @@ use effectcraft_color::BlendMode; use effectcraft_keyframe::Value as KV; -use effectcraft_project::{ItemKind, LayerSource, MaskMode}; +use effectcraft_project::{Comp, ItemKind, Layer, LayerSource, MaskMode}; use effectcraft_psd::write::*; use effectcraft_psd::{DValue, Descriptor, Rect}; use serde_json::json; @@ -74,22 +74,8 @@ pub(crate) fn sample_psd() -> Vec { write(&d) } -#[test] -fn psd_imports_as_composition() { - let path = tmp("sample.psd"); - std::fs::write(&path, sample_psd()).unwrap(); - let mut s = Session::default(); - let r = s.execute_checked("file.import", json!({"paths": [path], "importAs": "composition"})).unwrap(); - assert_eq!(r["errors"], json!([]), "{r}"); - let cid = effectcraft_project::ItemId(r["comps"][0].as_u64().unwrap()); - assert_eq!(s.state.active_comp, Some(cid)); - assert_eq!(s.project.item(cid).unwrap().name, "sample"); - let c = s.project.comp(cid).unwrap().clone(); - assert_eq!((c.width, c.height), (120, 80)); - let names: Vec<&str> = c.layers.iter().map(|l| l.name.as_str()).collect(); - assert_eq!(names, ["Title", "Hue Shift", "Vector Masked", "Clipped", "Base", "Group A", "Background"]); - // Text layer: editable text, positioned at the type origin. - let title = &c.layers[0]; +/// Text layer: editable text, positioned at the type origin. +fn assert_title_layer(title: &Layer) { assert!(matches!(title.source, LayerSource::Text)); let Some(KV::Text(doc)) = title.props.prop("text/sourceText").map(|p| p.value.clone()) else { panic!("text") }; assert_eq!(doc.text, "Hello PSD"); @@ -97,14 +83,18 @@ fn psd_imports_as_composition() { assert_eq!(doc.font, "Inter"); assert_eq!(doc.style, "Bold"); assert_eq!(title.props.prop("transform/position").unwrap().value.as_vec2(), [8.0, 70.0]); - // Adjustment layer with Hue/Saturation. - let hue = &c.layers[1]; +} + +/// Adjustment layer with Hue/Saturation. +fn assert_hue_layer(hue: &Layer) { assert!(hue.switches.adjustment); let fx = hue.effects().unwrap().groups().next().unwrap(); assert_eq!(fx.get("hue").unwrap().value.as_f64(), 40.0); assert_eq!(fx.get("saturation").unwrap().value.as_f64(), 10.0); - // Vector mask → mask; Stroke style. - let vm = &c.layers[2]; +} + +/// Vector mask → mask; Stroke style. +fn assert_vector_mask_layer(vm: &Layer) { let m = vm.masks().unwrap().groups().next().unwrap(); assert!(matches!(m.kind, effectcraft_project::GroupKind::Mask { mode: MaskMode::Add, .. })); let Some(KV::Path(p)) = m.get("path").map(|p| p.value.clone()) else { panic!("mask path") }; @@ -113,19 +103,23 @@ fn psd_imports_as_composition() { let st = vm.props.group("layerStyles/stroke").unwrap(); assert_eq!(st.get("size").unwrap().value.as_f64(), 2.0); assert_eq!(st.get("position").unwrap().value, KV::Enum(1)); - // Clipped → preserve transparency; opacity and blend mode. +} + +/// Clipped → preserve transparency (opacity, blend mode); drop shadow. +fn assert_clipped_and_shadow(c: &Comp) { let clipped = &c.layers[3]; assert!(clipped.preserve_transparency); assert_eq!(clipped.blend_mode, BlendMode::Screen); assert!((clipped.props.prop("transform/opacity").unwrap().value.as_f64() - 128.0 / 255.0 * 100.0).abs() < 1e-9); - // Drop shadow. let ds = c.layers[4].props.group("layerStyles/dropShadow").unwrap(); assert_eq!(ds.get("distance").unwrap().value.as_f64(), 9.0); assert_eq!(ds.get("angle").unwrap().value.as_f64(), 45.0); assert_eq!(ds.get("useGlobalLight").unwrap().value, KV::Bool(false)); assert_eq!(ds.get("opacity").unwrap().value.as_f64(), 60.0); - // Group → collapsed precomp (pass through) with its layers. - let g = &c.layers[5]; +} + +/// Group → collapsed precomp (pass through) with its layers; footage items name their Photoshop layer. +fn assert_group_precomp(s: &Session, g: &Layer) { assert!(g.switches.collapse); let LayerSource::Comp { item } = g.source else { panic!("precomp") }; let inner = s.project.comp(item).unwrap(); @@ -140,6 +134,27 @@ fn psd_imports_as_composition() { assert_eq!((sl.name.as_str(), sl.layer_size), ("Masked Square", false)); assert_eq!((f.width, f.height), (120, 80)); assert_eq!(s.project.item(item).unwrap().name, "Masked Square/sample.psd"); +} + +#[test] +fn psd_imports_as_composition() { + let path = tmp("sample.psd"); + std::fs::write(&path, sample_psd()).unwrap(); + let mut s = Session::default(); + let r = s.execute_checked("file.import", json!({"paths": [path], "importAs": "composition"})).unwrap(); + assert_eq!(r["errors"], json!([]), "{r}"); + let cid = effectcraft_project::ItemId(r["comps"][0].as_u64().unwrap()); + assert_eq!(s.state.active_comp, Some(cid)); + assert_eq!(s.project.item(cid).unwrap().name, "sample"); + let c = s.project.comp(cid).unwrap().clone(); + assert_eq!((c.width, c.height), (120, 80)); + let names: Vec<&str> = c.layers.iter().map(|l| l.name.as_str()).collect(); + assert_eq!(names, ["Title", "Hue Shift", "Vector Masked", "Clipped", "Base", "Group A", "Background"]); + assert_title_layer(&c.layers[0]); + assert_hue_layer(&c.layers[1]); + assert_vector_mask_layer(&c.layers[2]); + assert_clipped_and_shadow(&c); + assert_group_precomp(&s, &c.layers[5]); // One undo step removes everything. s.execute("edit.undo", json!({})).unwrap(); assert!(s.project.comp(cid).is_none()); diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index 9af195204..7ea28b571 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -270,7 +270,7 @@ async fn sequence(cx: &Cx<'_>, comp: &Comp, w: u32, h: u32, st: &mut State<'_>) let path = sequence_path(cx.path, f0 + k as i64); // Encode in memory first: the storage hook decides where it fits. let mut buf = std::io::Cursor::new(Vec::new()); - write_still(cx, &mut buf, fmt, &img, comp, channels, w, h, cx.output.quality)?; + write_still(cx, &mut buf, &img, comp, StillOutput { format: fmt, channels, size: (w, h), quality: cx.output.quality })?; let data = buf.into_inner(); let path = cx.place(&path, data.len() as u64); let mut f = cx.create(&path)?; @@ -287,18 +287,15 @@ async fn sequence(cx: &Cx<'_>, comp: &Comp, w: u32, h: u32, st: &mut State<'_>) Ok(Report { path: sequence_path(cx.path, f0), frames: 0, width: w, height: h, seconds: 0.0, bytes, audio: false, log: None, overflow: vec![] }) } -#[allow(clippy::too_many_arguments)] -fn write_still( - cx: &Cx, - file: &mut W, - fmt: OutputFormat, - img: &Image, - comp: &Comp, +struct StillOutput { + format: OutputFormat, channels: Channels, - w: u32, - h: u32, + size: (u32, u32), quality: u8, -) -> Result<()> { +} + +fn write_still(cx: &Cx, file: &mut W, img: &Image, comp: &Comp, output: StillOutput) -> Result<()> { + let StillOutput { format: fmt, channels, size: (w, h), quality } = output; use image::{ExtendedColorType, ImageEncoder, ImageFormat}; match fmt { OutputFormat::ExrSequence => { diff --git a/crates/export/tests/it/render_settings.rs b/crates/export/tests/it/render_settings.rs index 9e9a0a18f..465d8e5fe 100644 --- a/crates/export/tests/it/render_settings.rs +++ b/crates/export/tests/it/render_settings.rs @@ -45,7 +45,7 @@ fn custom(start: f64, end: f64) -> RenderSettings { } fn run_with(p: &Project, cid: ItemId, s: &RenderSettings, om: &OutputModule, path: &str, options: JobOptions, cancel: bool) -> (Result, Files) { - run_on(p, cid, s, om, path, options, None, cancel) + run_on(p, cid, s, om, path, options, (None, cancel)) } /// [`run_with`] with an accelerator. @@ -56,9 +56,9 @@ fn run_on( om: &OutputModule, path: &str, options: JobOptions, - accel: Option<&dyn Accelerator>, - cancel: bool, + execution: (Option<&dyn Accelerator>, bool), ) -> (Result, Files) { + let (accel, cancel) = execution; let files: Files = Default::default(); let f = files.clone(); let sink: Box = Box::new(move |path: &str, data: Vec| f.lock().unwrap().push((path.to_string(), data))); @@ -456,7 +456,7 @@ fn a_job_renders_on_one_compositor() { let om = OutputModule::for_format(OutputFormat::PngSequence); let s = custom(0.0, 0.4); let render = |p: &Project, accel: &GreenAccel, path: &str| { - let (r, files) = run_on(p, cid, &s, &om, path, JobOptions::default(), Some(accel), false); + let (r, files) = run_on(p, cid, &s, &om, path, JobOptions::default(), (Some(accel), false)); r.expect("export"); (0..10).map(|i| png(&files, i)).collect::>() }; diff --git a/crates/expr/src/host.rs b/crates/expr/src/host.rs index f98b895a5..a25fbc4f0 100644 --- a/crates/expr/src/host.rs +++ b/crates/expr/src/host.rs @@ -313,6 +313,32 @@ impl<'a> Resolver<'a> { self.try_resolve(req).unwrap_or(Resp::Null) } + /// Resolve an ancestor from its UID path while preserving child indexing. + fn ancestor(&self, comp: u64, layer: u64, path: &str, up: i64) -> Option { + let l = self.layer(comp, layer)?; + // The node's full `@uid/@uid/…` path from the layer: its ancestors are its prefixes. + let uid = match self.prop(comp, layer, path) { + Some((_, p)) => p.uid, + None => l.props.node(path)?.uid(), + }; + let full = l.props.path_of(uid)?; + let segs: Vec<&str> = full.split('/').collect(); + let up = usize::try_from(up).ok().filter(|u| *u >= 1 && *u <= segs.len())?; + if up == segs.len() { + return Some(Resp::Str("layer".into())); + } + let keep = segs.len() - up; + let group_path = segs.get(..keep)?.join("/"); + let parent = match keep { + 1 => &l.props, + _ => l.props.group(&segs.get(..keep - 1)?.join("/"))?, + }; + let uid = segs.get(keep - 1)?.strip_prefix('@')?.parse::().ok()?; + let index = parent.children.iter().position(|c| c.uid() == uid)?; + let n = parent.children.get(index)?; + Some(node_resp(group_path, n, index + 1)) + } + fn try_resolve(&self, req: &Req) -> Option { let project = self.ctx.project; Some(match req { @@ -373,30 +399,7 @@ impl<'a> Resolver<'a> { let path = if group.is_empty() { format!("@{}", n.uid()) } else { format!("{group}/@{}", n.uid()) }; node_resp(path, n, index) } - Req::Ancestor { comp, layer, path, up } => { - let l = self.layer(*comp, *layer)?; - // The node's full `@uid/@uid/…` path from the layer: its ancestors are its prefixes. - let uid = match self.prop(*comp, *layer, path) { - Some((_, p)) => p.uid, - None => l.props.node(path)?.uid(), - }; - let full = l.props.path_of(uid)?; - let segs: Vec<&str> = full.split('/').collect(); - let up = usize::try_from(*up).ok().filter(|u| *u >= 1 && *u <= segs.len())?; - if up == segs.len() { - return Some(Resp::Str("layer".into())); - } - let keep = segs.len() - up; - let group_path = segs.get(..keep)?.join("/"); - let parent = match keep { - 1 => &l.props, - _ => l.props.group(&segs.get(..keep - 1)?.join("/"))?, - }; - let uid = segs.get(keep - 1)?.strip_prefix('@')?.parse::().ok()?; - let index = parent.children.iter().position(|c| c.uid() == uid)?; - let n = parent.children.get(index)?; - node_resp(group_path, n, index + 1) - } + Req::Ancestor { comp, layer, path, up } => return self.ancestor(*comp, *layer, path, *up), Req::PropInfo { comp, layer, path } => { let (l, p) = self.prop(*comp, *layer, path)?; let dims = prop_dims(l, p); diff --git a/crates/expr/src/lib.rs b/crates/expr/src/lib.rs index 4963810c4..c77af5b37 100644 --- a/crates/expr/src/lib.rs +++ b/crates/expr/src/lib.rs @@ -93,7 +93,7 @@ impl ExprHost for Expressions { return Ok([v.first().copied().unwrap_or(100.0), v.get(1).copied().unwrap_or(100.0), v.get(2).copied().unwrap_or(100.0)]); }; let value = prop.value_at(layer.layer_time(ctx.time)); - let out = evaluate_out(self, ctx, layer, prop, &value, &e.text, [index as f64 + 1.0, total as f64], selector)?; + let out = evaluate_out(self, ctx, layer, prop, &value, &e.text, TextSelector { index_total: [index as f64 + 1.0, total as f64], value: selector })?; let num = |o: &runtime::Out| match o { runtime::Out::Num(x) => Some(*x), runtime::Out::Bool(b) => Some(if *b { 100.0 } else { 0.0 }), @@ -124,13 +124,17 @@ impl Drop for FrameGuard { /// Evaluate `text` as the expression of `prop` on `layer` at `ctx.time`, given its keyframed /// `value`. Other properties' expressions are evaluated through `host`. pub fn evaluate(host: &dyn ExprHost, ctx: &EvalCtx, layer: &Layer, prop: &Property, value: &Value, text: &str) -> Result { - let out = evaluate_out(host, ctx, layer, prop, value, text, [1.0, 1.0], [100.0; 3])?; + let out = evaluate_out(host, ctx, layer, prop, value, text, TextSelector { index_total: [1.0, 1.0], value: [100.0; 3] })?; runtime::to_value(&out, value) } -/// Run an expression and return the raw script result. `text_sel` / `selector` are the text -/// Expression Selector's `[textIndex, textTotal]` and `selectorValue`. -#[allow(clippy::too_many_arguments)] +/// Expression Selector state: `[textIndex, textTotal]` and `selectorValue`. +struct TextSelector { + index_total: [f64; 2], + value: [f64; 3], +} + +/// Run an expression and return the raw script result. fn evaluate_out( host: &dyn ExprHost, ctx: &EvalCtx, @@ -138,9 +142,9 @@ fn evaluate_out( prop: &Property, value: &Value, text: &str, - text_sel: [f64; 2], - selector: [f64; 3], + text_selector: TextSelector, ) -> Result { + let TextSelector { index_total: text_sel, value: selector } = text_selector; let owner = Owner { comp: ctx.comp_id.0, layer: layer.id.0, uid: prop.uid, time: ctx.time, text: text.to_string() }; let (depth, cycle) = FRAMES.with(|f| { let f = f.borrow(); diff --git a/crates/gpu/src/bokeh.rs b/crates/gpu/src/bokeh.rs index ac07448c9..39dc0bdea 100644 --- a/crates/gpu/src/bokeh.rs +++ b/crates/gpu/src/bokeh.rs @@ -26,7 +26,7 @@ pub(crate) fn apply(e: &mut Enc, src: &GpuImage, d: &PlaneDof) -> Option = d.radius.as_ref().clone(); let mut levels = vec![]; diff --git a/crates/gpu/src/classic3d.rs b/crates/gpu/src/classic3d.rs index 7ee2988f9..a3e2a3a59 100644 --- a/crates/gpu/src/classic3d.rs +++ b/crates/gpu/src/classic3d.rs @@ -214,7 +214,14 @@ pub(crate) fn draw_run(e: &mut Enc, run: &Run3d, canvas: &GpuImage) -> Option { owner: u64, } +/// Images bound to a compute kernel's source, optional auxiliary and output slots. +pub(crate) struct KernelImages<'a> { + pub src: &'a GpuImage, + pub aux: Option<&'a GpuImage>, + pub out: &'a GpuImage, +} + impl Drop for Enc<'_> { fn drop(&mut self) { // Unsubmitted work is discarded, so nothing recorded will read pooled textures. @@ -1162,32 +1169,21 @@ impl<'g> Enc<'g> { } /// Run kernel `entry` writing `out` (dispatched over `groups` workgroups). - pub fn dispatch( - &mut self, - entry: &str, - p: &Params, - src: &GpuImage, - aux: Option<&GpuImage>, - out: &GpuImage, - data: Option<&wgpu::Buffer>, - groups: (u32, u32), - ) { - self.dispatch_ext(entry, p, src, aux, out, data, groups, None); + pub fn dispatch(&mut self, entry: &str, p: &Params, images: KernelImages<'_>, data: Option<&wgpu::Buffer>, groups: (u32, u32)) { + self.dispatch_ext(entry, p, images, data, groups, None); } /// [`Enc::dispatch`] for the kernels in `EXT_ENTRIES`, with their group 1 storage buffers. - #[allow(clippy::too_many_arguments)] pub fn dispatch_ext( &mut self, entry: &str, p: &Params, - src: &GpuImage, - aux: Option<&GpuImage>, - out: &GpuImage, + images: KernelImages<'_>, data: Option<&wgpu::Buffer>, groups: (u32, u32), ext: Option<[&wgpu::Buffer; 4]>, ) { + let KernelImages { src, aux, out } = images; let g = self.g; let Some(pipe) = g.pipelines.get(entry) else { if !g.unavailable.iter().any(|(e, _)| *e == entry) { @@ -1243,7 +1239,7 @@ impl<'g> Enc<'g> { /// Per-pixel kernel over `out`'s size (16×16 workgroups). pub fn pixels(&mut self, entry: &str, p: &Params, src: &GpuImage, aux: Option<&GpuImage>, out: &GpuImage, data: Option<&wgpu::Buffer>) { let groups = (out.width.div_ceil(16), out.height.div_ceil(16)); - self.dispatch(entry, p, src, aux, out, data, groups); + self.dispatch(entry, p, KernelImages { src, aux, out }, data, groups); } /// Copy `src` into a larger zeroed image at (`x`, `y`) (Buf::pad). diff --git a/crates/gpu/src/effects.rs b/crates/gpu/src/effects.rs index 5041582f9..087c4961a 100644 --- a/crates/gpu/src/effects.rs +++ b/crates/gpu/src/effects.rs @@ -234,7 +234,13 @@ pub(crate) fn box_passes(e: &mut Enc, img: &GpuImage, rx: &[usize], ry: &[usize] let mut p = Params::default(); p.u[0] = [r as u32, repeat as u32, block, 0]; let out = e.scratch(cur.width, cur.height); - e.dispatch(if vertical { "box_v" } else { "box_h" }, &p, &cur, None, &out, None, (lines.div_ceil(64), n.div_ceil(block))); + e.dispatch( + if vertical { "box_v" } else { "box_h" }, + &p, + crate::context::KernelImages { src: &cur, aux: None, out: &out }, + None, + (lines.div_ceil(64), n.div_ceil(block)), + ); cur = out; } cur @@ -390,7 +396,7 @@ fn transform(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { * Mat3::translate(vec2(-anchor.0, -anchor.1)); let sampling = if ctx.params.e("sampling") == 1 { Sampling::Bicubic } else { Sampling::Bilinear }; let empty = e.image(b.img.width, b.img.height); - b.img = ops::warp(e, &empty, &b.img, &m, sampling, effectcraft_color::BlendMode::Normal, opacity, 0, None); + b.img = ops::warp(e, &empty, &b.img, &m, ops::WarpOptions { sampling, mode: effectcraft_color::BlendMode::Normal, opacity, seed: 0 }, None); Some(b) } diff --git a/crates/gpu/src/fx_generate.rs b/crates/gpu/src/fx_generate.rs index 0089dd4f4..1bb29f3fe 100644 --- a/crates/gpu/src/fx_generate.rs +++ b/crates/gpu/src/fx_generate.rs @@ -165,7 +165,7 @@ fn camera_lens(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { return None; } let pre = e.image(w + 1, h); - e.dispatch("gen_row_prefix", &Params::default(), &src, None, &pre, None, (h.div_ceil(64), 1)); + e.dispatch("gen_row_prefix", &Params::default(), crate::context::KernelImages { src: &src, aux: None, out: &pre }, None, (h.div_ceil(64), 1)); Some(pre) } else { None diff --git a/crates/gpu/src/fx_noise.rs b/crates/gpu/src/fx_noise.rs index b954f8cf3..22f23fd86 100644 --- a/crates/gpu/src/fx_noise.rs +++ b/crates/gpu/src/fx_noise.rs @@ -214,7 +214,7 @@ pub(crate) fn median_image(e: &mut Enc, img: &GpuImage, r: usize, straight: bool let seg = (4 * r as u32).clamp(32, 512).min(q.width); let out = e.scratch(q.width, q.height); let groups = (q.height.div_ceil(e.g.median_kernel.rows_per_workgroup()), q.width.div_ceil(seg)); - e.dispatch("fxn_median_huang", &with_u0([r as u32, seg, 0, 0]), &q, None, &out, Some(&levels), groups); + e.dispatch("fxn_median_huang", &with_u0([r as u32, seg, 0, 0]), crate::context::KernelImages { src: &q, aux: None, out: &out }, Some(&levels), groups); out } diff --git a/crates/gpu/src/fx_stylize.rs b/crates/gpu/src/fx_stylize.rs index d53367e76..4b8ae787a 100644 --- a/crates/gpu/src/fx_stylize.rs +++ b/crates/gpu/src/fx_stylize.rs @@ -879,7 +879,7 @@ fn corner_pin(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { let o = b.to_px([0.0, 0.0]); let m = quad * Mat3::scale(vec2(1.0 / (w * b.scale), 1.0 / (h * b.scale))) * Mat3::translate(vec2(-o.0, -o.1)); let empty = e.image(b.img.width, b.img.height); - b.img = ops::warp(e, &empty, &b.img, &m, Sampling::Bilinear, BlendMode::Normal, 1.0, 0, None); + b.img = ops::warp(e, &empty, &b.img, &m, ops::WarpOptions { sampling: Sampling::Bilinear, mode: BlendMode::Normal, opacity: 1.0, seed: 0 }, None); Some(b) } @@ -989,7 +989,7 @@ pub(crate) fn transform(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option GpuImage { let mut p = Params::default(); p.u[0][0] = 256; let out = e.scratch(cur.width, cur.height); - e.dispatch("fxv_box_h", &p, &cur, None, &out, Some(&buf), (cur.height.div_ceil(64), cur.width.div_ceil(256))); + e.dispatch( + "fxv_box_h", + &p, + crate::context::KernelImages { src: &cur, aux: None, out: &out }, + Some(&buf), + (cur.height.div_ceil(64), cur.width.div_ceil(256)), + ); cur = out; } for &r in &radii { @@ -192,7 +198,13 @@ fn sphere_blur(e: &mut Enc, img: &GpuImage, sigma: f64) -> GpuImage { let mut p = Params::default(); p.u[0] = [block, r as u32, 0, 0]; let out = e.scratch(cur.width, cur.height); - e.dispatch("fxv_box_v", &p, &cur, None, &out, None, (cur.width.div_ceil(64), cur.height.div_ceil(block))); + e.dispatch( + "fxv_box_v", + &p, + crate::context::KernelImages { src: &cur, aux: None, out: &out }, + None, + (cur.width.div_ceil(64), cur.height.div_ceil(block)), + ); cur = out; } cur diff --git a/crates/gpu/src/ops.rs b/crates/gpu/src/ops.rs index c9e0900e9..30c8c9bea 100644 --- a/crates/gpu/src/ops.rs +++ b/crates/gpu/src/ops.rs @@ -32,37 +32,23 @@ pub fn half(e: &mut Enc, src: &GpuImage) -> GpuImage { out } -/// `raster::composite_warp` (and, with `accumulate = Some(weight)`, `accumulate_warp`): draw -/// `src` transformed by `m` (src pixels → dst pixels) onto `dst`. -pub fn warp( - e: &mut Enc, - dst: &GpuImage, - src: &GpuImage, - m: &Mat3, - sampling: Sampling, - mode: BlendMode, - opacity: f32, - seed: u32, - accumulate: Option, -) -> GpuImage { - warp_q(e, dst, src, m, sampling, mode, opacity, seed, accumulate, None) +/// Sampling and blending policy for a transformed GPU image. +pub struct WarpOptions { + pub sampling: Sampling, + pub mode: BlendMode, + pub opacity: f32, + pub seed: u32, +} + +/// Draw `src` transformed by `m` onto `dst`, or accumulate it with a sample weight. +pub fn warp(e: &mut Enc, dst: &GpuImage, src: &GpuImage, m: &Mat3, opts: WarpOptions, accumulate: Option) -> GpuImage { + warp_q(e, dst, src, m, opts, accumulate, None) } /// [`warp`], clamping and quantising the result to `levels` (8/16 bpc: the comp after a layer, /// in the same pass). -#[allow(clippy::too_many_arguments)] -pub fn warp_q( - e: &mut Enc, - dst: &GpuImage, - src: &GpuImage, - m: &Mat3, - sampling: Sampling, - mode: BlendMode, - opacity: f32, - seed: u32, - accumulate: Option, - levels: Option, -) -> GpuImage { +pub fn warp_q(e: &mut Enc, dst: &GpuImage, src: &GpuImage, m: &Mat3, opts: WarpOptions, accumulate: Option, levels: Option) -> GpuImage { + let WarpOptions { sampling, mode, opacity, seed } = opts; if src.width == 0 || src.height == 0 || opacity <= 0.0 { return dst.clone(); } diff --git a/crates/gpu/src/tests/init.rs b/crates/gpu/src/tests/init.rs index 9c579ae69..2292544cd 100644 --- a/crates/gpu/src/tests/init.rs +++ b/crates/gpu/src/tests/init.rs @@ -2,7 +2,6 @@ //! Set EFFECTCRAFT_REQUIRE_GPU=1 for acceptance: unavailable adapters/devices then fail //! instead of taking the explicitly logged headless-CI skip path. #![cfg(not(target_arch = "wasm32"))] -#![allow(clippy::expect_used)] // Test helpers require successful setup to exercise rejection. use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; diff --git a/crates/gpu/src/walk.rs b/crates/gpu/src/walk.rs index b6e78870c..fc39345b8 100644 --- a/crates/gpu/src/walk.rs +++ b/crates/gpu/src/walk.rs @@ -466,12 +466,12 @@ fn place_src( ) -> Option { let pl = r.placement(ctx, layer, buf); if let [m] = pl.matrices.as_slice() { - return Some(ops::warp_q(e, target, &src, m, pl.sampling, mode, opacity, pl.seed, None, levels)); + return Some(ops::warp_q(e, target, &src, m, ops::WarpOptions { sampling: pl.sampling, mode, opacity, seed: pl.seed }, None, levels)); } let mut acc = e.zeros(target.width, target.height); let k = 1.0 / pl.matrices.len() as f32; for m in &pl.matrices { - acc = ops::warp(e, &acc, &src, m, pl.sampling, BlendMode::Normal, 1.0, 0, Some(k)); + acc = ops::warp(e, &acc, &src, m, ops::WarpOptions { sampling: pl.sampling, mode: BlendMode::Normal, opacity: 1.0, seed: 0 }, Some(k)); } Some(ops::blend_full(e, target, &acc, mode, opacity, pl.seed, levels)) } diff --git a/crates/hevcenc/src/deblock.rs b/crates/hevcenc/src/deblock.rs index 6f5d17b87..6d326e5d4 100644 --- a/crates/hevcenc/src/deblock.rs +++ b/crates/hevcenc/src/deblock.rs @@ -78,7 +78,6 @@ pub fn deblock(planes: &mut [Vec; 3], blk: &[Blk], w: usize, h: usize, qp: /// Filter one 4-sample luma edge segment. `o` = offset of q0 of the first line, `step` = distance /// between samples across the edge, `along` = distance between lines. -#[allow(clippy::too_many_arguments)] fn filter_luma(s: &mut [u16], o: usize, step: usize, along: usize, tc: i32, beta: i32, bd: u32) { let px = |s: &[u16], k: usize, i: usize| s[o + k * along - (i + 1) * step] as i32; let qx = |s: &[u16], k: usize, i: usize| s[o + k * along + i * step] as i32; diff --git a/crates/hevcenc/src/frame.rs b/crates/hevcenc/src/frame.rs index 760bea2b0..ee141e3e7 100644 --- a/crates/hevcenc/src/frame.rs +++ b/crates/hevcenc/src/frame.rs @@ -7,7 +7,10 @@ use crate::inter::{self, PlaneRef}; use crate::intra::{self, Refs}; use crate::me::MeRef; use crate::params::{LOG2_CTB, LOG2_MAX_TB, LOG2_MIN_CB, MAX_MERGE_CAND}; -use crate::tables::*; +use crate::tables::{ + ABS_MVD_GT0, ABS_MVD_GT1, CBF_CHROMA, CBF_LUMA, CODED_SUB_BLOCK, CTX_IDX_MAP, CU_SKIP, GT1, GT2, INTRA_CHROMA, LAST_X_PREFIX, LAST_Y_PREFIX, MERGE_FLAG, + MERGE_IDX, MVP_FLAG, PART_MODE, PRED_MODE, PREV_INTRA_LUMA, RQT_ROOT_CBF, SCAN_4, SIG_COEFF, SPLIT_CU, qpc_420, scan_order, +}; use crate::transform; pub const F_INTRA: u8 = 1; @@ -73,6 +76,40 @@ pub enum SliceKind { P, } +/// Picture geometry and slice coding parameters shared by the CTU encoder. +pub struct FrameConfig { + pub bit_depth: u32, + pub width: usize, + pub height: usize, + pub kind: SliceKind, + pub slice_qp: i32, +} + +/// A square node in the coding or transform quadtree. +#[derive(Clone, Copy)] +struct TreeNode { + x: usize, + y: usize, + log2: u32, + depth: u32, +} + +impl TreeNode { + fn children(self) -> impl Iterator { + let h = 1usize << (self.log2 - 1); + [(0, 0), (h, 0), (0, h), (h, h)].into_iter().map(move |(dx, dy)| Self { x: self.x + dx, y: self.y + dy, log2: self.log2 - 1, depth: self.depth + 1 }) + } +} + +/// Transform syntax inherited from the parent node and the enclosing CU. +#[derive(Clone, Copy)] +struct TransformState { + blk_idx: u32, + parent: [bool; 2], + intra: bool, + nxn: bool, +} + pub struct FrameCoder<'a> { pub bd: u32, pub w: usize, @@ -114,17 +151,8 @@ fn morton(x: u32, y: u32) -> u32 { const COARSE_MODES: [u32; 11] = [0, 1, 2, 6, 10, 14, 18, 22, 26, 30, 34]; impl<'a> FrameCoder<'a> { - #[allow(clippy::too_many_arguments)] - pub fn new( - bd: u32, - w: usize, - h: usize, - kind: SliceKind, - slice_qp: i32, - src: &'a [Vec; 3], - refp: Option<&'a [Vec; 3]>, - me: Option<&'a MeRef>, - ) -> Self { + pub fn new(config: FrameConfig, src: &'a [Vec; 3], refp: Option<&'a [Vec; 3]>, me: Option<&'a MeRef>) -> Self { + let FrameConfig { bit_depth: bd, width: w, height: h, kind, slice_qp } = config; let off = 6 * (bd as i32 - 8); let qpi = slice_qp.clamp(-off, 57); let qp_c = qpc_420(qpi) + off; @@ -256,7 +284,7 @@ impl<'a> FrameCoder<'a> { let mut decs = Vec::with_capacity(64); self.decide(x, y, LOG2_CTB, 0, &mut est, &mut decs); let mut idx = 0; - self.write_cq(enc, x, y, LOG2_CTB, 0, &decs, &mut idx); + self.write_cq(enc, TreeNode { x, y, log2: LOG2_CTB, depth: 0 }, &decs, &mut idx); debug_assert_eq!(idx, decs.len()); debug_assert_eq!(est.ctx, enc.ctx); enc.terminate((i + 1 == total) as u32); // end_of_slice_segment_flag @@ -276,7 +304,8 @@ impl<'a> FrameCoder<'a> { } /// Final pass: re-code the decided quadtree with the real arithmetic coder. - fn write_cq(&mut self, enc: &mut CabacEncoder, x: usize, y: usize, log2: u32, depth: u32, decs: &[CuDec], idx: &mut usize) { + fn write_cq(&mut self, enc: &mut CabacEncoder, node: TreeNode, decs: &[CuDec], idx: &mut usize) { + let TreeNode { x, y, log2, depth } = node; if x >= self.w || y >= self.h { return; } @@ -294,9 +323,8 @@ impl<'a> FrameCoder<'a> { let (eff, _) = self.encode_cu(enc, x, y, log2, depth, d.mode); debug_assert_eq!(eff, d.mode); } else { - let h = n / 2; - for (dx, dy) in [(0, 0), (h, 0), (0, h), (h, h)] { - self.write_cq(enc, x + dx, y + dy, log2 - 1, depth + 1, decs, idx); + for child in node.children() { + self.write_cq(enc, child, decs, idx); } } } @@ -447,7 +475,7 @@ impl<'a> FrameCoder<'a> { self.write_intra_modes(s, x, y, n, nxn, &modes); s.decision(INTRA_CHROMA, 0); // intra_chroma_pred_mode 4 (DM) let mut ti = 0; - self.write_tt(s, x, y, x, y, log2, 0, 0, [true, true], true, nxn, &mut ti); + self.write_tt(s, TreeNode { x, y, log2, depth: 0 }, TransformState { blk_idx: 0, parent: [true, true], intra: true, nxn }, &mut ti); (mode, self.cu_dist(x, y, n)) } _ => { @@ -490,7 +518,12 @@ impl<'a> FrameCoder<'a> { s.decision(MERGE_FLAG, 1); write_merge_idx(s, idx as usize); let mut ti = 0; - self.write_tt(s, x, y, x, y, log2, 0, 0, [true, true], false, false, &mut ti); + self.write_tt( + s, + TreeNode { x, y, log2, depth: 0 }, + TransformState { blk_idx: 0, parent: [true, true], intra: false, nxn: false }, + &mut ti, + ); } CuMode::Amvp { mv, mvp } => { s.decision(CU_SKIP + skip_ctx, 0); @@ -503,7 +536,12 @@ impl<'a> FrameCoder<'a> { s.decision(RQT_ROOT_CBF, root as u32); if root { let mut ti = 0; - self.write_tt(s, x, y, x, y, log2, 0, 0, [true, true], false, false, &mut ti); + self.write_tt( + s, + TreeNode { x, y, log2, depth: 0 }, + TransformState { blk_idx: 0, parent: [true, true], intra: false, nxn: false }, + &mut ti, + ); } } // Handled by the arm above. @@ -715,22 +753,9 @@ impl<'a> FrameCoder<'a> { /// transform_tree() (7.3.8.8) over the coded transform blocks of the CU; no split flag is ever /// coded (max_transform_hierarchy_depth_* = 0), splits are inferred. - #[allow(clippy::too_many_arguments)] - fn write_tt( - &self, - s: &mut S, - x0: usize, - y0: usize, - xb: usize, - yb: usize, - log2: u32, - depth: u32, - blk_idx: u32, - parent: [bool; 2], - intra: bool, - nxn: bool, - ti: &mut usize, - ) { + fn write_tt(&self, s: &mut S, node: TreeNode, state: TransformState, ti: &mut usize) { + let TreeNode { x: x0, y: y0, log2, depth } = node; + let TransformState { blk_idx, parent, intra, nxn } = state; let n = 1usize << log2; let split = log2 > LOG2_MAX_TB || (nxn && depth == 0); let mut cbf_c = [false; 2]; @@ -745,13 +770,11 @@ impl<'a> FrameCoder<'a> { cbf_c = parent; } if split { - let h = n / 2; - for (i, (dx, dy)) in [(0, 0), (h, 0), (0, h), (h, h)].into_iter().enumerate() { - self.write_tt(s, x0 + dx, y0 + dy, x0, y0, log2 - 1, depth + 1, i as u32, cbf_c, intra, nxn, ti); + for (i, child) in node.children().enumerate() { + self.write_tt(s, child, TransformState { blk_idx: i as u32, parent: cbf_c, ..state }, ti); } return; } - let _ = (xb, yb); let tb = self.tbs[*ti]; *ti += 1; debug_assert_eq!(tb.c, 0); @@ -874,9 +897,9 @@ impl<'a> FrameCoder<'a> { let (px, py, pn, pw, ph) = if c == 0 { (x, y, n, self.w, self.h) } else { (x / 2, y / 2, n / 2, self.cw, self.ch) }; let pr = PlaneRef { data: &refp[c], w: pw, h: ph }; if c == 0 { - inter::mc_luma(pr, self.bd, px as i32, py as i32, mv, pn, pn, &mut self.pred); + inter::mc_luma(pr, self.bd, px as i32, py as i32, mv, (pn, pn), &mut self.pred); } else { - inter::mc_chroma(pr, self.bd, px as i32, py as i32, mv, pn, pn, &mut self.pred); + inter::mc_chroma(pr, self.bd, px as i32, py as i32, mv, (pn, pn), &mut self.pred); } inter::put_uni(&self.pred, pn, pn, self.bd, &mut self.rec[c][py * pw + px..], pw); } @@ -1153,8 +1176,119 @@ fn last_prefix(v: u32) -> (u32, u32, u32) { } } +/// Context increment for significant_coeff_flag (H.265 9.3.4.2). +fn significance_context(group: (usize, usize), pos: (usize, usize), log2: u32, prev_csbf: u8, c: usize, scan_idx: usize) -> usize { + let (xs, ys) = group; + let (xp, yp) = pos; + let (xc, yc) = (xs * 4 + xp, ys * 4 + yp); + if log2 == 2 { + CTX_IDX_MAP[(yc << 2) + xc] as usize + } else if xc + yc == 0 { + 0 + } else { + let mut sc = match prev_csbf { + 0 => { + if xp + yp == 0 { + 2 + } else if xp + yp < 3 { + 1 + } else { + 0 + } + } + 1 => { + if yp == 0 { + 2 + } else if yp == 1 { + 1 + } else { + 0 + } + } + 2 => { + if xp == 0 { + 2 + } else if xp == 1 { + 1 + } else { + 0 + } + } + _ => 2, + }; + if c == 0 { + if xs + ys > 0 { + sc += 3; + } + sc += if log2 == 3 { if scan_idx == 0 { 9 } else { 15 } } else { 21 }; + } else { + sc += if log2 == 3 { 9 } else { 12 }; + } + sc + } +} + +/// Absolute levels, signs and Rice remainders for one significant coefficient group. +fn write_coefficient_levels(s: &mut S, sig: &[u8], at: impl Fn(usize) -> i32, i: usize, c: usize, prev_g1: &mut Option) { + let nsig = sig.len(); + let abs_at = |k: usize| at(sig[k] as usize).unsigned_abs(); + let mut ctx_set = if i == 0 || c > 0 { 0 } else { 2 }; + if *prev_g1 == Some(0) { + ctx_set += 1; + } + let mut g1ctx = 1u32; + let mut gt1 = [0u8; 16]; + let mut first_g1: Option = None; + let c_off_g1 = if c > 0 { 16 } else { 0 }; + for k in 0..nsig.min(8) { + let f = abs_at(k) > 1; + s.decision(GT1 + ctx_set * 4 + g1ctx.min(3) as usize + c_off_g1, f as u32); + gt1[k] = f as u8; + if f { + g1ctx = 0; + if first_g1.is_none() { + first_g1 = Some(k); + } + } else if g1ctx > 0 { + g1ctx += 1; + } + } + *prev_g1 = Some(g1ctx); + let mut gt2 = 0u32; + if let Some(k) = first_g1 { + gt2 = (abs_at(k) > 2) as u32; + s.decision(GT2 + ctx_set + if c > 0 { 4 } else { 0 }, gt2); + } + // sign data hiding: the sign of the first coefficient in scan order (sig[nsig - 1]) is + // inferred from the parity of the sum of absolute levels + let hidden = sig[0] as i32 - sig[nsig - 1] as i32 > 3; + let nsigns = if hidden { nsig - 1 } else { nsig }; + for k in 0..nsigns { + s.bypass((at(sig[k] as usize) < 0) as u32); + } + if hidden { + let sum: u32 = (0..nsig).map(abs_at).sum(); + debug_assert_eq!(at(sig[nsig - 1] as usize) < 0, sum & 1 == 1, "sign hiding parity"); + } + let mut rice = 0u32; + for k in 0..nsig { + let mut base = 1 + gt1[k] as u32; + if Some(k) == first_g1 { + base += gt2; + } + let thresh = if k < 8 { if Some(k) == first_g1 { 3 } else { 2 } } else { 1 }; + if base == thresh { + let level = abs_at(k); + write_remaining(s, level - base, rice); + if level > 3 * (1 << rice) { + rice = (rice + 1).min(4); + } + } + } +} + /// residual_coding() (7.3.8.11) of one transform block of levels (row-major, n x n). No transform -/// skip, no sign data hiding. +/// skip; signs use sign data hiding where the coefficient span permits it. fn residual_coding(s: &mut S, lv: &[i32], log2: u32, c: usize, scan_idx: usize) { let n = 1usize << log2; let log2sb = log2 - 2; @@ -1233,51 +1367,7 @@ fn residual_coding(s: &mut S, lv: &[i32], log2: u32, c: usize, scan_idx let (xc, yc) = (xs * 4 + xp, ys * 4 + yp); let v = lv[yc * n + xc] != 0; if np > 0 || !infer_dc { - let sig_ctx = if log2 == 2 { - CTX_IDX_MAP[(yc << 2) + xc] as usize - } else if xc + yc == 0 { - 0 - } else { - let mut sc = match prev_csbf { - 0 => { - if xp + yp == 0 { - 2 - } else if xp + yp < 3 { - 1 - } else { - 0 - } - } - 1 => { - if yp == 0 { - 2 - } else if yp == 1 { - 1 - } else { - 0 - } - } - 2 => { - if xp == 0 { - 2 - } else if xp == 1 { - 1 - } else { - 0 - } - } - _ => 2, - }; - if c == 0 { - if xs + ys > 0 { - sc += 3; - } - sc += if log2 == 3 { if scan_idx == 0 { 9 } else { 15 } } else { 21 }; - } else { - sc += if log2 == 3 { 9 } else { 12 }; - } - sc - }; + let sig_ctx = significance_context((xs, ys), (xp, yp), log2, prev_csbf, c, scan_idx); s.decision(SIG_COEFF + c_off_sig + sig_ctx, v as u32); if v { sig[nsig] = p as u8; @@ -1294,60 +1384,7 @@ fn residual_coding(s: &mut S, lv: &[i32], log2: u32, c: usize, scan_idx if nsig == 0 { continue; } - let abs_at = |k: usize| at(xs, ys, sig[k] as usize).unsigned_abs(); - let mut ctx_set = if i == 0 || c > 0 { 0 } else { 2 }; - if prev_g1 == Some(0) { - ctx_set += 1; - } - let mut g1ctx = 1u32; - let mut gt1 = [0u8; 16]; - let mut first_g1: Option = None; - let c_off_g1 = if c > 0 { 16 } else { 0 }; - for k in 0..nsig.min(8) { - let f = abs_at(k) > 1; - s.decision(GT1 + ctx_set * 4 + g1ctx.min(3) as usize + c_off_g1, f as u32); - gt1[k] = f as u8; - if f { - g1ctx = 0; - if first_g1.is_none() { - first_g1 = Some(k); - } - } else if g1ctx > 0 { - g1ctx += 1; - } - } - prev_g1 = Some(g1ctx); - let mut gt2 = 0u32; - if let Some(k) = first_g1 { - gt2 = (abs_at(k) > 2) as u32; - s.decision(GT2 + ctx_set + if c > 0 { 4 } else { 0 }, gt2); - } - // sign data hiding: the sign of the first coefficient in scan order (sig[nsig - 1]) is - // inferred from the parity of the sum of absolute levels - let hidden = sig[0] as i32 - sig[nsig - 1] as i32 > 3; - let nsigns = if hidden { nsig - 1 } else { nsig }; - for k in 0..nsigns { - s.bypass((at(xs, ys, sig[k] as usize) < 0) as u32); - } - if hidden { - let sum: u32 = (0..nsig).map(abs_at).sum(); - debug_assert_eq!(at(xs, ys, sig[nsig - 1] as usize) < 0, sum & 1 == 1, "sign hiding parity"); - } - let mut rice = 0u32; - for k in 0..nsig { - let mut base = 1 + gt1[k] as u32; - if Some(k) == first_g1 { - base += gt2; - } - let thresh = if k < 8 { if Some(k) == first_g1 { 3 } else { 2 } } else { 1 }; - if base == thresh { - let level = abs_at(k); - write_remaining(s, level - base, rice); - if level > 3 * (1 << rice) { - rice = (rice + 1).min(4); - } - } - } + write_coefficient_levels(s, &sig[..nsig], |p| at(xs, ys, p), i, c, &mut prev_g1); } } diff --git a/crates/hevcenc/src/inter.rs b/crates/hevcenc/src/inter.rs index e363871b5..03bf50ffb 100644 --- a/crates/hevcenc/src/inter.rs +++ b/crates/hevcenc/src/inter.rs @@ -35,7 +35,8 @@ impl PlaneRef<'_> { /// Luma prediction samples (14-bit intermediate) of a w x h block at (x, y) displaced by the /// quarter-sample vector `mv`, into `out` (stride w). -pub fn mc_luma(f: PlaneRef, bd: u32, x: i32, y: i32, mv: [i16; 2], w: usize, h: usize, out: &mut [i16]) { +pub fn mc_luma(f: PlaneRef, bd: u32, x: i32, y: i32, mv: [i16; 2], size: (usize, usize), out: &mut [i16]) { + let (w, h) = size; let (fx, fy) = ((mv[0] & 3) as usize, (mv[1] & 3) as usize); let xi = x + (mv[0] as i32 >> 2); let yi = y + (mv[1] as i32 >> 2); @@ -89,7 +90,8 @@ pub fn mc_luma(f: PlaneRef, bd: u32, x: i32, y: i32, mv: [i16; 2], w: usize, h: } /// Chroma prediction samples of a w x h block at chroma position (x, y) with a 1/8-sample vector. -pub fn mc_chroma(f: PlaneRef, bd: u32, x: i32, y: i32, mv: [i16; 2], w: usize, h: usize, out: &mut [i16]) { +pub fn mc_chroma(f: PlaneRef, bd: u32, x: i32, y: i32, mv: [i16; 2], size: (usize, usize), out: &mut [i16]) { + let (w, h) = size; let (fx, fy) = ((mv[0] & 7) as usize, (mv[1] & 7) as usize); let xi = x + (mv[0] as i32 >> 3); let yi = y + (mv[1] as i32 >> 3); diff --git a/crates/hevcenc/src/lib.rs b/crates/hevcenc/src/lib.rs index b2e86b6a1..4d38f84d9 100644 --- a/crates/hevcenc/src/lib.rs +++ b/crates/hevcenc/src/lib.rs @@ -15,8 +15,6 @@ //! ``` #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] -// Index loops over fixed-size blocks read more clearly than iterator chains in codec code. -#![allow(clippy::needless_range_loop, clippy::too_many_arguments)] mod bitstream; mod cabac; @@ -311,7 +309,7 @@ impl Encoder { let kind = if idr { SliceKind::I } else { SliceKind::P }; let me = if idr { None } else { self.reference.as_ref().map(|r| me::MeRef::new(&r[0], w, h, bd)) }; let refp = if idr { None } else { self.reference.as_ref() }; - let mut fc = FrameCoder::new(bd, w, h, kind, qp, &src, refp, me.as_ref()); + let mut fc = FrameCoder::new(frame::FrameConfig { bit_depth: bd, width: w, height: h, kind, slice_qp: qp }, &src, refp, me.as_ref()); // slice_segment_header() (7.3.6.1) let mut bw = BitWriter::new(); diff --git a/crates/media/src/convert.rs b/crates/media/src/convert.rs index d483e4b47..9e88ab4dd 100644 --- a/crates/media/src/convert.rs +++ b/crates/media/src/convert.rs @@ -103,12 +103,34 @@ pub(crate) fn frame_to_image_in(f: &VideoFrame, op: AlphaOp, mut buf: Vec) - PixelData::Yuv8 { planes, chroma, alpha } => { let yuv = Yuv::new(f.color.matrix, f.color.range, 8); let amax = 255.0; - convert_yuv(&mut img, w, h, *chroma, [&planes[0][..], &planes[1][..], &planes[2][..]], alpha.as_deref().map(|a| &a[..]), amax, &yuv, op); + convert_yuv( + &mut img, + YuvSource { + size: (w, h), + chroma: *chroma, + planes: [&planes[0][..], &planes[1][..], &planes[2][..]], + alpha: alpha.as_deref().map(|a| &a[..]), + amax, + }, + &yuv, + op, + ); } PixelData::Yuv16 { planes, chroma, bits, alpha } => { let yuv = Yuv::new(f.color.matrix, f.color.range, *bits); let amax = ((1u32 << *bits) - 1) as f32; - convert_yuv(&mut img, w, h, *chroma, [&planes[0][..], &planes[1][..], &planes[2][..]], alpha.as_deref().map(|a| &a[..]), amax, &yuv, op); + convert_yuv( + &mut img, + YuvSource { + size: (w, h), + chroma: *chroma, + planes: [&planes[0][..], &planes[1][..], &planes[2][..]], + alpha: alpha.as_deref().map(|a| &a[..]), + amax, + }, + &yuv, + op, + ); } } img @@ -280,8 +302,17 @@ fn centred(i: usize, n: usize) -> (usize, usize) { (c, o) } -#[allow(clippy::too_many_arguments)] -fn convert_yuv(img: &mut Image, w: usize, h: usize, chroma: Chroma, planes: [&[T]; 3], alpha: Option<&[T]>, amax: f32, k: &Yuv, op: AlphaOp) { +/// Borrowed YUV planes and the geometry/range needed to interpret their samples. +struct YuvSource<'a, T> { + size: (usize, usize), + chroma: Chroma, + planes: [&'a [T]; 3], + alpha: Option<&'a [T]>, + amax: f32, +} + +fn convert_yuv(img: &mut Image, source: YuvSource<'_, T>, k: &Yuv, op: AlphaOp) { + let YuvSource { size: (w, h), chroma, planes, alpha, amax } = source; let (sx, sy) = chroma.shifts(); let cw = w.div_ceil(1 << sx); let ch = h.div_ceil(1 << sy); diff --git a/crates/opusenc/src/bands.rs b/crates/opusenc/src/bands.rs index 744de3fe3..cf3c590e8 100644 --- a/crates/opusenc/src/bands.rs +++ b/crates/opusenc/src/bands.rs @@ -8,7 +8,8 @@ use crate::range::{BITRES, RangeEncoder, ilog}; use crate::rate::{Mode, QTHETA_OFFSET, get_pulses}; -use crate::tables::*; +use crate::tables::EBANDS; +use crate::{CeltFrame, rate::Allocation}; pub const SPREAD_NORMAL: usize = 2; @@ -55,7 +56,7 @@ fn exp_rotation1_inv(x: &mut [f32], len: usize, stride: usize, c: f32, s: f32) { if len <= stride { return; } - if len >= 2 * stride + 1 { + if len > 2 * stride { for i in 0..=len - 2 * stride - 1 { let x1 = x[i]; let x2 = x[i + stride]; @@ -217,7 +218,7 @@ impl BandEnc<'_, '_> { let q = qn as u32; let half = q >> 1; let ft = (half + 1) * (half + 1); - let (fl, fs) = if it <= half { (it * (it + 1) >> 1, it + 1) } else { (ft - ((q + 1 - it) * (q + 2 - it) >> 1), q + 1 - it) }; + let (fl, fs) = if it <= half { ((it * (it + 1)) >> 1, it + 1) } else { (ft - (((q + 1 - it) * (q + 2 - it)) >> 1), q + 1 - it) }; self.enc.encode(fl, fl + fs, ft); itheta = ((it * 16384) / q) as i32; } @@ -294,26 +295,19 @@ impl BandEnc<'_, '_> { } } -/// Codes all band shapes (`quant_all_bands`, encoder side, long blocks). `x` holds `c` channels -/// of `n` normalised MDCT coefficients each. -#[allow(clippy::too_many_arguments)] -pub fn quant_all_bands( - m: &Mode, - start: usize, - end: usize, - x: &[f32], - n_total: usize, - c: usize, - pulses: &[i32; NB_EBANDS], - spread: usize, - dual_stereo: bool, - intensity: usize, - total_bits: i32, - mut balance: i32, - enc: &mut RangeEncoder, - lm: usize, - coded_bands: usize, -) { +/// Shape-coding controls, independent of the selected band allocation. +pub struct BandCoding { + pub spread: usize, + pub total_bits: i32, +} + +/// Codes all band shapes (`quant_all_bands`, encoder side, long blocks). `x` holds +/// `frame.channels` channels of `n_total` normalised MDCT coefficients each. +pub fn quant_all_bands(m: &Mode, frame: CeltFrame, x: &[f32], n_total: usize, allocation: &Allocation, coding: BandCoding, enc: &mut RangeEncoder) { + let CeltFrame { start, end, channels: c, lm } = frame; + let Allocation { pulses, dual_stereo, intensity, coded_bands, balance, .. } = allocation; + let (dual_stereo, intensity, coded_bands, mut balance) = (*dual_stereo, *intensity, *coded_bands, *balance); + let BandCoding { spread, total_bits } = coding; let mm = 1usize << lm; debug_assert!(c == 1 || dual_stereo || intensity <= start, "stereo frames are coded as dual stereo"); let mut ctx = BandEnc { m, enc, i: 0, spread, remaining_bits: 0, pvq_v: Vec::new(), iy: Vec::new(), tmp: Vec::new() }; @@ -362,7 +356,7 @@ mod tests { x[i + stride] = c * x2 + s * x1; x[i] = c * x1 + ms * x2; } - if len >= 2 * stride + 1 { + if len > 2 * stride { for i in (0..=len - 2 * stride - 1).rev() { let x1 = x[i]; let x2 = x[i + stride]; diff --git a/crates/opusenc/src/energy.rs b/crates/opusenc/src/energy.rs index 0057ae33a..1d4b12719 100644 --- a/crates/opusenc/src/energy.rs +++ b/crates/opusenc/src/energy.rs @@ -4,9 +4,10 @@ //! The quantised energies are updated with exactly the decoder's arithmetic so the prediction //! state never drifts between encoder and decoder. +use crate::CeltFrame; use crate::range::RangeEncoder; -use crate::rate::MAX_FINE_BITS; -use crate::tables::*; +use crate::rate::{Allocation, MAX_FINE_BITS}; +use crate::tables::{BETA_COEF, BETA_INTRA, E_PROB_MODEL, NB_EBANDS, PRED_COEF, SMALL_ENERGY_ICDF}; const LAPLACE_MINP: u32 = 1; const LAPLACE_NMIN: u32 = 16; @@ -56,18 +57,8 @@ pub fn laplace_encode(enc: &mut RangeEncoder, value: i32, mut fs: u32, decay: u3 /// `quant_coarse_energy` for one prediction mode. `e` holds the target log2 energies (relative /// to `E_MEANS`), `old_e` the quantised energies, updated in place. `budget` is the frame size in /// bits. -#[allow(clippy::too_many_arguments)] -pub fn quant_coarse_energy( - start: usize, - end: usize, - e: &[f32; 2 * NB_EBANDS], - old_e: &mut [f32; 2 * NB_EBANDS], - intra: bool, - enc: &mut RangeEncoder, - c: usize, - lm: usize, - budget: i32, -) { +pub fn quant_coarse_energy(frame: CeltFrame, e: &[f32; 2 * NB_EBANDS], old_e: &mut [f32; 2 * NB_EBANDS], intra: bool, enc: &mut RangeEncoder, budget: i32) { + let CeltFrame { start, end, channels: c, lm } = frame; let prob = &E_PROB_MODEL[lm][intra as usize]; let (coef, beta) = if intra { (0.0, BETA_INTRA) } else { (PRED_COEF[lm], BETA_COEF[lm]) }; let mut prev = [0f32; 2]; @@ -111,15 +102,8 @@ pub fn quant_coarse_energy( } /// `quant_fine_energy`: raw bits refining each band's quantised energy. -pub fn quant_fine_energy( - start: usize, - end: usize, - e: &[f32; 2 * NB_EBANDS], - old_e: &mut [f32; 2 * NB_EBANDS], - fine_quant: &[i32; NB_EBANDS], - enc: &mut RangeEncoder, - c: usize, -) { +pub fn quant_fine_energy(frame: CeltFrame, e: &[f32; 2 * NB_EBANDS], old_e: &mut [f32; 2 * NB_EBANDS], fine_quant: &[i32; NB_EBANDS], enc: &mut RangeEncoder) { + let CeltFrame { start, end, channels: c, .. } = frame; for i in start..end { let fq = fine_quant[i]; if fq <= 0 { @@ -139,18 +123,16 @@ pub fn quant_fine_energy( /// `quant_energy_finalise`: spends the bits left at the end of the frame on one more bit of /// energy resolution per band, in priority order. -#[allow(clippy::too_many_arguments)] pub fn quant_energy_finalise( - start: usize, - end: usize, + frame: CeltFrame, e: &[f32; 2 * NB_EBANDS], old_e: &mut [f32; 2 * NB_EBANDS], - fine_quant: &[i32; NB_EBANDS], - fine_priority: &[i32; NB_EBANDS], + allocation: &Allocation, mut bits_left: i32, enc: &mut RangeEncoder, - c: usize, ) { + let CeltFrame { start, end, channels: c, .. } = frame; + let Allocation { fine_quant, fine_priority, .. } = allocation; for prio in 0..2 { let mut i = start; while i < end && bits_left >= c as i32 { diff --git a/crates/opusenc/src/lib.rs b/crates/opusenc/src/lib.rs index 9cc1f6a51..b4346ebd6 100644 --- a/crates/opusenc/src/lib.rs +++ b/crates/opusenc/src/lib.rs @@ -24,8 +24,6 @@ //! ``` #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] -// The bit-allocation code mirrors the normative integer arithmetic; keep C-like shift expressions. -#![allow(clippy::precedence, clippy::int_plus_one)] mod bands; mod energy; @@ -38,14 +36,23 @@ mod tables; use std::sync::OnceLock; -use bands::{SPREAD_NORMAL, quant_all_bands}; +use bands::{BandCoding, SPREAD_NORMAL, quant_all_bands}; use energy::{quant_coarse_energy, quant_energy_finalise, quant_fine_energy}; use mdct::Mdct; use range::{BITRES, RangeEncoder}; -use rate::{Mode as CeltMode, compute_allocation}; +use rate::{AllocationInput, Mode as CeltMode, compute_allocation}; use resample::Downsampler; use silk::SilkEncoder; -use tables::*; +use tables::{E_MEANS, EBANDS, NB_EBANDS, OVERLAP, PREEMPH, SPREAD_ICDF, TF_SELECT_TABLE, TRIM_ICDF}; + +/// Band range, channel count and transform size shared by CELT coding stages. +#[derive(Clone, Copy)] +struct CeltFrame { + start: usize, + end: usize, + channels: usize, + lm: usize, +} /// Samples per channel in one 20 ms frame at 48 kHz. const N: usize = 960; @@ -314,7 +321,7 @@ impl OpusEncoder { Mode::Celt => { let (log_e, silence) = self.celt_analysis(pcm); let len = self.frame_bytes; - packet.extend_from_slice(&self.encode_celt(RangeEncoder::new(), len, 0, NB_EBANDS, &log_e, silence)); + packet.extend_from_slice(&self.encode_celt(RangeEncoder::new(), len, 0..NB_EBANDS, &log_e, silence)); } Mode::Silk => { let x = self.silk_input(pcm); @@ -344,7 +351,7 @@ impl OpusEncoder { } let (log_e, _) = self.celt_analysis(pcm); let end = if self.bandwidth == Bandwidth::SuperWideband { 19 } else { NB_EBANDS }; - packet.extend_from_slice(&self.encode_celt(enc, len, HYBRID_START_BAND, end, &log_e, false)); + packet.extend_from_slice(&self.encode_celt(enc, len, HYBRID_START_BAND..end, &log_e, false)); } } packet @@ -406,10 +413,11 @@ impl OpusEncoder { /// Codes one CELT frame (RFC 6716 §4.3, in bitstream order) of `len` bytes into `enc` /// (which already holds the SILK frame in hybrid mode), coding bands `start..end`, and /// finalises the frame. - #[allow(clippy::too_many_arguments)] - fn encode_celt(&mut self, mut enc: RangeEncoder, len: usize, start: usize, end: usize, log_e: &[f32; 2 * NB_EBANDS], silence: bool) -> Vec { + fn encode_celt(&mut self, mut enc: RangeEncoder, len: usize, bands: std::ops::Range, log_e: &[f32; 2 * NB_EBANDS], silence: bool) -> Vec { let m = celt_mode(); let c = self.channels; + let frame = CeltFrame { start: bands.start, end: bands.end, channels: c, lm: LM }; + let CeltFrame { start, end, .. } = frame; if c == 1 { for i in 0..NB_EBANDS { self.old_band_e[i] = self.old_band_e[i].max(self.old_band_e[NB_EBANDS + i]); @@ -438,10 +446,10 @@ impl OpusEncoder { let mut enc_intra = enc.clone(); let mut old_intra = self.old_band_e; enc_intra.bit_logp(true, 3); - quant_coarse_energy(start, end, log_e, &mut old_intra, true, &mut enc_intra, c, LM, total_bits_raw); + quant_coarse_energy(frame, log_e, &mut old_intra, true, &mut enc_intra, total_bits_raw); enc.bit_logp(false, 3); let mut old_inter = self.old_band_e; - quant_coarse_energy(start, end, log_e, &mut old_inter, false, &mut enc, c, LM, total_bits_raw); + quant_coarse_energy(frame, log_e, &mut old_inter, false, &mut enc, total_bits_raw); if enc_intra.tell_frac() < enc.tell_frac() { enc = enc_intra; self.old_band_e = old_intra; @@ -449,14 +457,14 @@ impl OpusEncoder { self.old_band_e = old_inter; } } else { - quant_coarse_energy(start, end, log_e, &mut self.old_band_e, false, &mut enc, c, LM, total_bits_raw); + quant_coarse_energy(frame, log_e, &mut self.old_band_e, false, &mut enc, total_bits_raw); } // TF resolution: no changes. { let mut budget = total_bits_raw; let mut tell = enc.tell(); let mut logp = 4; - let tf_select_rsv = LM > 0 && tell + logp + 1 <= budget; + let tf_select_rsv = LM > 0 && tell + logp < budget; budget -= tf_select_rsv as i32; for _ in start..end { if tell + logp <= budget { @@ -488,27 +496,11 @@ impl OpusEncoder { enc.icdf(alloc_trim as usize, &TRIM_ICDF, 7); } let bits = (total_bits_raw << BITRES) - enc.tell_frac() - 1; - let alloc = compute_allocation(m, start, end, &offsets, &cap, alloc_trim, bits, c, LM, &mut enc); - quant_fine_energy(start, end, log_e, &mut self.old_band_e, &alloc.fine_quant, &mut enc, c); - quant_all_bands( - m, - start, - end, - &self.freq, - N, - c, - &alloc.pulses, - SPREAD_NORMAL, - alloc.dual_stereo, - alloc.intensity, - total_bits_raw << BITRES, - alloc.balance, - &mut enc, - LM, - alloc.coded_bands, - ); + let alloc = compute_allocation(m, frame, AllocationInput { offsets: &offsets, cap: &cap, trim: alloc_trim, bits }, &mut enc); + quant_fine_energy(frame, log_e, &mut self.old_band_e, &alloc.fine_quant, &mut enc); + quant_all_bands(m, frame, &self.freq, N, &alloc, BandCoding { spread: SPREAD_NORMAL, total_bits: total_bits_raw << BITRES }, &mut enc); let bits_left = total_bits_raw - enc.tell(); - quant_energy_finalise(start, end, log_e, &mut self.old_band_e, &alloc.fine_quant, &alloc.fine_priority, bits_left, &mut enc, c); + quant_energy_finalise(frame, log_e, &mut self.old_band_e, &alloc, bits_left, &mut enc); debug_assert!(enc.tell() <= total_bits_raw, "frame overflow: {} > {}", enc.tell(), total_bits_raw); if c == 1 { let (a, b) = self.old_band_e.split_at_mut(NB_EBANDS); diff --git a/crates/opusenc/src/rate.rs b/crates/opusenc/src/rate.rs index a6ae81f5c..ff6ccae95 100644 --- a/crates/opusenc/src/rate.rs +++ b/crates/opusenc/src/rate.rs @@ -4,8 +4,9 @@ //! spec-derived `filmcraft-opus` decoder (first-party, MIT OR Apache-2.0) step by step; the only //! difference is that the encoder *writes* the band-skip, intensity and dual-stereo symbols. +use crate::CeltFrame; use crate::range::{BITRES, RangeEncoder, ilog}; -use crate::tables::*; +use crate::tables::{ALLOC, EBANDS, LOG2_FRAC_TABLE, MAX_LM, NB_EBANDS}; const MAX_PSEUDO: i32 = 40; const LOG_MAX_PSEUDO: i32 = 6; @@ -127,7 +128,7 @@ impl Mode { let mut cap = [0i32; NB_EBANDS]; for (i, cp) in cap.iter_mut().enumerate() { let n = (EBANDS[i + 1] - EBANDS[i]) << lm; - *cp = (self.caps[NB_EBANDS * (2 * lm + c - 1) + i] as i32 + 64) * c as i32 * n >> 2; + *cp = ((self.caps[NB_EBANDS * (2 * lm + c - 1) + i] as i32 + 64) * c as i32 * n) >> 2; } cap } @@ -200,7 +201,7 @@ fn compute_caps(m: &Mode) -> Vec { let mut n0 = EBANDS[j + 1] - EBANDS[j]; let max_bits = if n0 << i == 1 { - c * (1 + MAX_FINE_BITS) << BITRES + (c * (1 + MAX_FINE_BITS)) << BITRES } else { let mut lm0 = 0; if n0 > 2 { @@ -240,7 +241,7 @@ fn compute_caps(m: &Mode) -> Vec { let num = mb + ndof * offset; let den = (ndof - 1) << BITRES; let qb = ((num + (den >> 1)) / den).min(MAX_FINE_BITS); - mb += c * qb << BITRES; + mb += (c * qb) << BITRES; mb }; let w = c * ((EBANDS[j + 1] - EBANDS[j]) << i); @@ -263,21 +264,19 @@ pub struct Allocation { pub fine_priority: [i32; NB_EBANDS], } +/// Per-band limits, boosts and available budget for the allocation search. +pub struct AllocationInput<'a> { + pub offsets: &'a [i32; NB_EBANDS], + pub cap: &'a [i32; NB_EBANDS], + pub trim: i32, + pub bits: i32, +} + /// `clt_compute_allocation`, encoder side. Stereo frames are always coded as dual (L/R) stereo /// with no intensity bands, and no band is skipped voluntarily. -#[allow(clippy::too_many_arguments)] -pub fn compute_allocation( - m: &Mode, - start: usize, - end: usize, - offsets: &[i32; NB_EBANDS], - cap: &[i32; NB_EBANDS], - alloc_trim: i32, - total: i32, - c: usize, - lm: usize, - enc: &mut RangeEncoder, -) -> Allocation { +pub fn compute_allocation(m: &Mode, frame: CeltFrame, input: AllocationInput<'_>, enc: &mut RangeEncoder) -> Allocation { + let CeltFrame { start, end, channels: c, lm } = frame; + let AllocationInput { offsets, cap, trim: alloc_trim, bits: total } = input; let ci = c as i32; let lmi = lm as i32; let mut total = total.max(0); @@ -302,8 +301,8 @@ pub fn compute_allocation( let mut trim_offset = [0i32; NB_EBANDS]; for j in start..end { let w = EBANDS[j + 1] - EBANDS[j]; - thresh[j] = (ci << BITRES).max((3 * w << lmi << BITRES) >> 4); - trim_offset[j] = ci * w * (alloc_trim - 5 - lmi) * (end as i32 - j as i32 - 1) * (1 << (lmi + BITRES as i32)) >> 6; + thresh[j] = (ci << BITRES).max(((3 * w) << lmi << BITRES) >> 4); + trim_offset[j] = (ci * w * (alloc_trim - 5 - lmi) * (end as i32 - j as i32 - 1) * (1 << (lmi + BITRES as i32))) >> 6; if w << lmi == 1 { trim_offset[j] -= ci << BITRES; } @@ -317,7 +316,7 @@ pub fn compute_allocation( let mid = (lo + hi) >> 1; for j in (start..end).rev() { let n = EBANDS[j + 1] - EBANDS[j]; - let mut bitsj = ci * n * (ALLOC[mid as usize][j] as i32) << lmi >> 2; + let mut bitsj = (ci * n * (ALLOC[mid as usize][j] as i32)) << lmi >> 2; if bitsj > 0 { bitsj = (bitsj + trim_offset[j]).max(0); } @@ -342,8 +341,8 @@ pub fn compute_allocation( lo -= 1; for j in start..end { let n = EBANDS[j + 1] - EBANDS[j]; - let mut bits1j = ci * n * (ALLOC[lo as usize][j] as i32) << lmi >> 2; - let mut bits2j = if hi >= nb_alloc { cap[j] } else { ci * n * (ALLOC[hi as usize][j] as i32) << lmi >> 2 }; + let mut bits1j = (ci * n * (ALLOC[lo as usize][j] as i32)) << lmi >> 2; + let mut bits2j = if hi >= nb_alloc { cap[j] } else { (ci * n * (ALLOC[hi as usize][j] as i32)) << lmi >> 2 }; if bits1j > 0 { bits1j = (bits1j + trim_offset[j]).max(0); } @@ -361,27 +360,27 @@ pub fn compute_allocation( bits1[j] = bits1j; bits2[j] = bits2j; } - interp_bits2pulses(m, start, end, skip_start, &bits1, &bits2, &thresh, cap, total, skip_rsv, intensity_rsv, dual_stereo_rsv, c, lm, enc) + let interpolation = + AllocationInterpolation { skip_start, bits1: &bits1, bits2: &bits2, thresh: &thresh, cap, total, skip_rsv, intensity_rsv, dual_stereo_rsv }; + interp_bits2pulses(m, frame, interpolation, enc) } -#[allow(clippy::too_many_arguments)] -fn interp_bits2pulses( - m: &Mode, - start: usize, - end: usize, +/// Bracketing allocation vectors and signalling reserves for the interpolation stage. +struct AllocationInterpolation<'a> { skip_start: usize, - bits1: &[i32; NB_EBANDS], - bits2: &[i32; NB_EBANDS], - thresh: &[i32; NB_EBANDS], - cap: &[i32; NB_EBANDS], - mut total: i32, + bits1: &'a [i32; NB_EBANDS], + bits2: &'a [i32; NB_EBANDS], + thresh: &'a [i32; NB_EBANDS], + cap: &'a [i32; NB_EBANDS], + total: i32, skip_rsv: i32, - mut intensity_rsv: i32, - mut dual_stereo_rsv: i32, - c: usize, - lm: usize, - enc: &mut RangeEncoder, -) -> Allocation { + intensity_rsv: i32, + dual_stereo_rsv: i32, +} + +fn interp_bits2pulses(m: &Mode, frame: CeltFrame, interpolation: AllocationInterpolation<'_>, enc: &mut RangeEncoder) -> Allocation { + let CeltFrame { start, end, channels: c, lm } = frame; + let AllocationInterpolation { skip_start, bits1, bits2, thresh, cap, mut total, skip_rsv, mut intensity_rsv, mut dual_stereo_rsv } = interpolation; let ci = c as i32; let alloc_floor = ci << BITRES; let stereo = (c > 1) as i32; @@ -393,7 +392,7 @@ fn interp_bits2pulses( let mut psum = 0; let mut done = false; for j in (start..end).rev() { - let tmp = bits1[j] + (mid * bits2[j] >> ALLOC_STEPS); + let tmp = bits1[j] + ((mid * bits2[j]) >> ALLOC_STEPS); if tmp >= thresh[j] || done { done = true; psum += tmp.min(cap[j]); @@ -411,7 +410,7 @@ fn interp_bits2pulses( let mut psum = 0; let mut done = false; for j in (start..end).rev() { - let mut tmp = bits1[j] + (lo * bits2[j] >> ALLOC_STEPS); + let mut tmp = bits1[j] + ((lo * bits2[j]) >> ALLOC_STEPS); if tmp < thresh[j] && !done { tmp = if tmp >= alloc_floor { alloc_floor } else { 0 }; } else { @@ -500,9 +499,9 @@ fn interp_bits2pulses( if n == 2 { offset += (den << BITRES) >> 2; } - if bits[j] + offset < den * 2 << BITRES { + if bits[j] + offset < (den * 2) << BITRES { offset += nclogn >> 2; - } else if bits[j] + offset < den * 3 << BITRES { + } else if bits[j] + offset < (den * 3) << BITRES { offset += nclogn >> 3; } let mut eb = (bits[j] + offset + (den << (BITRES - 1))).max(0); @@ -513,7 +512,7 @@ fn interp_bits2pulses( eb = eb.min(MAX_FINE_BITS); ebits[j] = eb; fine_priority[j] = (eb * (den << BITRES) >= bits[j] + offset) as i32; - bits[j] -= ci * eb << BITRES; + bits[j] -= (ci * eb) << BITRES; } else { excess = (bit - (ci << BITRES)).max(0); bits[j] = bit - excess; @@ -523,7 +522,7 @@ fn interp_bits2pulses( if excess > 0 { let extra_fine = (excess >> (stereo + BITRES as i32)).min(MAX_FINE_BITS - ebits[j]); ebits[j] += extra_fine; - let extra_bits = extra_fine * ci << BITRES; + let extra_bits = (extra_fine * ci) << BITRES; fine_priority[j] = (extra_bits >= excess - balance) as i32; excess -= extra_bits; } diff --git a/crates/opusenc/src/silk.rs b/crates/opusenc/src/silk.rs index c7a4b8692..2678a96a0 100644 --- a/crates/opusenc/src/silk.rs +++ b/crates/opusenc/src/silk.rs @@ -16,8 +16,12 @@ mod pulses; #[allow(dead_code)] pub(crate) mod tables; -use lpc::*; -use tables::*; +use lpc::{MAX_LPC_ORDER, div32_varq, inverse32_varq, nlsf2a, rshift_round, sat16, smlawb, smulwb, smulww}; +use tables::{ + DELTA_GAIN, GAIN_MSB, LTP_FILTER, LTP_PERIODICITY, LTP_SCALE, LTP_TAPS_0, LTP_TAPS_1, LTP_TAPS_2, MID_ONLY, NLSF_EXT, NLSF_INTERP, NLSF_SEL_NBMB, + NLSF_SEL_WB, NLSF_STAGE1, NLSF_STAGE2, PITCH_CONTOUR, PITCH_HIGH, PITCH_LOW, STEREO_PRED_JOINT, STEREO_WEIGHTS_Q13, TYPE_ACTIVE, TYPE_INACTIVE, UNIFORM3, + UNIFORM4, UNIFORM5, UNIFORM8, +}; use crate::range::RangeEncoder; diff --git a/crates/opusenc/src/silk/analysis.rs b/crates/opusenc/src/silk/analysis.rs index de510433e..439331076 100644 --- a/crates/opusenc/src/silk/analysis.rs +++ b/crates/opusenc/src/silk/analysis.rs @@ -1,7 +1,7 @@ //! Encoder-side signal analysis for SILK (own design): windowed autocorrelation LPC, LPC → NLSF //! conversion, open-loop pitch search and LTP codebook search. -use super::tables::*; +use super::tables::{CB_NB_20, CB_WB_20, LTP_FILTER, LTP_PERIODICITY, LTP_TAPS_0, LTP_TAPS_1, LTP_TAPS_2}; /// Short-term prediction coefficients `a` (prediction `x[n] ≈ Σ a[j] x[n-1-j]`) from the /// windowed segment `x`, with a Gaussian lag window and white-noise correction. diff --git a/crates/opusenc/src/silk/lpc.rs b/crates/opusenc/src/silk/lpc.rs index 7846d2973..e0f8c107e 100644 --- a/crates/opusenc/src/silk/lpc.rs +++ b/crates/opusenc/src/silk/lpc.rs @@ -5,7 +5,10 @@ //! spec-derived `filmcraft-opus` decoder (MIT OR Apache-2.0): the encoder must reconstruct the //! quantised NLSFs and LPC coefficients bit-exactly as the decoder does. -use super::tables::*; +use super::tables::{ + COS_Q12, LSF_ORDER_NBMB, LSF_ORDER_WB, NLSF_CB_NBMB, NLSF_CB_WB, NLSF_MIN_SPACING_NBMB, NLSF_MIN_SPACING_WB, NLSF_PRED_NBMB, NLSF_PRED_WB, NLSF_PSEL_NBMB, + NLSF_PSEL_WB, +}; pub const MAX_LPC_ORDER: usize = 16; diff --git a/crates/opusenc/src/silk/nlsf.rs b/crates/opusenc/src/silk/nlsf.rs index 3f1f17ac6..4695152ff 100644 --- a/crates/opusenc/src/silk/nlsf.rs +++ b/crates/opusenc/src/silk/nlsf.rs @@ -3,7 +3,9 @@ //! dequantisation exactly. The search strategy is our own. use super::lpc::{MAX_LPC_ORDER, nlsf_decode, smlawb, sqrt_approx}; -use super::tables::*; +use super::tables::{ + NLSF_CB_NBMB, NLSF_CB_WB, NLSF_EXT, NLSF_PRED_NBMB, NLSF_PRED_WB, NLSF_PSEL_NBMB, NLSF_PSEL_WB, NLSF_SEL_NBMB, NLSF_SEL_WB, NLSF_STAGE1, NLSF_STAGE2, +}; /// Number of stage-1 candidates refined with stage 2. const SURVIVORS: usize = 8; diff --git a/crates/opusenc/src/silk/pulses.rs b/crates/opusenc/src/silk/pulses.rs index f8cda15a9..ba22b5ec5 100644 --- a/crates/opusenc/src/silk/pulses.rs +++ b/crates/opusenc/src/silk/pulses.rs @@ -2,7 +2,7 @@ //! with LSB escapes, the recursive shell code, LSBs and signs, written in exactly the order the //! decoder reads them. -use super::tables::*; +use super::tables::{EXC_LSB, EXC_SIGN, PULSE_COUNT, RATE_LEVEL, SHELL_2, SHELL_4, SHELL_8, SHELL_16}; use crate::range::RangeEncoder; /// Largest pulse magnitude the quantiser produces (keeps every 16-sample block codable). diff --git a/crates/path/src/lib.rs b/crates/path/src/lib.rs index 5069cb83e..c52b3d262 100644 --- a/crates/path/src/lib.rs +++ b/crates/path/src/lib.rs @@ -144,7 +144,20 @@ pub fn ellipse(size: [f64; 2], pos: [f64; 2]) -> BezPath { } /// Polystar: a star (`inner` radius used) or polygon, rotation in degrees, roundness in percent. -pub fn polystar(star: bool, points: f64, pos: [f64; 2], rotation: f64, inner: f64, outer: f64, inner_round: f64, outer_round: f64) -> BezPath { +pub struct Polystar { + pub star: bool, + pub points: f64, + pub pos: [f64; 2], + pub rotation: f64, + pub inner: f64, + pub outer: f64, + pub inner_round: f64, + pub outer_round: f64, +} + +/// Build the path described by the polystar's geometry. +pub fn polystar(spec: Polystar) -> BezPath { + let Polystar { star, points, pos, rotation, inner, outer, inner_round, outer_round } = spec; let n = points.max(3.0).round() as usize; let verts = if star { n * 2 } else { n }; let mut path = BezPath::new(); @@ -398,7 +411,7 @@ mod tests { #[test] fn star_has_right_vertex_count() { - let p = polystar(true, 5.0, [0.0, 0.0], 0.0, 20.0, 50.0, 0.0, 0.0); + let p = polystar(Polystar { star: true, points: 5.0, pos: [0.0, 0.0], rotation: 0.0, inner: 20.0, outer: 50.0, inner_round: 0.0, outer_round: 0.0 }); let v = from_kurbo(&p); assert_eq!(v[0].vertices.len(), 10); } diff --git a/crates/pdf/src/font.rs b/crates/pdf/src/font.rs index 2c27838a2..64b0a1c3a 100644 --- a/crates/pdf/src/font.rs +++ b/crates/pdf/src/font.rs @@ -89,7 +89,7 @@ impl OutlinePen for Pen { /// A bundled stand-in for a font that is not embedded, by its PostScript name. fn fallback(base: &str) -> &'static [u8] { - use effectcraft_text::fonts::*; + use effectcraft_text::fonts::{INTER_BOLD, INTER_ITALIC, INTER_REGULAR, INTER_SEMIBOLD, JETBRAINS_MONO_REGULAR, NOTO_SERIF_REGULAR}; let b = base.to_ascii_lowercase(); if b.contains("courier") || b.contains("mono") || b.contains("consol") { return JETBRAINS_MONO_REGULAR; @@ -393,89 +393,10 @@ impl Font { } // Widths. if f.composite { - f.default_width = file.get_num(&desc_dict, "DW").unwrap_or(1000.0) / 1000.0; - if let Some(w) = file.get(&desc_dict, "W").and_then(Obj::array) { - let mut i = 0; - while i < w.len() { - let first = file.resolve(&w[i]).num().unwrap_or(0.0) as u32; - match w.get(i + 1).map(|x| file.resolve(x)) { - Some(Obj::Array(list)) => { - for (k, v) in list.iter().enumerate() { - if let Some(cid) = first.checked_add(k as u32) { - f.cid_widths.insert(cid, file.resolve(v).num().unwrap_or(0.0) / 1000.0); - } - } - i += 2; - } - Some(Obj::Num(last)) => { - let wv = w.get(i + 2).and_then(|x| file.resolve(x).num()).unwrap_or(0.0) / 1000.0; - for c in first..=(*last as u32).min(first.saturating_add(65535)) { - f.cid_widths.insert(c, wv); - } - i += 3; - } - _ => break, - } - } - } - // Vertical metrics (§9.7.4.3): /DW2 [vy w1y] and /W2 entries - // `c [w1y vx vy …]` or `cfirst clast w1y vx vy`. - let dw2 = file.get(&desc_dict, "DW2").map(|x| file.nums(x)).unwrap_or_default(); - if dw2.len() == 2 { - f.dw2 = (dw2[0] / 1000.0, dw2[1] / 1000.0); - } - if let Some(w) = file.get(&desc_dict, "W2").and_then(Obj::array) { - let num = |i: usize| w.get(i).and_then(|x| file.resolve(x).num()); - let mut i = 0; - while i < w.len() { - let Some(first) = num(i) else { break }; - let first = first.max(0.0) as u32; - match w.get(i + 1).map(|x| file.resolve(x)) { - Some(Obj::Array(list)) => { - let v: Vec = list.iter().filter_map(|x| file.resolve(x).num()).collect(); - for (k, t) in v.as_chunks::<3>().0.iter().enumerate() { - if let Some(cid) = first.checked_add(k as u32) { - f.cid_vmetrics.insert(cid, (t[0] / 1000.0, t[1] / 1000.0, t[2] / 1000.0)); - } - } - i += 2; - } - Some(Obj::Num(last)) => { - let (Some(w1), Some(vx), Some(vy)) = (num(i + 2), num(i + 3), num(i + 4)) else { break }; - for c in first..=(last.max(0.0) as u32).min(first.saturating_add(65535)) { - f.cid_vmetrics.insert(c, (w1 / 1000.0, vx / 1000.0, vy / 1000.0)); - } - i += 5; - } - _ => break, - } - } - } + f.load_cid_widths(file, &desc_dict); + f.load_vertical_metrics(file, &desc_dict); f.has_widths = true; - // Encoding CMap. - match file.get(d, "Encoding") { - Some(Obj::Name(n)) => { - f.vertical = n.ends_with("-V"); - if !n.starts_with("Identity") { - skipped.push(format!("CMap {n}")); - } - } - Some(Obj::Stream(sd, raw)) => { - if let Some(data) = decode_stream(file, sd, raw) { - let cm = parse_cmap(&data); - f.vertical = cm.wmode || file.get_num(sd, "WMode") == Some(1.0); - f.codespace = cm.codespace; - f.cid_ranges = cm.cids; - } - } - _ => {} - } - match file.get(&desc_dict, "CIDToGIDMap") { - Some(Obj::Stream(sd, raw)) => { - f.cid_to_gid = decode_stream(file, sd, raw).map(|b| b.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]])).collect()); - } - _ => f.cid_to_gid = None, - } + f.load_cid_encoding(file, d, &desc_dict, skipped); } else { f.first_char = file.get_num(d, "FirstChar").unwrap_or(0.0) as u32; if let Some(w) = file.get(d, "Widths") { @@ -486,16 +407,113 @@ impl Font { f.default_width = file.get_num(fd, "MissingWidth").unwrap_or(0.0); } } + f.load_program(file, d, fd.as_ref(), &subtype, skipped); + f.load_simple_encoding(file, d); + f + } + + fn load_cid_widths(&mut self, file: &File, desc_dict: &Dict) { + self.default_width = file.get_num(desc_dict, "DW").unwrap_or(1000.0) / 1000.0; + if let Some(w) = file.get(desc_dict, "W").and_then(Obj::array) { + let mut i = 0; + while i < w.len() { + let first = file.resolve(&w[i]).num().unwrap_or(0.0) as u32; + match w.get(i + 1).map(|x| file.resolve(x)) { + Some(Obj::Array(list)) => { + for (k, v) in list.iter().enumerate() { + if let Some(cid) = first.checked_add(k as u32) { + self.cid_widths.insert(cid, file.resolve(v).num().unwrap_or(0.0) / 1000.0); + } + } + i += 2; + } + Some(Obj::Num(last)) => { + let wv = w.get(i + 2).and_then(|x| file.resolve(x).num()).unwrap_or(0.0) / 1000.0; + for c in first..=(*last as u32).min(first.saturating_add(65535)) { + self.cid_widths.insert(c, wv); + } + i += 3; + } + _ => break, + } + } + } + } + + fn load_vertical_metrics(&mut self, file: &File, desc_dict: &Dict) { + // Vertical metrics (§9.7.4.3): /DW2 [vy w1y] and /W2 entries + // `c [w1y vx vy …]` or `cfirst clast w1y vx vy`. + let dw2 = file.get(desc_dict, "DW2").map(|x| file.nums(x)).unwrap_or_default(); + if dw2.len() == 2 { + self.dw2 = (dw2[0] / 1000.0, dw2[1] / 1000.0); + } + if let Some(w) = file.get(desc_dict, "W2").and_then(Obj::array) { + let num = |i: usize| w.get(i).and_then(|x| file.resolve(x).num()); + let mut i = 0; + while i < w.len() { + let Some(first) = num(i) else { break }; + let first = first.max(0.0) as u32; + match w.get(i + 1).map(|x| file.resolve(x)) { + Some(Obj::Array(list)) => { + let v: Vec = list.iter().filter_map(|x| file.resolve(x).num()).collect(); + for (k, t) in v.as_chunks::<3>().0.iter().enumerate() { + if let Some(cid) = first.checked_add(k as u32) { + self.cid_vmetrics.insert(cid, (t[0] / 1000.0, t[1] / 1000.0, t[2] / 1000.0)); + } + } + i += 2; + } + Some(Obj::Num(last)) => { + let (Some(w1), Some(vx), Some(vy)) = (num(i + 2), num(i + 3), num(i + 4)) else { break }; + for c in first..=(last.max(0.0) as u32).min(first.saturating_add(65535)) { + self.cid_vmetrics.insert(c, (w1 / 1000.0, vx / 1000.0, vy / 1000.0)); + } + i += 5; + } + _ => break, + } + } + } + } + + fn load_cid_encoding(&mut self, file: &File, d: &Dict, desc_dict: &Dict, skipped: &mut Vec) { + // Encoding CMap. + match file.get(d, "Encoding") { + Some(Obj::Name(n)) => { + self.vertical = n.ends_with("-V"); + if !n.starts_with("Identity") { + skipped.push(format!("CMap {n}")); + } + } + Some(Obj::Stream(sd, raw)) => { + if let Some(data) = decode_stream(file, sd, raw) { + let cm = parse_cmap(&data); + self.vertical = cm.wmode || file.get_num(sd, "WMode") == Some(1.0); + self.codespace = cm.codespace; + self.cid_ranges = cm.cids; + } + } + _ => {} + } + match file.get(desc_dict, "CIDToGIDMap") { + Some(Obj::Stream(sd, raw)) => { + self.cid_to_gid = decode_stream(file, sd, raw).map(|b| b.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]])).collect()); + } + _ => self.cid_to_gid = None, + } + } + + fn load_program(&mut self, file: &File, d: &Dict, fd: Option<&Dict>, subtype: &str, skipped: &mut Vec) { // The program. let font_file = |key: &str| -> Option<(Dict, Vec)> { - let fd = fd.as_ref()?; + let fd = fd?; match file.get(fd, key) { Some(Obj::Stream(sd, raw)) => Some((sd.clone(), decode_stream(file, sd, raw)?)), _ => None, } }; if subtype == "Type3" { - f.matrix = file + self.matrix = file .get(d, "FontMatrix") .map(|m| file.nums(m)) .filter(|m| m.len() == 6) @@ -511,76 +529,78 @@ impl Font { } } } - f.program = Program::Type3 { procs, resources: file.get_dict(d, "Resources").cloned() }; + self.program = Program::Type3 { procs, resources: file.get_dict(d, "Resources").cloned() }; // Type 3 widths are in glyph space. - let m = f.matrix.as_coeffs()[0]; - for w in &mut f.widths { + let m = self.matrix.as_coeffs()[0]; + for w in &mut self.widths { *w *= m * 1000.0; } } else if let Some((_, data)) = font_file("FontFile2") { - f.program = Program::TrueType(Arc::new(data)); + self.program = Program::TrueType(Arc::new(data)); } else if let Some((sd, data)) = font_file("FontFile3") { match file.get(&sd, "Subtype").and_then(Obj::name) { Some("OpenType") => { // An OpenType wrapper: TrueType outlines through skrifa, CFF through ours. let cff = FontRef::new(&data).ok().and_then(|fr| fr.table_data(skrifa::Tag::new(b"CFF ")).map(|t| t.as_bytes().to_vec())); match cff.and_then(|c| Cff::parse(&c)) { - Some(c) => f.program = Program::Cff(Box::new(c)), - None => f.program = Program::TrueType(Arc::new(data)), + Some(c) => self.program = Program::Cff(Box::new(c)), + None => self.program = Program::TrueType(Arc::new(data)), } } _ => match Cff::parse(&data) { - Some(c) => f.program = Program::Cff(Box::new(c)), + Some(c) => self.program = Program::Cff(Box::new(c)), None => skipped.push("font program (CFF)".into()), }, } } else if let Some((_, data)) = font_file("FontFile") { match Type1::parse(&data) { - Some(t) => f.program = Program::Type1(Box::new(t)), + Some(t) => self.program = Program::Type1(Box::new(t)), None => skipped.push("font program (Type 1)".into()), } } else { - f.program = Program::Fallback(fallback(&base_name)); + self.program = Program::Fallback(fallback(&self.base_name)); } - if let Program::Cff(c) = &f.program { + if let Program::Cff(c) = &self.program { let m = c.font_matrix; - f.matrix = Affine::new(m); + self.matrix = Affine::new(m); } - if let Program::Type1(t) = &f.program + if let Program::Type1(t) = &self.program && let Some(m) = t.font_matrix { - f.matrix = Affine::new(m); + self.matrix = Affine::new(m); } + } + + fn load_simple_encoding(&mut self, file: &File, d: &Dict) { // Simple-font encoding: base encoding, built-in encoding, Differences. - if !f.composite { + if !self.composite { let enc = file.get(d, "Encoding").map(|e| file.resolve(e)); let base = match enc { Some(Obj::Name(n)) => Base::from_name(n), Some(Obj::Dict(e)) => file.get(e, "BaseEncoding").and_then(Obj::name).and_then(Base::from_name), _ => None, }; - let builtin: Option>> = match &f.program { + let builtin: Option>> = match &self.program { Program::Type1(t) if !t.encoding.is_empty() => Some((0..256).map(|c| t.encoding.get(&(c as u8)).cloned()).collect()), _ => None, }; - let fallback_base = if f.symbolic { None } else { Some(Base::Standard) }; + let fallback_base = if self.symbolic { None } else { Some(Base::Standard) }; for c in 0..256usize { - f.names[c] = match (base, &builtin) { + self.names[c] = match (base, &builtin) { (Some(b), _) => b.name(c as u8), (None, Some(bi)) => bi[c].clone(), - (None, None) => match &f.program { + (None, None) => match &self.program { // TrueType symbolic fonts map codes directly; others use Standard. - Program::TrueType(_) if f.symbolic => None, - Program::Fallback(_) if base_name.contains("Symbol") || base_name.contains("Dingbats") => None, + Program::TrueType(_) if self.symbolic => None, + Program::Fallback(_) if self.base_name.contains("Symbol") || self.base_name.contains("Dingbats") => None, _ => fallback_base.or(Some(Base::Standard)).and_then(|b| b.name(c as u8)), }, }; } if let Some(Obj::Dict(e)) = enc { - differences(file, e, &mut f.names); + differences(file, e, &mut self.names); } } - f } /// Split a string into (code, byte length) pairs. diff --git a/crates/pdf/src/ps/builtin.rs b/crates/pdf/src/ps/builtin.rs index 3d998d620..dcafa9417 100644 --- a/crates/pdf/src/ps/builtin.rs +++ b/crates/pdf/src/ps/builtin.rs @@ -2,10 +2,166 @@ use super::eps::find_from; use super::scanner::Src; use super::text::encoding_array; use super::value::{Flow, V, array, matrix_of, matrix_val, new_dict}; -use super::*; +use super::{Affine, BezPath, Cap, FillRule, Interp, Join, PathEl, Point, Rc, RefCell, parse_num}; impl Interp { pub(super) fn builtin(&mut self, n: &str, src: &mut Src) -> Result<(), Flow> { + // Every handler leaves the stacks untouched when the operator is outside its family. + if self.try_stack_op(n) + || self.try_arithmetic_op(n) + || self.try_logic_op(n) + || self.try_control_op(n, src)? + || self.try_dictionary_op(n) + || self.try_collection_op(n) + || self.try_environment_op(n, src)? + || self.try_graphics_op(n)? + || self.try_coordinate_op(n) + || self.try_path_op(n)? + || self.try_paint_op(n)? + { + return Ok(()); + } + match n { + // Text (see `text_op`). + "scalefont" | "makefont" | "selectfont" | "setfont" | "show" | "charpath" | "glyphshow" | "rootfont" | "currentfont" | "setcachedevice" + | "setcharwidth" | "ashow" | "widthshow" | "awidthshow" | "xshow" | "yshow" | "xyshow" | "kshow" | "cshow" | "stringwidth" | "definefont" + | "StandardEncoding" | "ISOLatin1Encoding" | "eexec" | "StartData" | "undefinefont" => self.text_op(n, src)?, + // Images: operands popped, inline data skipped. + "image" | "imagemask" | "colorimage" => { + let mut reads_file = false; + let check = |v: &V, rf: &mut bool| { + if matches!(v, V::File) || matches!(v, V::Proc(p) if p.iter().any(|x| matches!(x, V::Name(n) if &**n == "currentfile"))) { + *rf = true; + } + }; + if n == "colorimage" { + let ncomp = self.popn().max(1.0) as usize; + let multi = matches!(self.pop(), V::Bool(true)); + for _ in 0..if multi { ncomp.min(4) } else { 1 } { + let v = self.pop(); + check(&v, &mut reads_file); + } + for _ in 0..4 { + self.pop(); + } + } else { + match self.pop() { + V::Dict(d) => { + if let Some(ds) = d.borrow().get("DataSource") { + check(ds, &mut reads_file); + } + } + top => { + check(&top, &mut reads_file); + for _ in 0..4 { + self.pop(); + } + } + } + } + if reads_file { + src.skip_image_data(); + } + self.b.skip("image"); + } + _ => { + if self.unknown.len() < 32 && !self.unknown.iter().any(|u| u == n) { + self.unknown.push(n.to_string()); + } + } + } + Ok(()) + } + + fn repeat_proc(&mut self, src: &mut Src) -> Result<(), Flow> { + let p = self.pop(); + let k = self.popn().clamp(0.0, 1e6) as usize; + for _ in 0..k { + self.spend(1)?; + match self.exec_obj(p.clone(), src) { + Err(Flow::Exit) => break, + r => r?, + } + } + + Ok(()) + } + + fn for_proc(&mut self, src: &mut Src) -> Result<(), Flow> { + let p = self.pop(); + let lim = self.popn(); + let inc = self.popn(); + let mut i = self.popn(); + let mut guard = 0; + while inc != 0.0 && ((inc > 0.0 && i <= lim) || (inc < 0.0 && i >= lim)) && guard < 1_000_000 { + self.spend(1)?; + self.push(V::Num(i)); + match self.exec_obj(p.clone(), src) { + Err(Flow::Exit) => break, + r => r?, + } + i += inc; + guard += 1; + } + + Ok(()) + } + + fn loop_proc(&mut self, src: &mut Src) -> Result<(), Flow> { + let p = self.pop(); + for _ in 0..1_000_000 { + self.spend(1)?; + match self.exec_obj(p.clone(), src) { + Err(Flow::Exit) => break, + r => r?, + } + } + + Ok(()) + } + + fn forall_proc(&mut self, src: &mut Src) -> Result<(), Flow> { + let p = self.pop(); + match self.pop() { + V::Dict(d) => { + let items: Vec<(String, V)> = d.borrow().iter().map(|(k, v)| (k.clone(), v.clone())).collect(); + for (k, v) in items { + self.spend(1)?; + self.push(V::Lit(k.into())); + self.push(v); + match self.exec_obj(p.clone(), src) { + Err(Flow::Exit) => break, + r => r?, + } + } + } + V::Str(s) => { + let bytes = s.borrow().clone(); + for c in bytes { + self.spend(1)?; + self.push(V::Num(c as f64)); + match self.exec_obj(p.clone(), src) { + Err(Flow::Exit) => break, + r => r?, + } + } + } + a => { + for v in a.items().unwrap_or_default() { + self.spend(1)?; + self.push(v); + match self.exec_obj(p.clone(), src) { + Err(Flow::Exit) => break, + r => r?, + } + } + } + } + + Ok(()) + } + + fn try_stack_op(&mut self, n: &str) -> bool { match n { // Stack. "pop" => { @@ -21,25 +177,27 @@ impl Interp { let a = self.st.last().cloned().unwrap_or(V::Null); self.push(a); } - "copy" => match self.pop() { - V::Num(k) => { - let k = (k.max(0.0) as usize).min(self.st.len()); - let tail: Vec = self.st[self.st.len() - k..].to_vec(); - self.st.extend(tail); - } - V::Array(dst) => { - let s = self.pop().items().unwrap_or_default(); - let mut d = dst.borrow_mut(); - for (i, v) in s.into_iter().enumerate() { - if i < d.len() { - d[i] = v; - } + "copy" => { + match self.pop() { + V::Num(k) => { + let k = (k.max(0.0) as usize).min(self.st.len()); + let tail: Vec = self.st[self.st.len() - k..].to_vec(); + self.st.extend(tail); } - drop(d); - self.push(V::Array(dst)); - } - other => self.push(other), - }, + V::Array(dst) => { + let s = self.pop().items().unwrap_or_default(); + let mut d = dst.borrow_mut(); + for (i, v) in s.into_iter().enumerate() { + if i < d.len() { + d[i] = v; + } + } + drop(d); + self.push(V::Array(dst)); + } + other => self.push(other), + }; + } "index" => { let k = self.popn().max(0.0) as usize; let v = if k < self.st.len() { self.st[self.st.len() - 1 - k].clone() } else { V::Null }; @@ -84,6 +242,13 @@ impl Interp { } self.push(V::Dict(d)); } + _ => return false, + } + true + } + + fn try_arithmetic_op(&mut self, n: &str) -> bool { + match n { // Arithmetic. "add" | "sub" | "mul" | "div" | "idiv" | "mod" | "atan" | "exp" | "max" | "min" => { let b = self.popn(); @@ -131,6 +296,13 @@ impl Interp { }; self.push(V::Num(if v.is_finite() { v } else { 0.0 })); } + _ => return false, + } + true + } + + fn try_logic_op(&mut self, n: &str) -> bool { + match n { // Comparison and logic. "eq" | "ne" => { let b = self.pop(); @@ -181,6 +353,13 @@ impl Interp { "true" => self.push(V::Bool(true)), "false" => self.push(V::Bool(false)), "null" => self.push(V::Null), + _ => return false, + } + true + } + + fn try_control_op(&mut self, n: &str, src: &mut Src) -> Result { + match n { // Control. "exec" => { let v = self.pop(); @@ -199,82 +378,10 @@ impl Interp { self.exec_obj(if c { t } else { f }, src)?; } // Each pass of a loop counts against the budget. - "repeat" => { - let p = self.pop(); - let k = self.popn().clamp(0.0, 1e6) as usize; - for _ in 0..k { - self.spend(1)?; - match self.exec_obj(p.clone(), src) { - Err(Flow::Exit) => break, - r => r?, - } - } - } - "for" => { - let p = self.pop(); - let lim = self.popn(); - let inc = self.popn(); - let mut i = self.popn(); - let mut guard = 0; - while inc != 0.0 && ((inc > 0.0 && i <= lim) || (inc < 0.0 && i >= lim)) && guard < 1_000_000 { - self.spend(1)?; - self.push(V::Num(i)); - match self.exec_obj(p.clone(), src) { - Err(Flow::Exit) => break, - r => r?, - } - i += inc; - guard += 1; - } - } - "loop" => { - let p = self.pop(); - for _ in 0..1_000_000 { - self.spend(1)?; - match self.exec_obj(p.clone(), src) { - Err(Flow::Exit) => break, - r => r?, - } - } - } - "forall" => { - let p = self.pop(); - match self.pop() { - V::Dict(d) => { - let items: Vec<(String, V)> = d.borrow().iter().map(|(k, v)| (k.clone(), v.clone())).collect(); - for (k, v) in items { - self.spend(1)?; - self.push(V::Lit(k.into())); - self.push(v); - match self.exec_obj(p.clone(), src) { - Err(Flow::Exit) => break, - r => r?, - } - } - } - V::Str(s) => { - let bytes = s.borrow().clone(); - for c in bytes { - self.spend(1)?; - self.push(V::Num(c as f64)); - match self.exec_obj(p.clone(), src) { - Err(Flow::Exit) => break, - r => r?, - } - } - } - a => { - for v in a.items().unwrap_or_default() { - self.spend(1)?; - self.push(v); - match self.exec_obj(p.clone(), src) { - Err(Flow::Exit) => break, - r => r?, - } - } - } - } - } + "repeat" => self.repeat_proc(src)?, + "for" => self.for_proc(src)?, + "loop" => self.loop_proc(src)?, + "forall" => self.forall_proc(src)?, "exit" => return Err(Flow::Exit), "stop" => return Err(Flow::Stop), "stopped" => { @@ -288,6 +395,13 @@ impl Interp { } "quit" => return Err(Flow::Quit), "showpage" | "copypage" => {} + _ => return Ok(false), + } + Ok(true) + } + + fn try_dictionary_op(&mut self, n: &str) -> bool { + match n { // Dictionaries. "def" => { let v = self.pop(); @@ -385,6 +499,13 @@ impl Interp { self.pop(); self.push(V::Bool(false)); } + _ => return false, + } + true + } + + fn try_collection_op(&mut self, n: &str) -> bool { + match n { // Arrays and strings. "array" => { let k = self.popn().clamp(0.0, 1e6) as usize; @@ -521,6 +642,13 @@ impl Interp { self.push(V::Name(t.into())); } "bind" | "readonly" | "executeonly" | "noaccess" => {} + _ => return false, + } + true + } + + fn try_environment_op(&mut self, n: &str, src: &mut Src) -> Result { + match n { "languagelevel" => self.push(V::Num(3.0)), "version" | "product" => self.push(V::Str(Rc::new(RefCell::new(b"3010".to_vec())))), "save" => { @@ -644,6 +772,13 @@ impl Interp { self.push(V::Num(0.0)); } } + _ => return Ok(false), + } + Ok(true) + } + + fn try_graphics_op(&mut self, n: &str) -> Result { + match n { // Graphics state. "gsave" => { self.spend(self.gs.path.elements().len())?; @@ -733,6 +868,13 @@ impl Interp { self.push(V::Num(c)); } } + _ => return Ok(false), + } + Ok(true) + } + + fn try_coordinate_op(&mut self, n: &str) -> bool { + match n { // Coordinates. "matrix" => self.push(matrix_val(Affine::IDENTITY)), "identmatrix" | "defaultmatrix" => { @@ -805,6 +947,13 @@ impl Interp { self.push(V::Num(p.x)); self.push(V::Num(p.y)); } + _ => return false, + } + true + } + + fn try_path_op(&mut self, n: &str) -> Result { + match n { // Paths. "newpath" => { self.gs.path = BezPath::new(); @@ -869,6 +1018,13 @@ impl Interp { let p = self.page; self.gs.path = kurbo::Shape::to_path(&kurbo::Rect::new(p[0], p[1], p[2], p[3]), 0.1); } + _ => return Ok(false), + } + Ok(true) + } + + fn try_paint_op(&mut self, n: &str) -> Result { + match n { // Painting. "fill" => self.paint(Some(FillRule::NonZero), false), "eofill" => self.paint(Some(FillRule::EvenOdd), false), @@ -903,54 +1059,8 @@ impl Interp { self.pop(); self.b.skip("smooth shading (shfill)"); } - // Text (see `text_op`). - "scalefont" | "makefont" | "selectfont" | "setfont" | "show" | "charpath" | "glyphshow" | "rootfont" | "currentfont" | "setcachedevice" - | "setcharwidth" | "ashow" | "widthshow" | "awidthshow" | "xshow" | "yshow" | "xyshow" | "kshow" | "cshow" | "stringwidth" | "definefont" - | "StandardEncoding" | "ISOLatin1Encoding" | "eexec" | "StartData" | "undefinefont" => self.text_op(n, src)?, - // Images: operands popped, inline data skipped. - "image" | "imagemask" | "colorimage" => { - let mut reads_file = false; - let check = |v: &V, rf: &mut bool| { - if matches!(v, V::File) || matches!(v, V::Proc(p) if p.iter().any(|x| matches!(x, V::Name(n) if &**n == "currentfile"))) { - *rf = true; - } - }; - if n == "colorimage" { - let ncomp = self.popn().max(1.0) as usize; - let multi = matches!(self.pop(), V::Bool(true)); - for _ in 0..if multi { ncomp.min(4) } else { 1 } { - let v = self.pop(); - check(&v, &mut reads_file); - } - for _ in 0..4 { - self.pop(); - } - } else { - match self.pop() { - V::Dict(d) => { - if let Some(ds) = d.borrow().get("DataSource") { - check(ds, &mut reads_file); - } - } - top => { - check(&top, &mut reads_file); - for _ in 0..4 { - self.pop(); - } - } - } - } - if reads_file { - src.skip_image_data(); - } - self.b.skip("image"); - } - _ => { - if self.unknown.len() < 32 && !self.unknown.iter().any(|u| u == n) { - self.unknown.push(n.to_string()); - } - } + _ => return Ok(false), } - Ok(()) + Ok(true) } } diff --git a/crates/pdf/src/ps/eps.rs b/crates/pdf/src/ps/eps.rs index 22391567c..90d60cfbe 100644 --- a/crates/pdf/src/ps/eps.rs +++ b/crates/pdf/src/ps/eps.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{HashMap, Rc, is_white}; /// The PostScript section of an EPS file (DOS EPS binary header handled) and its bounding box /// (`%%HiResBoundingBox` preferred). diff --git a/crates/pdf/src/ps/exec.rs b/crates/pdf/src/ps/exec.rs index dd7f6a579..27697527c 100644 --- a/crates/pdf/src/ps/exec.rs +++ b/crates/pdf/src/ps/exec.rs @@ -1,7 +1,7 @@ use super::eps::embedded_fonts; use super::scanner::Src; use super::value::{Flow, GState, V, new_dict}; -use super::*; +use super::{Affine, BezPath, Builder, Cap, HashMap, Interp, Join}; /// The deepest procedure nesting run (deeper recursion stops the program). const MAX_CALL_DEPTH: usize = 256; diff --git a/crates/pdf/src/ps/paths.rs b/crates/pdf/src/ps/paths.rs index 20af48b9d..b1fe07092 100644 --- a/crates/pdf/src/ps/paths.rs +++ b/crates/pdf/src/ps/paths.rs @@ -1,5 +1,5 @@ use super::value::{Flow, V}; -use super::*; +use super::{Affine, BezPath, FillRule, Interp, MAX_PATH, Paint, PathEl, Point, Stroke}; /// The angle from `a` counterclockwise to `b` (degrees, 0 to 360), worked out from what is left /// of each after whole turns. diff --git a/crates/pdf/src/ps/scanner.rs b/crates/pdf/src/ps/scanner.rs index e407fba55..59933e214 100644 --- a/crates/pdf/src/ps/scanner.rs +++ b/crates/pdf/src/ps/scanner.rs @@ -1,5 +1,5 @@ use super::value::V; -use super::*; +use super::{Rc, RefCell, ascii85, is_white, parse_num}; /// Source tokenizer (the data source for `currentfile`). pub(super) struct Src<'a> { diff --git a/crates/pdf/src/ps/text.rs b/crates/pdf/src/ps/text.rs index 06fdddb4e..b138965bb 100644 --- a/crates/pdf/src/ps/text.rs +++ b/crates/pdf/src/ps/text.rs @@ -1,6 +1,6 @@ use super::scanner::Src; use super::value::{DictRef, Flow, V, array, matrix_of, matrix_val, new_dict}; -use super::*; +use super::{Affine, BezPath, FillRule, Font, HashMap, Interp, Paint, Point, Rc, RefCell}; // ------------------------------------------------------------------ text diff --git a/crates/pdf/src/ps/value.rs b/crates/pdf/src/ps/value.rs index 31754a223..3cfafed6e 100644 --- a/crates/pdf/src/ps/value.rs +++ b/crates/pdf/src/ps/value.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Affine, BezPath, Cap, HashMap, Join, Point, Rc, RefCell}; pub(crate) type DictRef = Rc>>; diff --git a/crates/project/src/comp.rs b/crates/project/src/comp.rs index 2ff97cd37..5a3c8650e 100644 --- a/crates/project/src/comp.rs +++ b/crates/project/src/comp.rs @@ -1,7 +1,7 @@ use super::item::LayerId; use super::layer::{Layer, LayerSource}; use super::settings::{Marker, yes}; -use super::*; +use super::{Deserialize, FrameRate, Node, PropGroup, Serialize, Tick, Value, essential, styles}; /// 3D renderer of a composition. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/project/src/footage.rs b/crates/project/src/footage.rs index ff1983d88..391b1e17c 100644 --- a/crates/project/src/footage.rs +++ b/crates/project/src/footage.rs @@ -1,5 +1,5 @@ use super::settings::yes; -use super::*; +use super::{ColorSpace, Deserialize, FrameRate, Serialize, Tick}; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum FootageKind { diff --git a/crates/project/src/item.rs b/crates/project/src/item.rs index d9d5184c9..3f53bc823 100644 --- a/crates/project/src/item.rs +++ b/crates/project/src/item.rs @@ -2,7 +2,7 @@ use super::comp::Comp; use super::footage::{Footage, FootageKind, Proxy, Solid}; use super::layer::LayerSource; use super::settings::{APP_VERSION, ProjectError, ProjectSettings, SCHEMA_VERSION, SavedView}; -use super::*; +use super::{Arc, BTreeMap, Deserialize, FrameRate, Label, Project, Serialize, Tick, file_check, limits}; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] pub struct ItemId(pub u64); diff --git a/crates/project/src/layer.rs b/crates/project/src/layer.rs index b7286a6d5..25e2da7c4 100644 --- a/crates/project/src/layer.rs +++ b/crates/project/src/layer.rs @@ -1,6 +1,6 @@ use super::item::{ItemId, LayerId}; use super::settings::Marker; -use super::*; +use super::{BlendMode, Deserialize, Label, PropGroup, Serialize, Tick}; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum LightKind { diff --git a/crates/project/src/render_templates.rs b/crates/project/src/render_templates.rs index cf52b000e..cadfab2ad 100644 --- a/crates/project/src/render_templates.rs +++ b/crates/project/src/render_templates.rs @@ -8,7 +8,9 @@ use serde::{Deserialize, Serialize}; -use crate::render_queue::*; +use crate::render_queue::{ + AlphaMode, Channels, CurrentOrOff, FieldRender, OutputFormat, OutputModule, ProResProfile, ProxyUse, RenderQuality, RenderSettings, SwitchOverride, +}; use effectcraft_time::FrameRate; /// A default slot (the Defaults section of the Templates dialogs). @@ -288,6 +290,7 @@ impl RenderTemplates { #[cfg(test)] mod tests { use super::*; + use crate::render_queue::{ColorDepth, Crop}; #[test] fn builtins_defaults_and_round_trip() { diff --git a/crates/project/src/settings.rs b/crates/project/src/settings.rs index efd75fd14..5d3d57204 100644 --- a/crates/project/src/settings.rs +++ b/crates/project/src/settings.rs @@ -1,5 +1,5 @@ use super::item::{ItemId, LayerId}; -use super::*; +use super::{BTreeMap, ColorSpace, Deserialize, Label, Serialize, Tick}; /// The project file schema. 2: effects on layers without a source rectangle (shape, text) /// measure positions from the top-left of their comp-sized bounds (effect space), not from diff --git a/crates/psd/src/lib.rs b/crates/psd/src/lib.rs index b341f84af..91792a837 100644 --- a/crates/psd/src/lib.rs +++ b/crates/psd/src/lib.rs @@ -15,7 +15,6 @@ pub mod placed; mod read; pub mod write; -use std::borrow::Cow; use std::sync::Arc; pub use descriptor::{DValue, Descriptor}; diff --git a/crates/psd/src/limits.rs b/crates/psd/src/limits.rs index 5d6101989..c1c23b390 100644 --- a/crates/psd/src/limits.rs +++ b/crates/psd/src/limits.rs @@ -1,4 +1,4 @@ -use super::*; +use std::borrow::Cow; #[derive(Debug, Clone, PartialEq)] pub enum Error { diff --git a/crates/psd/src/model.rs b/crates/psd/src/model.rs index ffe44d4b0..729b9a3c4 100644 --- a/crates/psd/src/model.rs +++ b/crates/psd/src/model.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Descriptor, engine_data, placed}; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum ColorMode { diff --git a/crates/psd/src/parse.rs b/crates/psd/src/parse.rs index 136cc5745..d221b9044 100644 --- a/crates/psd/src/parse.rs +++ b/crates/psd/src/parse.rs @@ -2,7 +2,8 @@ use super::limits::{Error, Result}; use super::model::{ Adjustment, ChannelInfo, ColorMode, Layer, LayerMask, LinkedFile, MAX_GROUP_DEPTH, Node, Rect, Section, SmartObject, SubPath, TextInfo, VectorMask, }; -use super::*; +use super::{Psd, Reader, descriptor, engine_data, placed}; +use std::sync::Arc; /// Whether `bytes` start like a Photoshop document. pub fn is_psd(bytes: &[u8]) -> bool { diff --git a/crates/psd/src/pixels.rs b/crates/psd/src/pixels.rs index d5c0c5993..21a5c8845 100644 --- a/crates/psd/src/pixels.rs +++ b/crates/psd/src/pixels.rs @@ -1,6 +1,9 @@ +#[cfg(test)] +use super::counters; use super::limits::{Budget, DECODE_BUDGET, Error, Result, decode_pixels, finite_sample, inflate, release_bytes, too_large}; -use super::model::{ColorMode, Pixels, Rect}; -use super::*; +use super::model::{ColorMode, Layer, Pixels, Rect}; +use super::{Psd, Reader}; +use std::borrow::Cow; impl Psd { fn bytes_per_sample(&self) -> usize { @@ -29,7 +32,7 @@ impl Psd { pub(crate) fn plane_bytes<'a>(&self, comp: u16, r: &mut Reader<'a>, w: usize, h: usize, budget: &mut Budget) -> Result> { let rowb = self.row_bytes(w); let size = rowb.checked_mul(h).ok_or_else(|| too_large(w, h))?; - Ok(match self.open_planes(comp, r, 1, 1, w, h, budget)? { + Ok(match self.open_planes(comp, r, 1, 1, (w, h), budget)? { PlaneStream::Raw => Cow::Borrowed(r.bytes(size)?), PlaneStream::Rle(counts) => Cow::Owned(Self::unpack_plane(&counts, 0, r, rowb, h, budget)?), PlaneStream::Zip(mut v) => { @@ -44,8 +47,8 @@ impl Psd { /// `w`×`h` samples, of which the first `want` are decoded: the RLE row sizes of every plane, /// or the inflated ZIP data, which holds all planes one after another (charged to `budget` /// before it is inflated). - #[allow(clippy::too_many_arguments)] - fn open_planes(&self, comp: u16, r: &mut Reader, total: usize, want: usize, w: usize, h: usize, budget: &mut Budget) -> Result { + fn open_planes(&self, comp: u16, r: &mut Reader, total: usize, want: usize, size: (usize, usize), budget: &mut Budget) -> Result { + let (w, h) = size; let size = self.row_bytes(w).checked_mul(h).ok_or_else(|| too_large(w, h))?; Ok(match comp { 0 => PlaneStream::Raw, @@ -72,17 +75,16 @@ impl Psd { /// planes are read from `r` in order, raw ones borrowed from the file data, ZIP ones from the /// inflated buffer (un-predicted in place; already charged by `open_planes`), RLE ones charged /// to `budget`. - #[allow(clippy::too_many_arguments)] fn decode_plane<'x, 'a: 'x>( &self, comp: u16, planes: &'x mut PlaneStream, r: &mut Reader<'a>, c: usize, - w: usize, - h: usize, + size: (usize, usize), budget: &mut Budget, ) -> Result> { + let (w, h) = size; let rowb = self.row_bytes(w); let size = rowb.checked_mul(h).ok_or_else(|| too_large(w, h))?; Ok(match planes { @@ -265,9 +267,9 @@ impl Psd { // Opaque black until a channel says otherwise. let mut data = budget.filled(n, [0.0f32, 0.0, 0.0, 1.0])?; let mut black = if self.mode == ColorMode::Cmyk && want > 3 { Some(budget.filled(n, 0.0f32)?) } else { None }; - let mut stream = self.open_planes(comp, &mut r, nch, want, w, h, &mut budget)?; + let mut stream = self.open_planes(comp, &mut r, nch, want, (w, h), &mut budget)?; for c in 0..want { - let raw = self.decode_plane(comp, &mut stream, &mut r, c, w, h, &mut budget)?; + let raw = self.decode_plane(comp, &mut stream, &mut r, c, (w, h), &mut budget)?; self.store_merged_channel(c, &raw, &mut data, &mut black); release_bytes(&mut budget, raw); } @@ -407,12 +409,19 @@ impl Psd { } } } + self.apply_layer_mask(l, out_rect, &mut out, &mut budget)?; + Ok(Pixels { width: ow as u32, height: oh as u32, data: out }) + } + + /// Apply the raster mask to alpha, including its default outside the mask bounds. + fn apply_layer_mask(&self, l: &Layer, out_rect: Rect, out: &mut [[f32; 4]], budget: &mut Budget) -> Result<()> { + let (ow, oh) = (out_rect.width() as usize, out_rect.height() as usize); // Layer mask: multiplies alpha (also outside the mask rect, by its default colour). if let Some(m) = l.mask.filter(|m| !m.disabled) { let mc = l.channels.iter().find(|c| c.id == -2); let (mw, mh) = (m.rect.width() as usize, m.rect.height() as usize); let mask = match mc { - Some(c) if mw > 0 && mh > 0 => Some(self.channel_floats(c.offset, c.len, mw, mh, &mut budget)?), + Some(c) if mw > 0 && mh > 0 => Some(self.channel_floats(c.offset, c.len, mw, mh, budget)?), _ => None, }; let def = m.default_color as f32 / 255.0; @@ -426,7 +435,7 @@ impl Psd { } }; match &mask { - None => scale(&mut out, outside), + None => scale(out, outside), Some(mk) => { // The columns of the output that fall inside the mask rect (the same for every row). let lo = (i64::from(m.rect.left) - i64::from(out_rect.left)).clamp(0, ow as i64); @@ -454,7 +463,7 @@ impl Psd { } } } - Ok(Pixels { width: ow as u32, height: oh as u32, data: out }) + Ok(()) } } diff --git a/crates/psd/src/tests/opt.rs b/crates/psd/src/tests/opt.rs index 5387e42b9..908026d07 100644 --- a/crates/psd/src/tests/opt.rs +++ b/crates/psd/src/tests/opt.rs @@ -1,6 +1,8 @@ //! The optimised decode / mask / placed-bake paths against the implementations they replaced //! (kept below verbatim as references), and counters for the work they no longer do. +use std::borrow::Cow; + use crate::counters::{self, INSIDE, QUAD, SCRATCH}; use crate::placed::{Warp, square_to_quad}; use crate::write::*; diff --git a/crates/raster/src/inpaint.rs b/crates/raster/src/inpaint.rs index 15bdaa0dd..c64de3375 100644 --- a/crates/raster/src/inpaint.rs +++ b/crates/raster/src/inpaint.rs @@ -607,38 +607,44 @@ pub struct FillInput { pub references: Vec<(usize, Image)>, } -/// Trace pixel (x, y) of frame `t` through `flows` (`step` = +1 forward with `fwd`, −1 backward -/// with `bwd`) to the nearest frame where it is visible: (sampled colour, frames travelled). -fn trace( - t: usize, +/// Shared frame and flow data for tracing pixels through a sequence. +struct TraceContext<'a> { + fwd: &'a [Flow], + bwd: &'a [Flow], + known: &'a (dyn Fn(usize, i64, i64) -> bool + Sync), + frames: &'a [&'a Image], + max: usize, +} + +/// Starting frame, point, and direction for a flow trace. +struct TraceStart { + frame: usize, x: f64, y: f64, step: i64, - fwd: &[Flow], - bwd: &[Flow], - known: &dyn Fn(usize, i64, i64) -> bool, - frames: &[&Image], - max: usize, -) -> Option<([f32; 4], usize)> { - let n = frames.len(); - let (w, h) = (frames[0].width as f64, frames[0].height as f64); - let (mut px, mut py) = (x, y); - let mut k = t as i64; - for d in 1..=max { - let v = if step > 0 { +} + +/// Trace a point to the nearest visible frame: (sampled colour, frames travelled). +fn trace(context: &TraceContext<'_>, start: TraceStart) -> Option<([f32; 4], usize)> { + let n = context.frames.len(); + let (w, h) = (context.frames[0].width as f64, context.frames[0].height as f64); + let (mut px, mut py) = (start.x, start.y); + let mut k = start.frame as i64; + for d in 1..=context.max { + let v = if start.step > 0 { if k as usize + 1 >= n { return None; } - fwd[k as usize].at(px, py) + context.fwd[k as usize].at(px, py) } else { if k == 0 { return None; } - bwd[k as usize - 1].at(px, py) + context.bwd[k as usize - 1].at(px, py) }; px += v[0] as f64; py += v[1] as f64; - k += step; + k += start.step; if px < 0.0 || py < 0.0 || px >= w || py >= h { return None; } @@ -646,9 +652,9 @@ fn trace( let (x0, y0) = ((px - 0.5).floor() as i64, (py - 0.5).floor() as i64); let ok = [(x0, y0), (x0 + 1, y0), (x0, y0 + 1), (x0 + 1, y0 + 1)] .iter() - .all(|&(xx, yy)| known(k as usize, xx.clamp(0, w as i64 - 1), yy.clamp(0, h as i64 - 1))); + .all(|&(xx, yy)| (context.known)(k as usize, xx.clamp(0, w as i64 - 1), yy.clamp(0, h as i64 - 1))); if ok { - return Some((frames[k as usize].sample_bilinear_clamped(px, py), d)); + return Some((context.frames[k as usize].sample_bilinear_clamped(px, py), d)); } } None @@ -697,6 +703,7 @@ pub fn fill_sequence(input: &FillInput, o: &FillOpts, progress: &mut dyn FnMut(u // 2. Propagation (and the lighting-correction border ring). let refs: Vec<&Image> = frames.iter().collect(); let known = |k: usize, x: i64, y: i64| !holes[k][y as usize * w + x as usize]; + let trace_context = TraceContext { fwd: &fwd, bwd: &bwd, known: &known, frames: &refs, max: n }; let ring_r = 2usize; let propagated: Vec<(Image, Vec)> = (0..n) .into_par_iter() @@ -708,8 +715,8 @@ pub fn fill_sequence(input: &FillInput, o: &FillOpts, progress: &mut dyn FnMut(u } let sample = |i: usize| -> Option<[f32; 4]> { let (x, y) = ((i % w) as f64 + 0.5, (i / w) as f64 + 0.5); - let f = trace(t, x, y, 1, &fwd, &bwd, &known, &refs, n); - let b = trace(t, x, y, -1, &fwd, &bwd, &known, &refs, n); + let f = trace(&trace_context, TraceStart { frame: t, x, y, step: 1 }); + let b = trace(&trace_context, TraceStart { frame: t, x, y, step: -1 }); match (f, b) { (Some((a, da)), Some((c, dc))) => { let (wa, wc) = (1.0 / da as f32, 1.0 / dc as f32); diff --git a/crates/raster/src/warp.rs b/crates/raster/src/warp.rs index c16480865..9f6de31ce 100644 --- a/crates/raster/src/warp.rs +++ b/crates/raster/src/warp.rs @@ -203,22 +203,17 @@ fn union(r: [usize; 4], q: [usize; 4]) -> [usize; 4] { } } -/// Fast affine rows: `blend(dst, src, x, y)` is only called for visible source pixels. Returns -/// the rectangle of the pixels it blended. -#[allow(clippy::too_many_arguments)] -fn affine_rows( - dst: &mut Image, - img: &Image, - inv: &Mat3, +/// Sampling, blending and destination bounds for an affine row operation. +struct AffineRows { sampling: Sampling, - op: f32, + opacity: f32, quantize: Option, - x0: usize, - x1: usize, - y0: usize, - y1: usize, - blend: impl Fn(Px, Px, u32, u32) -> Px + Sync, -) -> [usize; 4] { + bounds: [usize; 4], +} + +/// Blend visible source samples and return the rectangle of changed pixels. +fn affine_rows(dst: &mut Image, img: &Image, inv: &Mat3, rows: AffineRows, blend: impl Fn(Px, Px, u32, u32) -> Px + Sync) -> [usize; 4] { + let AffineRows { sampling, opacity: op, quantize, bounds: [x0, x1, y0, y1] } = rows; let levels = quantize.map(|l| (l, 1.0 / l)); let w = dst.width as usize; let m = inv.0; @@ -363,22 +358,28 @@ fn composite_warp_impl( if let Some(k) = accumulate && affine { - let r = affine_rows(dst, img, &inv, opts.sampling, 1.0, None, x0, x1, y0, y1, |d, s, _, _| { + let r = affine_rows(dst, img, &inv, AffineRows { sampling: opts.sampling, opacity: 1.0, quantize: None, bounds: [x0, x1, y0, y1] }, |d, s, _, _| { [d[0] + s[0] * k, d[1] + s[1] * k, d[2] + s[2] * k, d[3] + s[3] * k] }); return changed(r); } if allow_fast && affine && !mode.is_stencil() { let r = match mode { - BlendMode::Normal => affine_rows(dst, img, &inv, opts.sampling, op, quantize, x0, x1, y0, y1, |d, s, _, _| { - let k = 1.0 - s[3]; - [s[0] + d[0] * k, s[1] + d[1] * k, s[2] + d[2] * k, s[3] + d[3] * k] - }), + BlendMode::Normal => { + affine_rows(dst, img, &inv, AffineRows { sampling: opts.sampling, opacity: op, quantize, bounds: [x0, x1, y0, y1] }, |d, s, _, _| { + let k = 1.0 - s[3]; + [s[0] + d[0] * k, s[1] + d[1] * k, s[2] + d[2] * k, s[3] + d[3] * k] + }) + } BlendMode::Dissolve | BlendMode::DancingDissolve => { let seed = opts.seed; - affine_rows(dst, img, &inv, opts.sampling, op, quantize, x0, x1, y0, y1, |d, s, x, y| blend_pixel(mode, d, s, hash_noise(x, y, seed))) + affine_rows(dst, img, &inv, AffineRows { sampling: opts.sampling, opacity: op, quantize, bounds: [x0, x1, y0, y1] }, |d, s, x, y| { + blend_pixel(mode, d, s, hash_noise(x, y, seed)) + }) } - _ => affine_rows(dst, img, &inv, opts.sampling, op, quantize, x0, x1, y0, y1, |d, s, _, _| blend_pixel(mode, d, s, 0.5)), + _ => affine_rows(dst, img, &inv, AffineRows { sampling: opts.sampling, opacity: op, quantize, bounds: [x0, x1, y0, y1] }, |d, s, _, _| { + blend_pixel(mode, d, s, 0.5) + }), }; return changed(r); } diff --git a/crates/render/src/accel.rs b/crates/render/src/accel.rs index b26175117..026fe6da1 100644 --- a/crates/render/src/accel.rs +++ b/crates/render/src/accel.rs @@ -2,7 +2,9 @@ use super::frames::content_bounds; use super::fx_host::MAX_FX_DEPTH; use super::layers::StyledLayer; use super::opts::LayerTiming; -use super::*; +use super::{ + Arc, AutoPick, Buf, EffectCtx, EvalCtx, Image, ItemId, ItemKind, Layer, LayerSource, Mat3, MatteKind, Renderer, Tick, cache, color, masks, three_d, vec2, +}; /// Which compositor renders a frame (Project Settings ▸ Video Rendering and Effects). #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] @@ -283,7 +285,7 @@ impl<'a> Renderer<'a> { /// layers). pub fn run_effects_on(&self, ctx: &EvalCtx, layer: &Layer, adjustment: bool, target: &mut dyn FxTarget) { // `target` is used as it is: the comp below for adjustment layers (the callers). - self.run_effect_stack(ctx, layer, adjustment, usize::MAX, None, [0.0; 2], target); + self.run_effect_stack(ctx, layer, crate::pipeline::EffectStack { adjustment, limit: usize::MAX, origin: [0.0; 2] }, None, target); } /// [`Renderer::run_effects_on`] for a (non-adjustment) layer's own pixels: `target` holds @@ -291,7 +293,7 @@ impl<'a> Renderer<'a> { /// there. Returns the effect-space origin in layer coordinates (rebase by its negation). pub fn run_layer_effects_on(&self, ctx: &EvalCtx, layer: &Layer, target: &mut dyn FxTarget) -> [f64; 2] { let origin = ctx.effect_bounds(layer).1; - self.run_effect_stack(ctx, layer, false, usize::MAX, None, origin, target); + self.run_effect_stack(ctx, layer, crate::pipeline::EffectStack { adjustment: false, limit: usize::MAX, origin }, None, target); origin } diff --git a/crates/render/src/audio.rs b/crates/render/src/audio.rs index 68be63fe4..66b54b6d0 100644 --- a/crates/render/src/audio.rs +++ b/crates/render/src/audio.rs @@ -25,7 +25,7 @@ const MAX_DEPTH: usize = 16; /// interleaved (L R L R …). pub fn mix_comp(project: &Project, footage: &dyn FootageSource, expr: Option<&dyn ExprHost>, comp: ItemId, start: Tick, frames: usize, rate: u32) -> Vec { let mut out = vec![0.0f32; frames * 2]; - mix_into(project, footage, expr, comp, start, rate, &mut out, 1.0, 1.0, 0); + mix_into(&AudioSources { project, footage, expr, rate }, comp, start, &mut out, 0); out } @@ -151,7 +151,7 @@ impl MixEnv<'_> { return out; } let mut sub = vec![0.0f32; src_n * 2]; - mix_into(self.project, self.footage, self.expr, *item, lt, rate, &mut sub, 1.0, 1.0, self.depth + 1); + mix_into(&AudioSources { project: self.project, footage: self.footage, expr: self.expr, rate }, *item, lt, &mut sub, self.depth + 1); sub } _ => return out, @@ -206,19 +206,16 @@ impl MixEnv<'_> { } } -#[allow(clippy::too_many_arguments)] -fn mix_into( - project: &Project, - footage: &dyn FootageSource, - expr: Option<&dyn ExprHost>, - comp_id: ItemId, - start: Tick, +/// Shared inputs for composition audio mixing, including nested compositions. +struct AudioSources<'a> { + project: &'a Project, + footage: &'a dyn FootageSource, + expr: Option<&'a dyn ExprHost>, rate: u32, - out: &mut [f32], - gl: f32, - gr: f32, - depth: usize, -) { +} + +fn mix_into(sources: &AudioSources<'_>, comp_id: ItemId, start: Tick, out: &mut [f32], depth: usize) { + let AudioSources { project, footage, expr, rate } = *sources; let Some(comp) = project.comp(comp_id) else { return }; if depth >= MAX_DEPTH || rate == 0 { return; @@ -239,7 +236,6 @@ fn mix_into( } let t_a = sample_tick(s0 + a as i64, rate); let (ll, lr) = levels(&env, l, t_a); - let (ll, lr) = (ll * gl, lr * gr); if ll == 0.0 && lr == 0.0 { continue; } diff --git a/crates/render/src/cache.rs b/crates/render/src/cache.rs index 89e9fd1c9..21f745c0e 100644 --- a/crates/render/src/cache.rs +++ b/crates/render/src/cache.rs @@ -580,7 +580,6 @@ fn key_with(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool, f /// `blur`: the layer is motion blurred in this render (`Renderer::mb_on`). `effects`: key only /// the first that many effects (a layer's input, see [`input_key`]). -#[allow(clippy::too_many_arguments)] fn key_any(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool, footage: bool, effects: Option) -> Option { let mut h = KeyHasher(0xcbf2_9ce4_8422_2325); ctx.comp.frame_rate.as_f64().to_bits().hash(&mut h); diff --git a/crates/render/src/footage.rs b/crates/render/src/footage.rs index e58ef2a57..04dd706db 100644 --- a/crates/render/src/footage.rs +++ b/crates/render/src/footage.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Arc, AtomicBool, Footage, Image, ItemId, Ordering, Tick}; /// How a [`FootageSource`] hands over float footage (OpenEXR, float TIFF), whose values are /// linear light: encoded with a curve, as the compositor takes footage, or as stored. Other diff --git a/crates/render/src/frames.rs b/crates/render/src/frames.rs index 19ea785ec..7dd0fc0de 100644 --- a/crates/render/src/frames.rs +++ b/crates/render/src/frames.rs @@ -1,6 +1,6 @@ use super::footage::NoFootage; use super::opts::RenderOpts; -use super::*; +use super::{EvalCtx, Footage, FrameBlend, FrameRate, Image, ItemId, Layer, LayerSource, Project, Renderer, TICKS_PER_SECOND, Tick, shapes, source_size, text}; /// The layer's frame blending mode, if the comp's Enable Frame Blending switch is on. /// One field of an interlaced frame as a full frame: the field's lines (`parity` 0 = even / diff --git a/crates/render/src/fx_host.rs b/crates/render/src/fx_host.rs index 6275af5f0..8366cf7c0 100644 --- a/crates/render/src/fx_host.rs +++ b/crates/render/src/fx_host.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Arc, Buf, EffectHost, EvalCtx, ItemKind, Layer, LayerPixels, LayerSource, Params, Renderer, Tick, three_d}; /// Layer parameters and audio for effects (see [`effectcraft_effects::EffectHost`]). What it /// hands out is in effect space (see [`effect_bounds`]): the other layer's for its pixels, the diff --git a/crates/render/src/layers.rs b/crates/render/src/layers.rs index c24c37ff8..cac2add43 100644 --- a/crates/render/src/layers.rs +++ b/crates/render/src/layers.rs @@ -1,6 +1,16 @@ use super::frames::adaptive_samples; use super::opts::{LayerTiming, Through}; -use super::*; +use super::{ + Arc, BlendMode, Buf, Comp, EvalCtx, Image, IndexedParallelIterator, IntoParallelRefIterator, IntoParallelRefMutIterator, Layer, LayerSource, Mat3, + MatteKind, ParallelIterator, Quality, Region, Renderer, Sampling, Tick, WarpOpts, cache, color, composite_warp_changed, masks, source_size, styles, vec2, +}; + +/// Blend policy for placing a layer, including optional output-depth quantization. +struct PlacementBlend { + mode: BlendMode, + opacity: f32, + quantize: Option, +} /// A motion-blur shutter: the comp's, or for a collapsed precomp's layers the outermost /// containing comp's (they are drawn into its frames). @@ -342,22 +352,13 @@ impl<'a> Renderer<'a> { /// Draw a processed layer into `target` (transform, motion blur) with `mode`/`opacity`. pub(crate) fn place(&self, ctx: &EvalCtx, layer: &Layer, buf: &Buf, target: &mut Image, mode: BlendMode, opacity: f32) { - self.place_changed(ctx, layer, buf, target, mode, opacity, None); + self.place_changed(ctx, layer, buf, target, PlacementBlend { mode, opacity, quantize: None }); } /// [`Self::place`], returning the target pixels it changed when the warp tracked them (then /// also quantised to `quantize` levels; see [`composite_warp_changed`]). - #[allow(clippy::too_many_arguments)] - fn place_changed( - &self, - ctx: &EvalCtx, - layer: &Layer, - buf: &Buf, - target: &mut Image, - mode: BlendMode, - opacity: f32, - quantize: Option, - ) -> Option<[u32; 4]> { + fn place_changed(&self, ctx: &EvalCtx, layer: &Layer, buf: &Buf, target: &mut Image, blend: PlacementBlend) -> Option<[u32; 4]> { + let PlacementBlend { mode, opacity, quantize } = blend; let pl = self.placement(ctx, layer, buf); let opts = WarpOpts { sampling: pl.sampling, opacity, mode, seed: pl.seed, clip: None }; if let [m] = pl.matrices.as_slice() { @@ -459,7 +460,7 @@ impl<'a> Renderer<'a> { fn composite_layer(&self, ctx: &EvalCtx, layer: &Layer, buf: &Buf, canvas: &mut Image, opacity: f32, quantize: Option) -> Option<[u32; 4]> { let matte = layer.track_matte.is_some_and(|tm| tm.layer != layer.id && ctx.comp.layer(tm.layer).is_some()); if !matte && !layer.preserve_transparency { - return self.place_changed(ctx, layer, buf, canvas, layer.blend_mode, opacity, quantize); + return self.place_changed(ctx, layer, buf, canvas, PlacementBlend { mode: layer.blend_mode, opacity, quantize }); } // Render in isolation, then matte / preserve transparency, then blend. let mut iso = Image::new(canvas.width, canvas.height); diff --git a/crates/render/src/opts.rs b/crates/render/src/opts.rs index 9d0b2a39d..c6175bf39 100644 --- a/crates/render/src/opts.rs +++ b/crates/render/src/opts.rs @@ -1,5 +1,5 @@ use super::accel::Backend; -use super::*; +use super::{Comp, EvalCtx, ItemId, Layer, Mat4, Tick, three_d}; #[derive(Clone, Copy, Debug, serde::Serialize, serde::Deserialize)] #[serde(default, rename_all = "camelCase")] diff --git a/crates/render/src/pipeline.rs b/crates/render/src/pipeline.rs index 92b5b7f5c..bc1879369 100644 --- a/crates/render/src/pipeline.rs +++ b/crates/render/src/pipeline.rs @@ -3,7 +3,18 @@ use super::footage::{FootageSource, is_vector_footage}; use super::frames::{blend_frames, content_bounds, field_frame, frame_blend_mode, frame_position, frame_size, interpret_pixels, roi_inside}; use super::fx_host::{FxHost, essential_overrides}; use super::opts::{Collapse3d, LayerTiming, MAX_THROUGH, RenderOpts, Through}; -use super::*; +use super::{ + Arc, AutoKey, AutoPick, BlendMode, Buf, Comp, EffectCtx, EffectEnv, EvalCtx, Footage, FootageKind, FrameBlend, FrameRate, GroupKind, Image, ItemId, + ItemKind, Layer, LayerCache, LayerSource, Params, Project, Quality, Region, Renderer, Tick, cache, color, masks, shapes, styles, text, three_d, vec2, +}; + +/// Coordinate space and stack range shared by CPU and accelerated effect evaluation. +#[derive(Clone, Copy)] +pub(crate) struct EffectStack { + pub adjustment: bool, + pub limit: usize, + pub origin: [f64; 2], +} impl<'a> Renderer<'a> { pub fn new(project: &'a Project, footage: &'a dyn FootageSource, opts: RenderOpts) -> Renderer<'a> { @@ -423,26 +434,16 @@ impl<'a> Renderer<'a> { // The comp below an adjustment layer is not in its layer space: it stays as it is. let origin = if adjustment { [0.0; 2] } else { ctx.effect_bounds(layer).1 }; buf.rebase(origin); - let mut out = self.effect_space_stack(ctx, layer, buf, adjustment, limit, timing, origin); + let mut out = self.effect_space_stack(ctx, layer, buf, EffectStack { adjustment, limit, origin }, timing); out.rebase([-origin[0], -origin[1]]); out } /// [`Renderer::apply_effects_timed`] on a buffer already in effect space (measured from /// `origin`, in layer coordinates); the result stays in effect space. - #[allow(clippy::too_many_arguments)] - fn effect_space_stack( - &self, - ctx: &EvalCtx, - layer: &Layer, - buf: Buf, - adjustment: bool, - limit: usize, - timing: Option<&mut Vec<(String, f64)>>, - origin: [f64; 2], - ) -> Buf { + fn effect_space_stack(&self, ctx: &EvalCtx, layer: &Layer, buf: Buf, stack: EffectStack, timing: Option<&mut Vec<(String, f64)>>) -> Buf { let mut t = CpuFx { accel: self.active_accel(), levels: self.pipe.levels, buf: Some(buf) }; - self.run_effect_stack(ctx, layer, adjustment, limit, timing, origin, &mut t); + self.run_effect_stack(ctx, layer, stack, timing, &mut t); t.buf.unwrap_or_else(|| Buf { img: Image::new(1, 1), offset: [0.0; 2], scale: self.opts.scale }) } @@ -450,17 +451,15 @@ impl<'a> Renderer<'a> { /// in layer coordinates): runs of GPU-capable effects go to [`FxTarget::gpu`] (when an /// accelerator is active and supports them), the rest to [`FxTarget::cpu`] one by one /// (quantised to the bit depth after each). - #[allow(clippy::too_many_arguments)] pub(crate) fn run_effect_stack( &self, ctx: &EvalCtx, layer: &Layer, - adjustment: bool, - limit: usize, + stack: EffectStack, mut timing: Option<&mut Vec<(String, f64)>>, - origin: [f64; 2], target: &mut dyn FxTarget, ) { + let EffectStack { adjustment, limit, origin } = stack; if !layer.switches.effects { return; } @@ -873,7 +872,7 @@ impl<'a> Renderer<'a> { let origin = ctx.effect_bounds(layer).1; buf.rebase(origin); if effects > 0 { - buf = self.effect_space_stack(ctx, layer, buf, false, effects, None, origin); + buf = self.effect_space_stack(ctx, layer, buf, EffectStack { adjustment: false, limit: effects, origin }, None); } let buf = Arc::new(buf); // The buffer the cache keeps (another worker's, if it kept one first). diff --git a/crates/render/src/shapes.rs b/crates/render/src/shapes.rs index f8de5a290..a4e4077f8 100644 --- a/crates/render/src/shapes.rs +++ b/crates/render/src/shapes.rs @@ -211,16 +211,16 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec { let star = ctx.e(layer, g, "type") == 0; - let p = effectcraft_path::polystar( + let p = effectcraft_path::polystar(effectcraft_path::Polystar { star, - ctx.f(layer, g, "points", 5.0), - ctx.v2(layer, g, "position", [0.0; 2]), - ctx.f(layer, g, "rotation", 0.0), - ctx.f(layer, g, "innerRadius", 50.0), - ctx.f(layer, g, "outerRadius", 100.0), - ctx.f(layer, g, "innerRoundness", 0.0), - ctx.f(layer, g, "outerRoundness", 0.0), - ); + points: ctx.f(layer, g, "points", 5.0), + pos: ctx.v2(layer, g, "position", [0.0; 2]), + rotation: ctx.f(layer, g, "rotation", 0.0), + inner: ctx.f(layer, g, "innerRadius", 50.0), + outer: ctx.f(layer, g, "outerRadius", 100.0), + inner_round: ctx.f(layer, g, "innerRoundness", 0.0), + outer_round: ctx.f(layer, g, "outerRoundness", 0.0), + }); push_shape(ctx, layer, g, p, arena, &mut live); } "path" => { diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index 496bc3a60..e1954b597 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -287,11 +287,13 @@ pub fn char_transforms(ctx: &EvalCtx, layer: &Layer, text: &PropGroup, lay: &Tex let pos = get("position").map(|v| c3(v, 0.0)); let anchor = get("anchor").map(|v| c3(v, 0.0)); let scale = get("scale").map(|v| c3(v, 100.0)); - let rot = get1("rotation"); - let rx = get1("rotationX"); - let ry = get1("rotationY"); - let skew = get1("skew"); - let skew_axis = get1("skewAxis"); + let angles = CharacterAngles { + rotation: get1("rotation"), + rotation_x: get1("rotationX"), + rotation_y: get1("rotationY"), + skew: get1("skew"), + skew_axis: get1("skewAxis"), + }; let op = get1("opacity"); let fill = props.get("fillColor").map(|p| ctx.value(layer, p).as_color()); let stroke = props.get("strokeColor").map(|p| ctx.value(layer, p).as_color()); @@ -338,21 +340,7 @@ pub fn char_transforms(ctx: &EvalCtx, layer: &Layer, text: &PropGroup, lay: &Tex c.scale[d] *= 1.0 + (s[d] / 100.0 - 1.0) * k3[d]; } } - if let Some(r) = rot { - c.rotation += r * k; - } - if let Some(r) = rx { - c.rotation_x += r * k; - } - if let Some(r) = ry { - c.rotation_y += r * k; - } - if let Some(s) = skew { - c.skew += s * k; - } - if let Some(a) = skew_axis { - c.skew_axis = a; - } + angles.apply(c, k); if let Some(o) = op { c.opacity *= 1.0 + (o / 100.0 - 1.0) * k; } @@ -381,17 +369,7 @@ pub fn char_transforms(ctx: &EvalCtx, layer: &Layer, text: &PropGroup, lay: &Tex if let Some(w) = sw { c.stroke_width += w * k; } - if let Some(t) = tracking { - let t = t * k; - match tracking_type { - 1 => c.track_before += t, - 2 => c.track_after += t, - _ => { - c.track_before += t * 0.5; - c.track_after += t * 0.5; - } - } - } + apply_tracking(c, tracking, tracking_type, k); if let Some(a) = line_anchor && k.abs() >= c.line_anchor_k { @@ -432,6 +410,50 @@ pub fn char_transforms(ctx: &EvalCtx, layer: &Layer, text: &PropGroup, lay: &Tex out } +/// Optional animator orientation properties; an omitted property leaves the glyph unchanged. +struct CharacterAngles { + rotation: Option, + rotation_x: Option, + rotation_y: Option, + skew: Option, + skew_axis: Option, +} + +impl CharacterAngles { + fn apply(&self, c: &mut CharXf, k: f64) { + if let Some(r) = self.rotation { + c.rotation += r * k; + } + if let Some(r) = self.rotation_x { + c.rotation_x += r * k; + } + if let Some(r) = self.rotation_y { + c.rotation_y += r * k; + } + if let Some(s) = self.skew { + c.skew += s * k; + } + if let Some(a) = self.skew_axis { + c.skew_axis = a; + } + } +} + +/// Distribute selected tracking before and/or after a glyph according to Tracking Type. +fn apply_tracking(c: &mut CharXf, tracking: Option, tracking_type: u32, k: f64) { + if let Some(t) = tracking { + let t = t * k; + match tracking_type { + 1 => c.track_before += t, + 2 => c.track_after += t, + _ => { + c.track_before += t * 0.5; + c.track_after += t * 0.5; + } + } + } +} + /// Character Offset / Character Value substitution (None = unchanged). pub fn substitute(ch: char, x: &CharXf) -> Option { if ch.is_whitespace() { diff --git a/crates/render/src/three_d/adv/shade.rs b/crates/render/src/three_d/adv/shade.rs index 9acc852fe..0ebd0b1ac 100644 --- a/crates/render/src/three_d/adv/shade.rs +++ b/crates/render/src/three_d/adv/shade.rs @@ -82,9 +82,20 @@ pub fn f_schlick(f0: [f32; 3], v_h: f32) -> [f32; 3] { [f0[0] + (1.0 - f0[0]) * k, f0[1] + (1.0 - f0[1]) * k, f0[2] + (1.0 - f0[2]) * k] } +/// Surface parameters used by the direct-light BRDF after texture sampling. +#[derive(Clone, Copy)] +pub struct BrdfSurface { + pub base: [f32; 3], + pub metallic: f32, + pub roughness: f32, + pub specular: f32, + pub diffuse: f32, +} + /// Cook–Torrance BRDF × N·L × π for one light: (diffuse colour factor, specular colour). /// Returned as the reflected fraction of the light's irradiance. -pub fn brdf(base: [f32; 3], metallic: f32, rough: f32, spec_k: f32, diff_k: f32, n: [f32; 3], v: [f32; 3], l: [f32; 3]) -> [f32; 3] { +pub fn brdf(surface: BrdfSurface, n: [f32; 3], v: [f32; 3], l: [f32; 3]) -> [f32; 3] { + let BrdfSurface { base, metallic, roughness: rough, specular: spec_k, diffuse: diff_k } = surface; let n_l = dot(n, l); if n_l <= 0.0 { return [0.0; 3]; @@ -484,7 +495,7 @@ pub fn shade(s: &Scene, f: &Frag) -> Option<[f32; 4]> { continue; } let sh = if m.receives_shadows { light_shadow(s, l, f.pos, ng, n_l) } else { 1.0 }; - let b = brdf(rgb, metallic, rough, m.specular_k, m.diffuse_k, n, v, to_l); + let b = brdf(BrdfSurface { base: rgb, metallic, roughness: rough, specular: m.specular_k, diffuse: m.diffuse_k }, n, v, to_l); for c in 0..3 { out[c] += b[c] * l.color[c] * k * sh; } diff --git a/crates/render/src/three_d/adv/tests.rs b/crates/render/src/three_d/adv/tests.rs index 3f5ee4813..f9a0f2066 100644 --- a/crates/render/src/three_d/adv/tests.rs +++ b/crates/render/src/three_d/adv/tests.rs @@ -16,7 +16,7 @@ fn brdf_matches_cook_torrance_formula() { let v = shade::norm([0.3, 0.1, 1.0]); let l = shade::norm([-0.4, 0.2, 1.0]); let (base, metal, rough) = ([0.8, 0.4, 0.2], 0.3f32, 0.45f32); - let got = shade::brdf(base, metal, rough, 1.0, 1.0, n, v, l); + let got = shade::brdf(shade::BrdfSurface { base, metallic: metal, roughness: rough, specular: 1.0, diffuse: 1.0 }, n, v, l); let h = shade::norm([v[0] + l[0], v[1] + l[1], v[2] + l[2]]); let (nv, nl, nh, vh) = (shade::dot(n, v), shade::dot(n, l), shade::dot(n, h), shade::dot(v, h)); let a2 = (rough * rough).powi(2); @@ -30,9 +30,9 @@ fn brdf_matches_cook_torrance_formula() { assert!((got[c] - expect).abs() < 1e-5, "channel {c}: {} vs {expect}", got[c]); } // No light from behind the surface. - assert_eq!(shade::brdf(base, metal, rough, 1.0, 1.0, n, v, [0.0, 0.0, -1.0]), [0.0; 3]); + assert_eq!(shade::brdf(shade::BrdfSurface { base, metallic: metal, roughness: rough, specular: 1.0, diffuse: 1.0 }, n, v, [0.0, 0.0, -1.0]), [0.0; 3]); // A white rough dielectric lit head-on reflects about its base colour (π folded in). - let w = shade::brdf([1.0; 3], 0.0, 1.0, 1.0, 1.0, n, n, n); + let w = shade::brdf(shade::BrdfSurface { base: [1.0; 3], metallic: 0.0, roughness: 1.0, specular: 1.0, diffuse: 1.0 }, n, n, n); assert!(w[0] > 0.95 && w[0] < 1.05, "{w:?}"); } diff --git a/crates/render/src/three_d/compose.rs b/crates/render/src/three_d/compose.rs index 929b48d45..8e932819a 100644 --- a/crates/render/src/three_d/compose.rs +++ b/crates/render/src/three_d/compose.rs @@ -197,7 +197,7 @@ fn mb_samples(r: &Renderer, ctx: &EvalCtx, layer: &Layer) -> usize { /// Prepare a layer (buffer, geometry, material). `None` when it shows nothing. pub(crate) fn prepare<'a>(r: &Renderer, ctx: &EvalCtx<'a>, layer: &'a Layer, order: usize, out: (u32, u32), in_run: bool) -> Option> { let buf = r.blend_layer_buf(ctx, layer)?; - prepare_with(r, ctx, layer, order, out, in_run, buf, Mat4::IDENTITY) + prepare_with(r, ctx, layer, order, out, in_run, PlaneInput { buf, local: Mat4::IDENTITY }) } /// Prepare a layer as planes: one per character for per-character 3D text, else one. @@ -205,24 +205,21 @@ pub(crate) fn prepare_all<'a>(r: &Renderer, ctx: &EvalCtx<'a>, layer: &'a Layer, if crate::text::per_char_3d(ctx, layer) { return crate::text::per_char_planes(ctx, layer, r.opts.scale) .into_iter() - .filter_map(|(buf, m)| prepare_with(r, ctx, layer, order, out, in_run, r.to_blend(Arc::new(buf), None), m)) + .filter_map(|(buf, m)| prepare_with(r, ctx, layer, order, out, in_run, PlaneInput { buf: r.to_blend(Arc::new(buf), None), local: m })) .collect(); } prepare(r, ctx, layer, order, out, in_run).into_iter().collect() } -/// Prepare a plane showing `buf`, whose space maps into the layer through `local`. -#[allow(clippy::too_many_arguments)] -fn prepare_with<'a>( - r: &Renderer, - ctx: &EvalCtx<'a>, - layer: &'a Layer, - order: usize, - out: (u32, u32), - in_run: bool, - mut buf: Arc, +/// A processed plane and its mapping into the layer's coordinate system. +struct PlaneInput { + buf: Arc, local: Mat4, -) -> Option> { +} + +/// Prepare the plane's geometry, motion samples and material. +fn prepare_with<'a>(r: &Renderer, ctx: &EvalCtx<'a>, layer: &'a Layer, order: usize, out: (u32, u32), in_run: bool, plane: PlaneInput) -> Option> { + let PlaneInput { mut buf, local } = plane; if buf.img.is_empty() { return None; } diff --git a/crates/script/src/prelude.js b/crates/script/src/prelude.js index ce64e221b..f012ce128 100644 --- a/crates/script/src/prelude.js +++ b/crates/script/src/prelude.js @@ -189,7 +189,12 @@ BridgeTalk.pump = function () { return false; }; // ------------------------------------------------------------------ File / Folder -function __join(a, b) { return a.replace(/[\/\\]+$/, "") + "/" + b; } +function __trimPathSeparators(path) { + var end = path.length; + while (end > 0 && (path.charAt(end - 1) === "/" || path.charAt(end - 1) === "\\")) end--; + return path.slice(0, end); +} +function __join(a, b) { return __trimPathSeparators(a) + "/" + b; } function File(path) { if (!(this instanceof File)) return new File(path); @@ -292,9 +297,9 @@ Folder.prototype = { get fsName() { return this.__path; }, get fullName() { return this.__path; }, get absoluteURI() { return this.__path; }, - get name() { return this.__path.replace(/[\/\\]+$/, "").split(/[\/\\]/).pop(); }, + get name() { return __trimPathSeparators(this.__path).split(/[\/\\]/).pop(); }, get displayName() { return this.name; }, - get path() { var p = this.__path.replace(/[\/\\]+$/, "").split(/[\/\\]/); p.pop(); return p.join("/"); }, + get path() { var p = __trimPathSeparators(this.__path).split(/[\/\\]/); p.pop(); return p.join("/"); }, get parent() { return new Folder(this.path); }, get exists() { return JSON.parse(__file("isDir", this.__path)) === true; }, toString: function () { return this.__path; }, diff --git a/crates/script/src/socket.rs b/crates/script/src/socket.rs index 6b2976dc1..4103f12c1 100644 --- a/crates/script/src/socket.rs +++ b/crates/script/src/socket.rs @@ -55,7 +55,7 @@ mod imp { use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; - use super::*; + use super::{J, err, json}; enum Sock { Stream { s: TcpStream, buf: Vec, eof: bool }, diff --git a/crates/script/src/tests.rs b/crates/script/src/tests.rs index 8b7973c5d..c0411585b 100644 --- a/crates/script/src/tests.rs +++ b/crates/script/src/tests.rs @@ -456,6 +456,38 @@ fn layers_items_and_markers() { ); } +#[test] +fn folder_paths_trim_trailing_separators() { + let mut s = session(); + let o = ok( + &mut s, + r#" + var paths = ['', '/', '///', 'a/b/', 'a\\b\\/', 'a/b', 'a/\n']; + var result = paths.map(function (path) { + var folder = new Folder(path); + return [folder.name, folder.path, __join(path, 'child')]; + }); + // A long run followed by a non-separator used to cause quadratic regex backtracking. + var longPath = Array(20001).join('/') + 'x'; + result.push([new Folder(longPath).name, new Folder(longPath).path.length, __join(longPath, 'child').length]); + result + "#, + ); + assert_eq!( + o.result, + json!([ + ["", "", "/child"], + ["", "", "/child"], + ["", "", "/child"], + ["b", "a", "a/b/child"], + ["b", "a", "a\\b/child"], + ["b", "a", "a/b/child"], + ["\n", "a", "a/\n/child"], + ["x", 19999, 20007] + ]) + ); +} + #[test] fn security_gate_for_files() { let dir = std::env::temp_dir().join(format!("ec-script-gate-{}", std::process::id())); diff --git a/crates/segment/src/lib.rs b/crates/segment/src/lib.rs index e04b7c336..adeb44881 100644 --- a/crates/segment/src/lib.rs +++ b/crates/segment/src/lib.rs @@ -14,8 +14,8 @@ //! so official weights load as published. //! - [`mobilesam`]: MobileSAM (Apache-2.0) and [`mediapipe`]: MediaPipe Face Landmarker //! (Apache-2.0), in plain Rust ([`nn`] kernels on rayon). -#![forbid(unsafe_code)] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::todo, clippy::unreachable)] +#![forbid(unsafe_code)] pub mod face; pub mod mediapipe; diff --git a/crates/segment/src/mobilesam.rs b/crates/segment/src/mobilesam.rs index 22d98ba7c..6599b018a 100644 --- a/crates/segment/src/mobilesam.rs +++ b/crates/segment/src/mobilesam.rs @@ -191,7 +191,7 @@ impl Block { v[t * dim + hh * hd..][..hd].copy_from_slice(&s[2 * hd..]); } } - nn::attention(&q, &k, &v, n, n, heads, hd, Some(&self.bias)) + nn::attention(&q, &k, &v, nn::AttentionShape { query_tokens: n, key_tokens: n, heads, head_dim: hd }, Some(&self.bias)) }) .collect(); // Un-window into the real grid, project, residual. @@ -325,7 +325,7 @@ impl Attn { fn forward(&self, q: &[f32], nq: usize, k: &[f32], v: &[f32], nk: usize) -> Vec { let (q, k, v) = (self.q.forward(q, nq), self.k.forward(k, nk), self.v.forward(v, nk)); let heads = 8; - let o = nn::attention(&q, &k, &v, nq, nk, heads, self.q.out / heads, None); + let o = nn::attention(&q, &k, &v, nn::AttentionShape { query_tokens: nq, key_tokens: nk, heads, head_dim: self.q.out / heads }, None); self.out.forward(&o, nq) } } diff --git a/crates/segment/src/nn.rs b/crates/segment/src/nn.rs index 4cf7a5344..713b392b8 100644 --- a/crates/segment/src/nn.rs +++ b/crates/segment/src/nn.rs @@ -334,9 +334,19 @@ pub fn softmax_rows(x: &mut [f32], n: usize) { } } -/// Multi-head scaled dot-product attention of `nq` queries over `nk` keys/values, each `heads × -/// hd` wide (token-major, heads side by side), plus an optional per-head bias `[heads × nq × nk]`. -pub fn attention(q: &[f32], k: &[f32], v: &[f32], nq: usize, nk: usize, heads: usize, hd: usize, bias: Option<&[f32]>) -> Vec { +/// Token counts and channel layout shared by the query, key and value tensors. +#[derive(Clone, Copy, Debug)] +pub struct AttentionShape { + pub query_tokens: usize, + pub key_tokens: usize, + pub heads: usize, + pub head_dim: usize, +} + +/// Multi-head scaled dot-product attention with token-major tensors, heads side by side, +/// and an optional per-head bias `[heads × query_tokens × key_tokens]`. +pub fn attention(q: &[f32], k: &[f32], v: &[f32], shape: AttentionShape, bias: Option<&[f32]>) -> Vec { + let AttentionShape { query_tokens: nq, key_tokens: nk, heads, head_dim: hd } = shape; let dim = heads * hd; let mut out = vec![0.0f32; nq * dim]; if q.len() < nq * dim || k.len() < nk * dim || v.len() < nk * dim { @@ -372,6 +382,37 @@ pub fn attention(q: &[f32], k: &[f32], v: &[f32], nq: usize, nk: usize, heads: u mod tests { use super::*; + #[test] + fn multi_head_cross_attention_matches_direct_sums() { + let shape = AttentionShape { query_tokens: 2, key_tokens: 3, heads: 2, head_dim: 2 }; + let q = [1.0, -0.5, 0.25, 2.0, -1.0, 0.75, 1.5, -0.25]; + let k = [0.5, 1.0, -0.5, 0.25, 1.5, -0.25, 0.75, 1.0, -1.0, 0.5, 2.0, -1.5]; + let v = [1.0, 2.0, 3.0, 4.0, -1.0, 0.5, 1.5, -2.0, 0.25, 0.75, -0.5, 2.5]; + let bias = [0.0, 0.5, -0.5, 1.0, 0.0, -1.0, -0.25, 0.25, 0.5, 0.0, -0.5, 1.0]; + for bias in [None, Some(bias.as_slice())] { + let actual = attention(&q, &k, &v, shape, bias); + for query in 0..shape.query_tokens { + for head in 0..shape.heads { + let mut scores: Vec = (0..shape.key_tokens) + .map(|key| { + let dot: f32 = (0..shape.head_dim).map(|channel| q[query * 4 + head * 2 + channel] * k[key * 4 + head * 2 + channel]).sum(); + dot / (shape.head_dim as f32).sqrt() + bias.map_or(0.0, |b| b[(head * 2 + query) * 3 + key]) + }) + .collect(); + let max = scores.iter().copied().fold(f32::NEG_INFINITY, f32::max); + for score in &mut scores { + *score = (*score - max).exp(); + } + let total: f32 = scores.iter().sum(); + for channel in 0..shape.head_dim { + let expected: f32 = scores.iter().enumerate().map(|(key, score)| score / total * v[key * 4 + head * 2 + channel]).sum(); + assert!((actual[query * 4 + head * 2 + channel] - expected).abs() < 1e-6); + } + } + } + } + } + #[test] fn gemm_matches_naive() { let (m, k, n) = (130, 17, 9); diff --git a/crates/text/src/fonts.rs b/crates/text/src/fonts.rs index 8475525cd..a973335f4 100644 --- a/crates/text/src/fonts.rs +++ b/crates/text/src/fonts.rs @@ -292,15 +292,37 @@ impl Face { } struct Db { + fallback: Arc, faces: Vec>, scanned: bool, } +/// Keep the compiled-in regular face available independently of discovery results. +fn bundled_default_face() -> Arc { + let info = + read_faces_bytes(INTER_REGULAR).into_iter().next().map(|n| info_from(n, FaceData::Static(INTER_REGULAR), "bundled")).unwrap_or_else(|| FaceInfo { + family: DEFAULT_FAMILY.into(), + style: "Regular".into(), + weight: 400, + italic: false, + index: 0, + data: FaceData::Static(INTER_REGULAR), + origin: "bundled", + native_family: None, + full_name: "Inter Regular".into(), + postscript: Some("Inter-Regular".into()), + variable: false, + instances: Vec::new(), + }); + Arc::new(Face { id: 0, info, bytes: OnceLock::new(), shaper: OnceLock::new() }) +} + fn db() -> &'static RwLock { static DB: OnceLock> = OnceLock::new(); DB.get_or_init(|| { - let mut db = Db { faces: Vec::new(), scanned: false }; - for b in [INTER_REGULAR, INTER_MEDIUM, INTER_SEMIBOLD, INTER_BOLD, INTER_ITALIC, JETBRAINS_MONO_REGULAR, NOTO_SERIF_REGULAR] { + let fallback = bundled_default_face(); + let mut db = Db { faces: vec![fallback.clone()], fallback, scanned: false }; + for b in [INTER_MEDIUM, INTER_SEMIBOLD, INTER_BOLD, INTER_ITALIC, JETBRAINS_MONO_REGULAR, NOTO_SERIF_REGULAR] { for n in read_faces_bytes(b) { push(&mut db, info_from(n, FaceData::Static(b), "bundled")); } @@ -394,10 +416,7 @@ pub fn system_scanned() -> bool { /// A face by id (panics never: unknown ids give the default face). pub fn face(id: FaceId) -> Arc { let d = db().read().unwrap_or_else(std::sync::PoisonError::into_inner); - // The bundled fonts are compiled in and always parse (`tests::bundled_families_and_resolution` checks them), so - // the database is never empty. - #[allow(clippy::expect_used)] - d.faces.get(id).or_else(|| d.faces.first()).cloned().expect("bundled fonts present") + d.faces.get(id).cloned().unwrap_or_else(|| d.fallback.clone()) } /// All faces (bundled first). diff --git a/crates/text/src/fonts/tests.rs b/crates/text/src/fonts/tests.rs index c703bdd9a..079a001e0 100644 --- a/crates/text/src/fonts/tests.rs +++ b/crates/text/src/fonts/tests.rs @@ -469,3 +469,12 @@ fn metrics_and_coverage() { assert!(!f.has_char('\u{5d0}'), "Inter has no Hebrew"); assert!(f.has_feature(b"liga") || f.has_feature(b"calt")); } + +#[test] +fn unknown_face_id_returns_bundled_default() { + let fallback = face(usize::MAX); + assert_eq!(fallback.id, 0); + assert_eq!(fallback.info.family, DEFAULT_FAMILY); + assert!(fallback.font().is_some()); + assert!(Arc::ptr_eq(&fallback, &face(0))); +} diff --git a/crates/track/src/camtrack/bundle.rs b/crates/track/src/camtrack/bundle.rs index 077f04b71..7f8dc5bcc 100644 --- a/crates/track/src/camtrack/bundle.rs +++ b/crates/track/src/camtrack/bundle.rs @@ -12,7 +12,7 @@ use rayon::prelude::*; use super::Distortion; use super::geometry::Pose; -use super::linalg::*; +use super::linalg::{M3, V3, add, cholesky_solve, dot, inv3, mm, mtv, mv, rodrigues, scale, skew, sub}; /// A camera in the adjustment. #[derive(Clone, Copy, Debug, PartialEq)] @@ -174,6 +174,29 @@ fn cam_jac(l: &Layout, ci: usize, c: &BaCam, xc: V3, a: &[[f64; 3]; 2], p: [f64; } } +/// Smooth per-camera log focal lengths in the normal equations. +fn add_focal_prior(cams: &[BaCam], o: &BaOpts, l: &Layout, s_mat: &mut [f64], bc: &mut [f64]) { + let n = l.n; + // Focal prior. + if let FocalMode::PerCamera(k) = o.focal { + for i in 0..cams.len().saturating_sub(1) { + let r = k * (cams[i].f.ln() - cams[i + 1].f.ln()); + let (a, b) = (l.fidx[i], l.fidx[i + 1]); + let ia = (a != usize::MAX).then_some(a); + let ib = (b != usize::MAX).then_some(b); + for (idx, g) in [(ia, k), (ib, -k)] { + let Some(x) = idx else { continue }; + bc[x] -= g * r; + for (idy, h) in [(ia, k), (ib, -k)] { + if let Some(y) = idy { + s_mat[x * n + y] += g * h; + } + } + } + } + } +} + /// Run Levenberg–Marquardt. `cams` and `pts` are updated in place. pub fn adjust(cams: &mut [BaCam], pts: &mut [V3], obs: &[BaObs], o: &BaOpts) -> BaReport { let l = layout(cams.len(), o); @@ -298,24 +321,7 @@ pub fn adjust(cams: &mut [BaCam], pts: &mut [V3], obs: &[BaObs], o: &BaOpts) -> let sys = (0..npts).map(|j| build_point(j, &mut s, &mut b)).collect(); (s, b, sys) }; - // Focal prior. - if let FocalMode::PerCamera(k) = o.focal { - for i in 0..cams.len().saturating_sub(1) { - let r = k * (cams[i].f.ln() - cams[i + 1].f.ln()); - let (a, b) = (l.fidx[i], l.fidx[i + 1]); - let ia = (a != usize::MAX).then_some(a); - let ib = (b != usize::MAX).then_some(b); - for (idx, g) in [(ia, k), (ib, -k)] { - let Some(x) = idx else { continue }; - bc[x] -= g * r; - for (idy, h) in [(ia, k), (ib, -k)] { - if let Some(y) = idy { - s_mat[x * n + y] += g * h; - } - } - } - } - } + add_focal_prior(cams, o, &l, &mut s_mat, &mut bc); let base_s = s_mat.clone(); let base_bc = bc.clone(); let mean_diag = ((0..n).map(|i| base_s[i * n + i]).sum::() / n.max(1) as f64).max(1e-12); diff --git a/crates/track/src/camtrack/geometry.rs b/crates/track/src/camtrack/geometry.rs index 6956ac2b5..5d74ad9b3 100644 --- a/crates/track/src/camtrack/geometry.rs +++ b/crates/track/src/camtrack/geometry.rs @@ -5,7 +5,10 @@ //! All image points here are **normalised** (calibrated) coordinates `x = (u − c) / f`. //! A camera is `(R, C)` with `X_cam = R (X − C)`. -use super::linalg::*; +use super::linalg::{ + I3, M3, V3, add, col, cross, det3, dot, from_cols, madd, mm, mscale, mt, mtv, mv, nearest_rotation, normalize, null_vector, outer, scale, sub, svd3, + sym_eigen, +}; /// A deterministic xorshift generator for RANSAC samples. pub struct Rng(pub u64); @@ -361,6 +364,7 @@ pub fn procrustes(a: &[V3], b: &[V3]) -> Option { #[cfg(test)] mod tests { + use super::super::linalg::{log_rot, norm, rodrigues}; use super::*; fn scene() -> (Vec, Pose) { diff --git a/crates/track/src/camtrack/solver.rs b/crates/track/src/camtrack/solver.rs index a4e2c21e6..d00f7106f 100644 --- a/crates/track/src/camtrack/solver.rs +++ b/crates/track/src/camtrack/solver.rs @@ -7,7 +7,7 @@ use rayon::prelude::*; use super::bundle::{self, BaCam, BaObs, BaOpts, FocalMode}; use super::geometry::{self, Pose}; -use super::linalg::*; +use super::linalg::{V3, add, log_rot, mm, mt, mtv, mv, norm, normalize, rodrigues, scale, slerp, sub, sym_eigen}; use super::{CameraSolve, CameraTracks, Distortion, ShotType, SolveMethod, SolveSettings, SolvedFrame, SolvedPoint}; use crate::fit::{Model, ransac}; diff --git a/crates/track/src/face.rs b/crates/track/src/face.rs index 45c7c4baa..b65dc3d81 100644 --- a/crates/track/src/face.rs +++ b/crates/track/src/face.rs @@ -547,6 +547,32 @@ fn fit_from_face(face: &Face, topo: &Topology, offset: [f64; 2]) -> Option Option<(usize, usize)> { + let (w, h) = size; + let (ci, cj) = center; + for r in 0..radius { + for dj in -r..=r { + for di in -r..=r { + if di.abs() != r && dj.abs() != r { + continue; + } + let (i, j) = (ci as i64 + di, cj as i64 + dj); + if i >= 0 && j >= 0 && (i as usize) < w && (j as usize) < h && skin[j as usize * w + i as usize] { + return Some((i as usize, j as usize)); + } + } + } + } + None +} + +struct FaceGeometry { + center: [f64; 2], + height: f64, + roll: f64, +} + /// A pixel window of a frame in layer coordinates. struct Window { x0: i64, @@ -752,6 +778,59 @@ impl FaceTracker { Some(fit) } + /// Connected dark features inside the fitted face, in normalized face coordinates. + fn feature_blobs(&self, frame: &Frame, win: &Window, skin: &[bool], luma: &[f32], outline: &[[f64; 2]], geometry: FaceGeometry) -> Vec { + let FaceGeometry { center, height: b, roll } = geometry; + let (w, h) = (win.w, win.h); + // Non-skin components inside the face outline (shrunk a little: anti-aliased edges). + let inner: Vec<[f64; 2]> = outline.iter().map(|p| [center[0] + 0.9 * (p[0] - center[0]), center[1] + 0.9 * (p[1] - center[1])]).collect(); + let mut feat = vec![false; w * h]; + for j in 0..h { + for i in 0..w { + if !skin[j * w + i] && crate::mask::inside_polygon(&inner, win.layer(frame, i, j)) { + feat[j * w + i] = true; + } + } + } + let min_area = (0.0006 * b * b).max(2.0) as usize; + let mut seen = vec![false; w * h]; + let mut blobs: Vec = vec![]; + let skin_luma = self.skin.med[0] as f64; + for j0 in 0..h { + for i0 in 0..w { + if !feat[j0 * w + i0] || seen[j0 * w + i0] { + continue; + } + let mut st = vec![(i0, j0)]; + seen[j0 * w + i0] = true; + let mut pts = vec![]; + while let Some((i, j)) = st.pop() { + let q = to_face(center, b, roll, win.layer(frame, i, j)); + pts.push((q, skin_luma - luma[j * w + i] as f64)); + for dj in -1i64..=1 { + for di in -1i64..=1 { + let (a, c) = (i as i64 + di, j as i64 + dj); + if a < 0 || c < 0 || a as usize >= w || c as usize >= h { + continue; + } + let (a, c) = (a as usize, c as usize); + if feat[c * w + a] && !seen[c * w + a] { + seen[c * w + a] = true; + st.push((a, c)); + } + } + } + } + if pts.len() >= min_area { + let n = pts.len() as f64; + let centroid = [pts.iter().map(|p| p.0[0]).sum::() / n, pts.iter().map(|p| p.0[1]).sum::() / n]; + blobs.push(Blob { pts, centroid }); + } + } + } + blobs + } + fn fit(&self, frame: &Frame) -> Option { let pv = &self.prev; let b0 = pv.height; @@ -769,22 +848,7 @@ impl FaceTracker { } // Seed: the skin pixel nearest the predicted centre. let (ci, cj) = win.cell(frame, pv.center)?; - let mut seed = None; - 'search: for r in 0..(0.6 * b0).ceil() as i64 { - for dj in -r..=r { - for di in -r..=r { - if di.abs() != r && dj.abs() != r { - continue; - } - let (i, j) = (ci as i64 + di, cj as i64 + dj); - if i >= 0 && j >= 0 && (i as usize) < w && (j as usize) < h && skin[j as usize * w + i as usize] { - seed = Some((i as usize, j as usize)); - break 'search; - } - } - } - } - let (si, sj) = seed?; + let (si, sj) = nearest_skin(&skin, (w, h), (ci, cj), (0.6 * b0).ceil() as i64)?; // Flood fill (4-connected). let mut comp = vec![false; w * h]; let mut stack = vec![(si, sj)]; @@ -855,52 +919,7 @@ impl FaceTracker { roll += 180.0; } let b = major; - // Non-skin components inside the face outline (shrunk a little: anti-aliased edges). - let inner: Vec<[f64; 2]> = outline.iter().map(|p| [center[0] + 0.9 * (p[0] - center[0]), center[1] + 0.9 * (p[1] - center[1])]).collect(); - let mut feat = vec![false; w * h]; - for j in 0..h { - for i in 0..w { - if !skin[j * w + i] && crate::mask::inside_polygon(&inner, win.layer(frame, i, j)) { - feat[j * w + i] = true; - } - } - } - let min_area = (0.0006 * b * b).max(2.0) as usize; - let mut seen = vec![false; w * h]; - let mut blobs: Vec = vec![]; - let skin_luma = self.skin.med[0] as f64; - for j0 in 0..h { - for i0 in 0..w { - if !feat[j0 * w + i0] || seen[j0 * w + i0] { - continue; - } - let mut st = vec![(i0, j0)]; - seen[j0 * w + i0] = true; - let mut pts = vec![]; - while let Some((i, j)) = st.pop() { - let q = to_face(center, b, roll, win.layer(frame, i, j)); - pts.push((q, skin_luma - luma[j * w + i] as f64)); - for dj in -1i64..=1 { - for di in -1i64..=1 { - let (a, c) = (i as i64 + di, j as i64 + dj); - if a < 0 || c < 0 || a as usize >= w || c as usize >= h { - continue; - } - let (a, c) = (a as usize, c as usize); - if feat[c * w + a] && !seen[c * w + a] { - seen[c * w + a] = true; - st.push((a, c)); - } - } - } - } - if pts.len() >= min_area { - let n = pts.len() as f64; - let centroid = [pts.iter().map(|p| p.0[0]).sum::() / n, pts.iter().map(|p| p.0[1]).sum::() / n]; - blobs.push(Blob { pts, centroid }); - } - } - } + let blobs = self.feature_blobs(frame, &win, &skin, &luma, &outline, FaceGeometry { center, height: b, roll }); // Match components to features near their expected positions. let ex = &self.expect; let mean_of = |ids: &[usize]| { diff --git a/crates/track/src/roto.rs b/crates/track/src/roto.rs index 305065d2c..0948d3fe0 100644 --- a/crates/track/src/roto.rs +++ b/crates/track/src/roto.rs @@ -407,7 +407,9 @@ fn beta(img: &[[f32; 3]], free: &[bool], w: usize, h: usize) -> f64 { /// One min-cut over the `free` pixels; others keep `labels`. `prior`: P(foreground) per pixel /// and how much it weighs against the colour models. -fn cut(img: &[[f32; 3]], w: usize, h: usize, labels: &mut [u8], free: &[bool], hard: &[u8], fg: &Gmm, bg: &Gmm, prior: Option<(&[f32], f64)>) { +fn cut(img: &[[f32; 3]], size: (usize, usize), labels: &mut [u8], free: &[bool], hard: &[u8], models: (&Gmm, &Gmm), prior: Option<(&[f32], f64)>) { + let (w, h) = size; + let (fg, bg) = models; let mut idx = vec![u32::MAX; w * h]; let mut nodes = vec![]; for i in 0..w * h { @@ -622,6 +624,36 @@ pub struct Prior { pub weight: f64, } +/// Fit foreground/background colour models from strokes and the propagated matte. +fn initial_models(rgb: &[[f32; 3]], hard: &[u8], fgs: &[bool], strokes: &[&Stroke], prior: Option<&Prior>, opts: &SegOpts, size: (usize, usize)) -> (Gmm, Gmm) { + let (w, h) = size; + // Initial colour models: the strokes (and the propagated matte). + match prior { + Some(p) => { + let fgc = samples(rgb, |i| (hard[i] == HARD_FG) || (hard[i] == 0 && p.soft[i] >= 0.5)); + let bgc = samples(rgb, |i| (hard[i] == HARD_BG) || (hard[i] == 0 && p.soft[i] < 0.5)); + let f = Gmm::fit(&fgc, COMPONENTS, MAX_SAMPLES); + let b = Gmm::fit(&bgc, COMPONENTS, MAX_SAMPLES); + (if f.is_empty() { p.fg.clone() } else { f }, if b.is_empty() { p.bg.clone() } else { b }) + } + None => { + let fgc = samples(rgb, |i| hard[i] == HARD_FG); + let mut bgc = samples(rgb, |i| hard[i] == HARD_BG); + if bgc.is_empty() { + // No background strokes: what is far from the foreground strokes. + let d = matting::distance(fgs, w, h); + let r = strokes.iter().filter(|s| s.kind == StrokeKind::Fg).map(|s| s.radius).fold(1.0, f64::max) * opts.scale; + let far = (4.0 * r).max(0.25 * w.max(h) as f64) as f32; + bgc = samples(rgb, |i| d[i] > far); + if bgc.is_empty() { + bgc = samples(rgb, |i| hard[i] != HARD_FG); + } + } + (Gmm::fit(&fgc, COMPONENTS, MAX_SAMPLES), Gmm::fit(&bgc, COMPONENTS, MAX_SAMPLES)) + } + } +} + /// Segment a frame from its strokes (and a propagated prior): GrabCut iterations on a reduced /// copy, then a full-resolution cut in a band around the boundary. pub fn segment(img: &Image, strokes: &[&Stroke], prior: Option<&Prior>, opts: &SegOpts, refine_radius: f64) -> FrameSeg { @@ -649,31 +681,7 @@ pub fn segment(img: &Image, strokes: &[&Stroke], prior: Option<&Prior>, opts: &S let soft_prior: Option> = prior.map(|p| if p.weight > PRIOR { p.soft.clone() } else { matting::gauss(&p.soft, w, h, (opts.search_radius * opts.scale / 4.0).max(1.0)) }); let pw = prior.map_or(PRIOR, |p| p.weight); - // Initial colour models: the strokes (and the propagated matte). - let (fg0, bg0) = match prior { - Some(p) => { - let fgc = samples(&rgb, |i| (hard[i] == HARD_FG) || (hard[i] == 0 && p.soft[i] >= 0.5)); - let bgc = samples(&rgb, |i| (hard[i] == HARD_BG) || (hard[i] == 0 && p.soft[i] < 0.5)); - let f = Gmm::fit(&fgc, COMPONENTS, MAX_SAMPLES); - let b = Gmm::fit(&bgc, COMPONENTS, MAX_SAMPLES); - (if f.is_empty() { p.fg.clone() } else { f }, if b.is_empty() { p.bg.clone() } else { b }) - } - None => { - let fgc = samples(&rgb, |i| hard[i] == HARD_FG); - let mut bgc = samples(&rgb, |i| hard[i] == HARD_BG); - if bgc.is_empty() { - // No background strokes: what is far from the foreground strokes. - let d = matting::distance(&fgs, w, h); - let r = strokes.iter().filter(|s| s.kind == StrokeKind::Fg).map(|s| s.radius).fold(1.0, f64::max) * opts.scale; - let far = (4.0 * r).max(0.25 * w.max(h) as f64) as f32; - bgc = samples(&rgb, |i| d[i] > far); - if bgc.is_empty() { - bgc = samples(&rgb, |i| hard[i] != HARD_FG); - } - } - (Gmm::fit(&fgc, COMPONENTS, MAX_SAMPLES), Gmm::fit(&bgc, COMPONENTS, MAX_SAMPLES)) - } - }; + let (fg0, bg0) = initial_models(&rgb, &hard, &fgs, strokes, prior, opts, (w, h)); let iters = if opts.best { 4 } else { 3 }; let work = if opts.best { 960 } else { 480 }; let f = w.max(h).div_ceil(work).max(1); @@ -721,7 +729,7 @@ pub fn segment(img: &Image, strokes: &[&Stroke], prior: Option<&Prior>, opts: &S }; let free = vec![true; cw * ch]; for it in 0..iters { - cut(&crgb, cw, ch, &mut cl, &free, &chard, &fgm, &bgm, cprior.as_deref().map(|c| (c, pw))); + cut(&crgb, (cw, ch), &mut cl, &free, &chard, (&fgm, &bgm), cprior.as_deref().map(|c| (c, pw))); if it + 1 < iters { let (a, b) = fit_models(&crgb, &cl); if !a.is_empty() && !b.is_empty() { @@ -743,11 +751,11 @@ pub fn segment(img: &Image, strokes: &[&Stroke], prior: Option<&Prior>, opts: &S let sd = matting::signed_distance(&labels, w, h); let band = f as f32 + 1.5; let free: Vec = sd.iter().map(|d| d.abs() <= band).collect(); - cut(&rgb, w, h, &mut labels, &free, &hard, &fgm, &bgm, soft_prior.as_deref().map(|c| (c, pw))); + cut(&rgb, (w, h), &mut labels, &free, &hard, (&fgm, &bgm), soft_prior.as_deref().map(|c| (c, pw))); } else { let free = vec![true; w * h]; for it in 0..iters { - cut(&rgb, w, h, &mut labels, &free, &hard, &fgm, &bgm, soft_prior.as_deref().map(|c| (c, pw))); + cut(&rgb, (w, h), &mut labels, &free, &hard, (&fgm, &bgm), soft_prior.as_deref().map(|c| (c, pw))); if it + 1 < iters { let (a, b) = fit_models(&rgb, &labels); if !a.is_empty() && !b.is_empty() { @@ -843,7 +851,7 @@ pub fn propagate(prev_img: &Image, prev: &FrameSeg, next_img: &Image, strokes: & let hard = vec![0u8; w * h]; let soft = matting::gauss(&prior.soft, w, h, (r as f64 / 4.0).max(1.0)); let (fg, bg) = if prev.fg.is_empty() || prev.bg.is_empty() { fit_models(&rgb, &labels) } else { (prev.fg.clone(), prev.bg.clone()) }; - cut(&rgb, w, h, &mut labels, &free, &hard, &fg, &bg, Some((&soft, PRIOR))); + cut(&rgb, (w, h), &mut labels, &free, &hard, (&fg, &bg), Some((&soft, PRIOR))); let seeds = core_of(&prior.soft, w, h, r as f64); clean(&mut labels, &seeds, &vec![false; w * h], w, h); finish(next_img, &rgb, labels, strokes, Some(&prior.refine), opts, refine_radius) diff --git a/crates/track/src/roto/matting.rs b/crates/track/src/roto/matting.rs index 439d9c43e..cb22b680a 100644 --- a/crates/track/src/roto/matting.rs +++ b/crates/track/src/roto/matting.rs @@ -200,7 +200,8 @@ pub fn guided_filter(img: &[[f32; 3]], p: &[f32], w: usize, h: usize, r: usize, /// (K. He, J. Sun and X. Tang, "Fast Matting Using Large Kernel Matting Laplacian Matrices", /// CVPR 2010): `(L·x)ᵢ = Σ_{k∋i} (xᵢ − a_kᵀIᵢ − b_k)` with `(a_k, b_k)` the ridge-regression /// fit of `x` to the colours in window k. -pub fn closed_form(img: &[[f32; 3]], init: &[f32], known: &[bool], w: usize, h: usize, r: usize, eps: f32, iters: usize) -> Vec { +pub fn closed_form(img: &[[f32; 3]], init: &[f32], known: &[bool], size: (usize, usize), r: usize, eps: f32, iters: usize) -> Vec { + let (w, h) = size; let n = w * h; if n == 0 || known.iter().all(|k| *k) { return init.to_vec(); diff --git a/crates/track/src/roto/refine.rs b/crates/track/src/roto/refine.rs index 2a498e286..71f9261b2 100644 --- a/crates/track/src/roto/refine.rs +++ b/crates/track/src/roto/refine.rs @@ -123,7 +123,7 @@ pub fn matte_alpha( q[j] = if band[j] { q[j].clamp(0.0, 1.0) } else { cp[j] }; } if p.higher_quality { - q = matting::closed_form(&cimg, &q, &outside, cw, ch, 1, 1e-5 * (1.0 + p.smooth.max(0.0)) as f32, 50); + q = matting::closed_form(&cimg, &q, &outside, (cw, ch), 1, 1e-5 * (1.0 + p.smooth.max(0.0)) as f32, 50); } // Refine Edge Matte adjustments. if p.smooth > 1.0 { @@ -177,7 +177,8 @@ pub fn matte_alpha( /// Decontaminate edge colours of `rgb` in place for the matte `alpha` (see /// [`matting::decontaminate`]); returns the decontamination map. `band`: the Refine Edge band /// (pixels where decontamination always applies). -pub fn decontaminate(rgb: &mut [[f32; 3]], alpha: &[f32], band: &[u8], w: usize, h: usize, p: &MatteParams, band_radius: f64, scale: f64) -> Vec { +pub fn decontaminate(rgb: &mut [[f32; 3]], alpha: &[f32], band: &[u8], size: (usize, usize), p: &MatteParams, band_radius: f64, scale: f64) -> Vec { + let (w, h) = size; if !p.decontaminate { return vec![0.0; w * h]; } diff --git a/crates/track/src/roto/tests.rs b/crates/track/src/roto/tests.rs index 3c4645cb2..92c7bec04 100644 --- a/crates/track/src/roto/tests.rs +++ b/crates/track/src/roto/tests.rs @@ -246,7 +246,7 @@ fn refine_edge_recovers_a_soft_edge_and_decontaminates() { assert!(frac as f64 > 0.6 * n as f64, "{frac}/{n} fractional"); // Decontamination: edge pixels' colour is the foreground's, not a blue blend. let mut col = rgb.clone(); - let map = decontaminate(&mut col, &a, &seg.refine, W as usize, H as usize, &p, 9.0, 1.0); + let map = decontaminate(&mut col, &a, &seg.refine, (W as usize, H as usize), &p, 9.0, 1.0); let (mut blue_before, mut blue_after, mut m) = (0.0f64, 0.0f64, 0usize); for i in 0..a.len() { if a[i] > 0.2 && a[i] < 0.8 { diff --git a/crates/ui-egui/src/app/files.rs b/crates/ui-egui/src/app/files.rs index da6aef559..b1beab972 100644 --- a/crates/ui-egui/src/app/files.rs +++ b/crates/ui-egui/src/app/files.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Dialog, EffectcraftApp}; impl EffectcraftApp { /// Show the crash-recovery dialog for a previous run that didn't exit cleanly. diff --git a/crates/ui-egui/src/app/gpu.rs b/crates/ui-egui/src/app/gpu.rs index 6b486997e..9febfa413 100644 --- a/crates/ui-egui/src/app/gpu.rs +++ b/crates/ui-egui/src/app/gpu.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Arc, EffectcraftApp, RenderSource, gpu_failure, menus, panels}; impl EffectcraftApp { pub fn render_source(&self) -> RenderSource { @@ -184,6 +184,7 @@ impl EffectcraftApp { #[cfg(test)] mod gpu_failure_tests { use super::*; + use crate::{FrameKey, ItemId, RenderOpts, Session, Tick, json}; #[test] fn host_failure_preserves_document_undo_and_cpu_pixels_and_detaches_late_readbacks() { diff --git a/crates/ui-egui/src/app/prefetch.rs b/crates/ui-egui/src/app/prefetch.rs index b55884117..1fdf0aad1 100644 --- a/crates/ui-egui/src/app/prefetch.rs +++ b/crates/ui-egui/src/app/prefetch.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{EffectcraftApp, FrameKey, FrameWindow, ItemId, PlaybackPass, audio, frames}; /// Seconds without a project change before paused prefetch resumes (see /// [`EffectcraftApp::editing`]). diff --git a/crates/ui-egui/src/app/prefs.rs b/crates/ui-egui/src/app/prefs.rs index 5ccb3ca18..6fc424e01 100644 --- a/crates/ui-egui/src/app/prefs.rs +++ b/crates/ui-egui/src/app/prefs.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{EffectcraftApp, Tokens, json, theme}; impl EffectcraftApp { /// Apply changed settings: theme and brightness, label colours, tool tips, preview caches. @@ -67,6 +67,7 @@ impl EffectcraftApp { #[cfg(test)] mod appearance_tests { use super::*; + use crate::menus; use crate::theme::ThemeKind; fn app() -> EffectcraftApp { diff --git a/crates/ui-egui/src/app/screenshot.rs b/crates/ui-egui/src/app/screenshot.rs index 12092f2f3..a80a76bd6 100644 --- a/crates/ui-egui/src/app/screenshot.rs +++ b/crates/ui-egui/src/app/screenshot.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{DrawGeometry, EffectcraftApp, PendingShot, Sender, ShotTarget, control, json}; /// How long a screenshot request waits for a frame, from admission (app clock seconds). pub(crate) const SCREENSHOT_TIMEOUT_S: f64 = 8.0; diff --git a/crates/ui-egui/src/header.rs b/crates/ui-egui/src/header.rs index 861d6fd91..3eee89453 100644 --- a/crates/ui-egui/src/header.rs +++ b/crates/ui-egui/src/header.rs @@ -25,11 +25,19 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { p.rect_filled(rect, 0.0, t.header_bg); p.line_segment([rect.left_bottom(), rect.right_bottom()], Stroke::new(1.0, t.app_bg)); let cy = rect.center().y; - let mut x = rect.min.x + if app.integrated_titlebar && !app.ui.show_menu_bar { 80.0 } else { 10.0 }; + let x = rect.min.x + if app.integrated_titlebar && !app.ui.show_menu_bar { 80.0 } else { 10.0 }; + let x = brand_and_home(app, ui, &p, x, cy); + let x = tool_slots(app, ui, &p, x, cy); + let x = tool_options_bar(app, ui, &p, x + 10.0, cy); + let x = snapping_toggle(app, ui, &p, x, cy); + right_side(app, ui, &p, rect, x, cy); +} - // Brand mark. +/// The brand mark (About) and the Home button; returns the x after them. +fn brand_and_home(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, mut x: f32, cy: f32) -> f32 { + let t = app.tokens; let brand = Rect::from_min_size(pos2(x, cy - 12.0), vec2(24.0, 24.0)); - paint_logo(&p, brand); + paint_logo(p, brand); let bresp = ui.interact(brand, egui::Id::unique("brand"), Sense::click()); app.auto.add_response("header.about", &bresp, "About EffectCraft"); widgets::focus_outline(ui, &bresp, &t); @@ -38,7 +46,6 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } x += 32.0; - // Home. let home = Rect::from_min_size(pos2(x, cy - 13.0), vec2(26.0, 26.0)); let home_response = widgets::icon_button(ui, home, Icon::Home, app.ui.start_screen, &t, egui::Id::unique("tool-home")).on_hover_text(tr("Home")); app.auto.add_response("header.home", &home_response, "Home"); @@ -47,78 +54,19 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } x += 30.0; p.line_segment([pos2(x, cy - 10.0), pos2(x, cy + 10.0)], Stroke::new(1.0, t.separator)); - x += 6.0; + x + 6.0 +} - // Tool slots (with group separators after camera tools and pan-behind). +/// The tool slots (with group separators after camera tools and pan-behind); returns the x after them. +fn tool_slots(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, mut x: f32, cy: f32) -> f32 { + let t = app.tokens; let held_id = egui::Id::unique("tool-slot-held"); if ui.input(|i| i.pointer.any_pressed()) { ui.data_mut(|d| d.remove::(held_id)); } for (si, slot) in Tool::SLOTS.iter().enumerate() { - let cur = app.ui.slot_tools.get(si).copied().unwrap_or(slot[0]); let r = Rect::from_min_size(pos2(x, cy - 13.0), vec2(26.0, 26.0)); - let active = slot.contains(&app.ui.tool); - let id = egui::Id::unique(("tool-slot", si)); - let resp = widgets::icon_button(ui, r, cur.icon(), active, &t, id); - if slot.len() > 1 { - // Flyout corner triangle. - p.add(egui::Shape::convex_polygon( - vec![pos2(r.max.x - 2.0, r.max.y - 6.0), pos2(r.max.x - 2.0, r.max.y - 2.0), pos2(r.max.x - 6.0, r.max.y - 2.0)], - if active { Color32::WHITE } else { t.text_dim }, - Stroke::NONE, - )); - } - app.auto.add_response(&format!("tools.{cur:?}"), &resp, cur.label()); - let tip = match cur.shortcut() { - Some(s) => format!("{} ({})", cur.label(), crate::menus::shortcut_text(s)), - None => cur.label().to_string(), - }; - let resp = resp.on_hover_text(tip); - let held = ui.data(|d| d.get_temp::(held_id)) == Some(id); - if resp.clicked() && !held { - app.ui.tool = cur; - } - // Ctrl+double-click Pan Behind: Center Anchor Point in Layer Content. - if cur == Tool::PanBehind - && resp.double_clicked() - && ui.input(|i| i.modifiers.command) - && let Err(e) = crate::menus::invoke(app, ui.ctx(), "layer.centerAnchor", json!({})) - { - app.ui.status = e; - } - if slot.len() > 1 { - let held_for = resp.is_pointer_button_down_on().then(|| ui.input(|i| i.pointer.press_start_time().map(|s| i.time - s))).flatten(); - if let Some(seconds) = held_for - && seconds < 0.35 - { - ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(0.35 - seconds)); - } - let long_press = held_for.is_some_and(|seconds| seconds >= 0.35) && !held; - let command = if resp.secondary_clicked() || long_press { - if long_press { - ui.data_mut(|d| d.insert_temp(held_id, id)); - } - Some(egui::SetOpenCommand::Bool(true)) - } else if resp.clicked() && !held { - Some(egui::SetOpenCommand::Bool(false)) - } else { - None - }; - let close_behavior = if held && resp.clicked() { egui::PopupCloseBehavior::IgnoreClicks } else { egui::PopupCloseBehavior::CloseOnClickOutside }; - egui::Popup::menu(&resp).style(crate::widgets::menu_style).open_memory(command).close_behavior(close_behavior).show(|ui| { - for tool in slot.iter() { - let row = ui.selectable_label(app.ui.tool == *tool, tool.label()); - app.auto.add(&format!("tools.select.{tool:?}"), row.rect, tool.label()); - if row.clicked() { - app.ui.tool = *tool; - if let Some(saved) = app.ui.slot_tools.get_mut(si) { - *saved = *tool; - } - ui.close(); - } - } - }); - } + tool_slot(app, ui, p, si, slot, r, held_id); x += 28.0; if matches!(si, 2 | 5 | 7 | 10 | 13) { x += 4.0; @@ -126,17 +74,96 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { x += 6.0; } } + x +} - // Tool options. - x += 10.0; - // Selected shape layers: the options show their paint and edit it (as in After Effects). - let shape_layers: Vec = app - .session +/// One tool slot button in `r`: select on click, Ctrl+double-click Pan Behind, and the fly-out. +fn tool_slot(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, si: usize, slot: &[Tool], r: Rect, held_id: egui::Id) { + let t = app.tokens; + let cur = app.ui.slot_tools.get(si).copied().unwrap_or(slot[0]); + let active = slot.contains(&app.ui.tool); + let id = egui::Id::unique(("tool-slot", si)); + let resp = widgets::icon_button(ui, r, cur.icon(), active, &t, id); + if slot.len() > 1 { + // Flyout corner triangle. + p.add(egui::Shape::convex_polygon( + vec![pos2(r.max.x - 2.0, r.max.y - 6.0), pos2(r.max.x - 2.0, r.max.y - 2.0), pos2(r.max.x - 6.0, r.max.y - 2.0)], + if active { Color32::WHITE } else { t.text_dim }, + Stroke::NONE, + )); + } + app.auto.add_response(&format!("tools.{cur:?}"), &resp, cur.label()); + let tip = match cur.shortcut() { + Some(s) => format!("{} ({})", cur.label(), crate::menus::shortcut_text(s)), + None => cur.label().to_string(), + }; + let resp = resp.on_hover_text(tip); + let held = ui.data(|d| d.get_temp::(held_id)) == Some(id); + if resp.clicked() && !held { + app.ui.tool = cur; + } + // Ctrl+double-click Pan Behind: Center Anchor Point in Layer Content. + if cur == Tool::PanBehind + && resp.double_clicked() + && ui.input(|i| i.modifiers.command) + && let Err(e) = crate::menus::invoke(app, ui.ctx(), "layer.centerAnchor", json!({})) + { + app.ui.status = e; + } + if slot.len() > 1 { + slot_flyout(app, ui, si, slot, &resp, held_id); + } +} + +/// The fly-out menu of a multi-tool slot: right-click or press-and-hold opens it. +fn slot_flyout(app: &mut EffectcraftApp, ui: &mut egui::Ui, si: usize, slot: &[Tool], resp: &egui::Response, held_id: egui::Id) { + let id = resp.id; + let held = ui.data(|d| d.get_temp::(held_id)) == Some(id); + let held_for = resp.is_pointer_button_down_on().then(|| ui.input(|i| i.pointer.press_start_time().map(|s| i.time - s))).flatten(); + if let Some(seconds) = held_for + && seconds < 0.35 + { + ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(0.35 - seconds)); + } + let long_press = held_for.is_some_and(|seconds| seconds >= 0.35) && !held; + let command = if resp.secondary_clicked() || long_press { + if long_press { + ui.data_mut(|d| d.insert_temp(held_id, id)); + } + Some(egui::SetOpenCommand::Bool(true)) + } else if resp.clicked() && !held { + Some(egui::SetOpenCommand::Bool(false)) + } else { + None + }; + let close_behavior = if held && resp.clicked() { egui::PopupCloseBehavior::IgnoreClicks } else { egui::PopupCloseBehavior::CloseOnClickOutside }; + egui::Popup::menu(resp).style(crate::widgets::menu_style).open_memory(command).close_behavior(close_behavior).show(|ui| { + for tool in slot.iter() { + let row = ui.selectable_label(app.ui.tool == *tool, tool.label()); + app.auto.add(&format!("tools.select.{tool:?}"), row.rect, tool.label()); + if row.clicked() { + app.ui.tool = *tool; + if let Some(saved) = app.ui.slot_tools.get_mut(si) { + *saved = *tool; + } + ui.close(); + } + } + }); +} + +/// The ids of the selected shape layers: the options show their paint and edit it. +fn selected_shape_layers(app: &EffectcraftApp) -> Vec { + app.session .active_comp() .map(|c| { app.session.state.selected_layers.iter().filter(|id| c.layer(**id).is_some_and(|l| matches!(l.source, LayerSource::Shape))).map(|id| id.0).collect() }) - .unwrap_or_default(); + .unwrap_or_default() +} + +/// Mirrors the first selected shape layer's paint into the shape tool (as in After Effects). +fn sync_layer_paint(app: &mut EffectcraftApp, shape_layers: &[u64]) { let layer_paint = shape_layers.first().and_then(|id| app.session.active_comp()?.layer(LayerId(*id))).map(|l| { let lt = l.layer_time(app.session.time()); (effectcraft_engine::commands::shape_stroke::first_paint(l, lt), effectcraft_engine::commands::shape_stroke::first_paint_kinds(l, lt)) @@ -154,6 +181,13 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } tool.stroke_width = width.unwrap_or(0.0); } +} + +/// Options for the active tool (shape creation, fill and stroke, puppet, roto); returns the x after them. +fn tool_options_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, mut x: f32, cy: f32) -> f32 { + // Selected shape layers: the options show their paint and edit it (as in After Effects). + let shape_layers = selected_shape_layers(app); + sync_layer_paint(app, &shape_layers); // One undo step per picker session or width drag: end the merge once both are done. let picking = ["fill", "stroke"].iter().any(|k| widgets::popup_is_open(ui, picker_id(k))); if !picking && !ui.input(|i| i.pointer.any_down()) && app.session.history.merge_key.as_deref().is_some_and(|k| k.starts_with("tool-")) { @@ -167,14 +201,20 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { // Fill and Stroke, hidden while the tools draw masks. let masking = drawing && !shape_layers.is_empty() && app.session.state.shape_tool.creates_mask; if (drawing || !shape_layers.is_empty()) && !masking { - x = fill_stroke_options(app, ui, &p, &shape_layers, x, cy); + x = fill_stroke_options(app, ui, p, &shape_layers, x, cy); } if app.ui.tool.puppet_kind().is_some() { - x = puppet_options(app, ui, &p, x, cy); + x = puppet_options(app, ui, p, x, cy); } if app.ui.tool.roto_kind(false).is_some() { - x = roto_options(app, ui, &p, x, cy); + x = roto_options(app, ui, p, x, cy); } + x +} + +/// The Snapping checkbox, its label and options menu; returns the x where it started. +fn snapping_toggle(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, x: f32, cy: f32) -> f32 { + let t = app.tokens; let snap = Rect::from_min_size(pos2(x, cy - 10.0), vec2(20.0, 20.0)); // The engine owns snapping (View ▸ Snapping); the checkbox mirrors it. app.ui.snapping = app.session.state.snapping; @@ -185,9 +225,24 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { app.auto.add("header.snapping", snap, "Snapping"); let label = p.text(pos2(snap.max.x + 4.0, cy), Align2::LEFT_CENTER, tr("Snapping"), Tokens::ui(12.0), t.text_dim); snapping_options(app, ui, Rect::from_min_size(pos2(label.max.x + 2.0, cy - 9.0), vec2(18.0, 18.0))); + x +} - // Right side: community buttons, workspaces. - let mut rx = rect.max.x - 10.0; +/// Right side: community buttons, appearance and workspaces; `left` is where the tools end. +fn right_side(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, rect: Rect, left: f32, cy: f32) { + let t = app.tokens; + let rx = community_buttons(app, ui, p, rect.max.x - 10.0, cy); + let mut rx = appearance_button(app, ui, rx, cy); + rx -= 10.0; + p.line_segment([pos2(rx, cy - 10.0), pos2(rx, cy + 10.0)], Stroke::new(1.0, t.separator)); + rx -= 10.0; + let rx = workspace_menu(app, ui, p, rx, cy); + workspace_tabs(app, ui, p, rx, left, cy); +} + +/// The Discord pill and the GitHub and website buttons, right to left from `rx`; returns the new `rx`. +fn community_buttons(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, mut rx: f32, cy: f32) -> f32 { + let t = app.tokens; // The pill fits its label (icon, gap, text) with even padding: it was a fixed 104 px with the // text at its left, which left a wide gap on the right (#581). let label = p.layout_no_wrap(tr("Discord").to_string(), Tokens::semibold(12.0), Color32::WHITE); @@ -195,7 +250,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let dresp = ui.interact(discord, egui::Id::unique("hdr-discord"), Sense::click()); let dc = Color32::from_rgb(0x58, 0x65, 0xf2); p.rect_filled(discord, 13.0, if dresp.hovered() { dc.gamma_multiply(1.2) } else { dc }); - icons::paint(&p, Rect::from_center_size(pos2(discord.min.x + 17.0, cy), vec2(14.0, 14.0)), Icon::Chat, Color32::WHITE); + icons::paint(p, Rect::from_center_size(pos2(discord.min.x + 17.0, cy), vec2(14.0, 14.0)), Icon::Chat, Color32::WHITE); p.galley(pos2(discord.min.x + 30.0, cy - label.size().y / 2.0), label, Color32::WHITE); app.auto.add("header.discord", discord, "Join the ArtCraft Discord"); if dresp.on_hover_text(tr("Join the ArtCraft community on Discord")).clicked() { @@ -212,7 +267,13 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { app.auto.add(&format!("header.{}", &id[4..]), r, tip); rx = r.min.x - 4.0; } - // Appearance: Sync with System, Light, Dark (a monitor, sun or moon), one click to the next. + rx +} + +/// Appearance: Sync with System, Light, Dark (a monitor, sun or moon), one click to the next; +/// returns the new `rx`. +fn appearance_button(app: &mut EffectcraftApp, ui: &mut egui::Ui, rx: f32, cy: f32) -> f32 { + let t = app.tokens; let (icon, mode) = match app.session.prefs.appearance.appearance_mode.as_str() { "auto" => (Icon::Monitor, tr("Sync with System")), "light" => (Icon::Sun, tr("Light")), @@ -224,37 +285,41 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if response.clicked() { app.cycle_appearance(ui.ctx()); } - response.context_menu(|ui| { - let settings = ui.button(tr("Appearance Settings…")); - app.auto.add("header.appearanceSettings", settings.rect, "Appearance Settings…"); - if settings.clicked() { - crate::panels::settings::open(app, "appearance"); - ui.close(); - } - let favorites = app.session.prefs.appearance.favorite_themes.clone(); - for options in [effectcraft_engine::prefs::LIGHT_THEMES, effectcraft_engine::prefs::DARK_THEMES] { - let group: Vec<_> = options.iter().filter(|(_, id)| favorites.iter().any(|saved| saved == id)).collect(); - if !group.is_empty() { - ui.separator(); - } - for (label, id) in group { - let r = ui.selectable_label(app.ui.theme.name() == *id, tr(label)); - app.auto.add(&format!("header.favoriteTheme.{id}"), r.rect, label); - if r.clicked() { - if let Some(kind) = crate::theme::ThemeKind::from_name(id) { - app.set_theme(ui.ctx(), kind); - } - ui.close(); - } - } - } - }); + response.context_menu(|ui| appearance_menu(app, ui)); app.auto.add("header.appearance", r, &tip); - rx = r.min.x - 4.0; - rx -= 10.0; - p.line_segment([pos2(rx, cy - 10.0), pos2(rx, cy + 10.0)], Stroke::new(1.0, t.separator)); - rx -= 10.0; - // Workspace tabs (right to left), with a » menu of all. + r.min.x - 4.0 +} + +/// The appearance button's context menu: settings and the favorite themes. +fn appearance_menu(app: &mut EffectcraftApp, ui: &mut egui::Ui) { + let settings = ui.button(tr("Appearance Settings…")); + app.auto.add("header.appearanceSettings", settings.rect, "Appearance Settings…"); + if settings.clicked() { + crate::panels::settings::open(app, "appearance"); + ui.close(); + } + let favorites = app.session.prefs.appearance.favorite_themes.clone(); + for options in [effectcraft_engine::prefs::LIGHT_THEMES, effectcraft_engine::prefs::DARK_THEMES] { + let group: Vec<_> = options.iter().filter(|(_, id)| favorites.iter().any(|saved| saved == id)).collect(); + if !group.is_empty() { + ui.separator(); + } + for (label, id) in group { + let r = ui.selectable_label(app.ui.theme.name() == *id, tr(label)); + app.auto.add(&format!("header.favoriteTheme.{id}"), r.rect, label); + if r.clicked() { + if let Some(kind) = crate::theme::ThemeKind::from_name(id) { + app.set_theme(ui.ctx(), kind); + } + ui.close(); + } + } + } +} + +/// The » button with the menu of all workspaces; returns the new `rx`. +fn workspace_menu(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, rx: f32, cy: f32) -> f32 { + let t = app.tokens; let more = Rect::from_min_max(pos2(rx - 20.0, cy - 11.0), pos2(rx, cy + 11.0)); let mresp = ui.interact(more, egui::Id::unique("ws-more"), Sense::click()); p.text(more.center(), Align2::CENTER_CENTER, "»", Tokens::ui(15.0), if mresp.hovered() { t.text } else { t.text_dim }); @@ -273,15 +338,19 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { ) { app.set_workspace(crate::dock::WORKSPACES[i]); } - rx = more.min.x - 6.0; - // After Effects 2026's workspace bar order (right to left here): Default, Review, Learn, - // Small Screen, Standard. + more.min.x - 6.0 +} + +/// Workspace tabs, right to left from `rx`, stopping 120 px right of `left`. +/// After Effects 2026's order (right to left here): Default, Review, Learn, Small Screen, Standard. +fn workspace_tabs(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, mut rx: f32, left: f32, cy: f32) { + let t = app.tokens; let shown = ["Standard", "Small Screen", "Learn", "Review", "Default"]; for name in shown { let g = p.layout_no_wrap(crate::i18n::command_label("window.workspace", name).to_string(), Tokens::ui(12.0), t.text); let w = g.size().x + 16.0; let r = Rect::from_min_max(pos2(rx - w, cy - 13.0), pos2(rx, cy + 13.0)); - if r.min.x < x + 120.0 { + if r.min.x < left + 120.0 { break; } let active = app.ui.workspace == name; @@ -439,7 +508,14 @@ fn fill_stroke_options(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Pa x = sr.max.x + 12.0; } let width = app.session.state.shape_tool.stroke_width; - let (r, v, _) = widgets::hot_number_at(ui, pos2(x - 6.0, cy - 9.0), egui::Id::unique("tool-stroke-w"), width, 0.2, (0.0, 1000.0), 0, " px", &t); + let (r, v, _) = widgets::hot_number_at( + ui, + pos2(x - 6.0, cy - 9.0), + egui::Id::unique("tool-stroke-w"), + width, + &widgets::HotSpec { speed: 0.2, range: (0.0, 1000.0), decimals: 0, suffix: " px" }, + &t, + ); if let Some(v) = v { tool_options(app, json!({"strokeWidth": v})); paint(app, "strokeWidth", json!(v)); @@ -506,7 +582,14 @@ fn paint_options_popup(app: &mut EffectcraftApp, ui: &mut egui::Ui, shape_layers } app.auto.add(&format!("header.{key}Options.blend"), blend_rect, "Blend Mode"); p.text(row(98.0), Align2::LEFT_CENTER, tr("Opacity:"), Tokens::ui(12.0), t.text_dim); - let (or, v, _) = widgets::hot_number_at(ui, row(89.0) + vec2(84.0, 0.0), id.with("opacity"), tp.opacity, 0.5, (0.0, 100.0), 0, "%", &t); + let (or, v, _) = widgets::hot_number_at( + ui, + row(89.0) + vec2(84.0, 0.0), + id.with("opacity"), + tp.opacity, + &widgets::HotSpec { speed: 0.5, range: (0.0, 100.0), decimals: 0, suffix: "%" }, + &t, + ); app.auto.add(&format!("header.{key}Options.opacity"), or, "Opacity"); if let Some(v) = v { let k_opacity = format!("{key}Opacity"); @@ -577,7 +660,14 @@ fn roto_options(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, let label = tr("Diameter:"); let lr = p.text(pos2(x, cy), Align2::LEFT_CENTER, label, Tokens::ui(12.0), t.text_dim); x = lr.max.x + 6.0; - let (r, nv, _) = widgets::hot_number_at(ui, pos2(x, cy - 9.0), egui::Id::unique(("roto-opt", key)), v, 0.5, (1.0, 5000.0), 0, " px", &t); + let (r, nv, _) = widgets::hot_number_at( + ui, + pos2(x, cy - 9.0), + egui::Id::unique(("roto-opt", key)), + v, + &widgets::HotSpec { speed: 0.5, range: (1.0, 5000.0), decimals: 0, suffix: " px" }, + &t, + ); app.auto.add("header.roto.diameter", r, label); if let Some(nv) = nv && let Err(e) = app.session.execute("roto.options", json!({key: nv})) @@ -604,7 +694,14 @@ fn puppet_options(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter for (label, key, v, range) in [("Expansion:", "expansion", o.expansion, (-100.0, 200.0)), ("Density:", "density", o.density, (0.0, 100.0))] { p.text(pos2(x, cy), Align2::LEFT_CENTER, label, Tokens::ui(12.0), t.text_dim); x += if key == "expansion" { 64.0 } else { 52.0 }; - let (r, nv, _) = widgets::hot_number_at(ui, pos2(x, cy - 9.0), egui::Id::unique(("puppet-opt", key)), v, 0.2, range, 0, "", &t); + let (r, nv, _) = widgets::hot_number_at( + ui, + pos2(x, cy - 9.0), + egui::Id::unique(("puppet-opt", key)), + v, + &widgets::HotSpec { speed: 0.2, range, decimals: 0, suffix: "" }, + &t, + ); app.auto.add(&format!("header.puppet.{key}"), r, label); if let Some(nv) = nv { let _ = app.session.execute("puppet.mesh", json!({key: nv, "merge": format!("puppet-opt-{key}")})); diff --git a/crates/ui-egui/src/menus/bar.rs b/crates/ui-egui/src/menus/bar.rs index 1a7f6e2a3..e48bae5c8 100644 --- a/crates/ui-egui/src/menus/bar.rs +++ b/crates/ui-egui/src/menus/bar.rs @@ -1,7 +1,7 @@ use super::entries::{entry_checked, entry_enabled, entry_label, entry_shortcut, menu_choice}; use super::invoke::invoke; use super::shortcuts::shortcut_text; -use super::*; +use super::{EffectcraftApp, MenuEntry, MenuNode, Value, json}; /// The id under which [`menu_bar`] remembers which of its menus was open at the end of the last /// frame (its title's open look, and the hover that switches between the titles). diff --git a/crates/ui-egui/src/menus/commands.rs b/crates/ui-egui/src/menus/commands.rs index bc695f609..c162bbd3a 100644 --- a/crates/ui-egui/src/menus/commands.rs +++ b/crates/ui-egui/src/menus/commands.rs @@ -1,4 +1,4 @@ -use super::*; +use super::UiCommand; macro_rules! uic { ($id:literal, $label:literal, [$($m:literal),*], $sc:expr) => { diff --git a/crates/ui-egui/src/menus/frontend.rs b/crates/ui-egui/src/menus/frontend.rs index d808b4620..f536086d9 100644 --- a/crates/ui-egui/src/menus/frontend.rs +++ b/crates/ui-egui/src/menus/frontend.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{EffectcraftApp, PanelKind, Resolution, Value, json}; /// Run an engine command and perform the frontend events it emitted right away. /// Commands that fill the internal clipboard. diff --git a/crates/ui-egui/src/menus/shortcuts.rs b/crates/ui-egui/src/menus/shortcuts.rs index 7e3dfcec2..b9990786c 100644 --- a/crates/ui-egui/src/menus/shortcuts.rs +++ b/crates/ui-egui/src/menus/shortcuts.rs @@ -1,6 +1,6 @@ use super::frontend::run_engine; use super::invoke::invoke; -use super::*; +use super::{EffectcraftApp, PanelKind, Value, json}; /// Shortcut text in this OS's notation (`⇧⌘K` on macOS, `Ctrl+Shift+K` elsewhere). pub fn shortcut_text(s: &str) -> String { @@ -197,54 +197,40 @@ pub fn handle_tab_shortcut(app: &mut EffectcraftApp, ctx: &egui::Context) { } } -fn handle_shortcuts_impl(app: &mut EffectcraftApp, ctx: &egui::Context, tab_only: bool) { - let start = modifiers_at_start(ctx); - // Dialog cancellation owns Escape even when a text field has keyboard focus. - if !tab_only && app.dialog.is_some() && ctx.input(|i| i.key_pressed(egui::Key::Escape)) { - // A popup or colour picker takes Escape first (the picker goes back to its original colour). - if crate::panels::shortcut_editor::recording(app) - || crate::panels::shortcut_editor::confirming(app) - || egui::Popup::is_any_open(ctx) - || crate::color_picker::open_recently(ctx) +/// Escape with a dialog open: close the popup/picker first, else cancel the dialog. +fn escape_dialog(app: &mut EffectcraftApp, ctx: &egui::Context) { + // A popup or colour picker takes Escape first (the picker goes back to its original colour). + if crate::panels::shortcut_editor::recording(app) + || crate::panels::shortcut_editor::confirming(app) + || egui::Popup::is_any_open(ctx) + || crate::color_picker::open_recently(ctx) + { + return; + } + ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)); + if app.dialog == Some(crate::Dialog::LayerStyles) { + crate::panels::layer_styles_dialog::finish(app, false); + } else if app.dialog == Some(crate::Dialog::Info) { + crate::panels::forms::close_info(app); + } else { + if app.dialog == Some(crate::Dialog::Settings) + && let Some(p) = app.dialog_state.prefs_snapshot.take() { - return; + app.session.prefs = p; + app.session.prefs_changed(); } - ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)); - if app.dialog == Some(crate::Dialog::LayerStyles) { - crate::panels::layer_styles_dialog::finish(app, false); - } else if app.dialog == Some(crate::Dialog::Info) { - crate::panels::forms::close_info(app); - } else { - if app.dialog == Some(crate::Dialog::Settings) - && let Some(p) = app.dialog_state.prefs_snapshot.take() - { - app.session.prefs = p; - app.session.prefs_changed(); - } - if app.dialog == Some(crate::Dialog::UnsavedChanges) { - app.dialog_state.unsaved.command = None; - } - app.dialog = None; + if app.dialog == Some(crate::Dialog::UnsavedChanges) { + app.dialog_state.unsaved.command = None; } - return; + app.dialog = None; } - // A mouse button down while Spacebar is held belongs to the Hand tool: its release won't - // preview. - if ctx.input(|i| i.pointer.any_down()) || ctx.egui_wants_keyboard_input() { - ctx.data_mut(|d| d.remove::(space_tap_id())); - } - // While a menu or popup list is open, keys go to it, not to the app (Space must not start a - // preview behind the menu, #279). - if ctx.egui_wants_keyboard_input() || crate::widgets::any_menu_open(ctx) { - return; - } - // Text editing in the viewer takes the clipboard events itself. - let clipboard = app.session.state.text_edit.is_none(); - let project = app.ui.focused == PanelKind::Project; - // The paste event's text: Edit ▸ Paste pastes SVG markup as a shape layer. - let pasted = - if clipboard { ctx.input(|i| i.events.iter().rev().find_map(|e| if let egui::Event::Paste(t) = e { Some(t.clone()) } else { None })) } else { None }; - let mut events: Vec<(egui::Key, egui::Modifiers, bool, Option)> = ctx.input(|i| { +} + +type KeyEvent = (egui::Key, egui::Modifiers, bool, Option); + +/// The frame's key presses, with clipboard events mapped back to Ctrl+C / X / V. +fn collect_key_events(ctx: &egui::Context, start: Option, clipboard: bool, project: bool) -> Vec { + ctx.input(|i| { // The windowing layer turns Ctrl+C / Ctrl+X / Ctrl+V into clipboard events instead of // key presses: map them back to the keys (with the modifiers held) so Edit ▸ Copy, Cut, // Paste and their variants (Ctrl+Alt+C…) run. Clipboard events carry no modifiers and @@ -283,22 +269,57 @@ fn handle_shortcuts_impl(app: &mut EffectcraftApp, ctx: &egui::Context, tab_only } } out - }); + }) +} + +/// Numpad * (or `*` typed with Shift+8): Add Marker (#275). egui has no key for it, so it +/// comes as text. +fn numpad_star_marker(app: &mut EffectcraftApp, ctx: &egui::Context) { + if !ctx.input(|i| i.events.iter().any(|e| matches!(e, egui::Event::Text(t) if t == "*"))) { + return; + } + let bound = app.session.shortcuts().bindings().into_iter().find(|(k, _)| *k == "Num*").map(|(_, b)| (b.command.clone(), b.params.clone())); + // As in After Effects, it marks the selected layers; only with none selected does it add + // a composition marker (#976). + let bound = + bound.map( + |(id, params)| { + if id == "comp.addMarker" && !app.session.state.selected_layers.is_empty() { ("layer.addMarker".to_owned(), params) } else { (id, params) } + }, + ); + if let Some((id, params)) = bound + && let Err(e) = invoke(app, ctx, &id, if params.is_null() { json!({}) } else { params }) + { + app.ui.status = e; + } +} + +fn handle_shortcuts_impl(app: &mut EffectcraftApp, ctx: &egui::Context, tab_only: bool) { + let start = modifiers_at_start(ctx); + if !tab_only && app.dialog.is_some() && ctx.input(|i| i.key_pressed(egui::Key::Escape)) { + escape_dialog(app, ctx); + return; + } + // A mouse button down while Spacebar is held belongs to the Hand tool: its release won't + // preview. + if ctx.input(|i| i.pointer.any_down()) || ctx.egui_wants_keyboard_input() { + ctx.data_mut(|d| d.remove::(space_tap_id())); + } + // While a menu or popup list is open, keys go to it, not to the app (Space must not start a + // preview behind the menu, #279). + if ctx.egui_wants_keyboard_input() || crate::widgets::any_menu_open(ctx) { + return; + } + // Text editing in the viewer takes the clipboard events itself. + let clipboard = app.session.state.text_edit.is_none(); + let project = app.ui.focused == PanelKind::Project; + // The paste event's text: Edit ▸ Paste pastes SVG markup as a shape layer. + let pasted = + if clipboard { ctx.input(|i| i.events.iter().rev().find_map(|e| if let egui::Event::Paste(t) = e { Some(t.clone()) } else { None })) } else { None }; + let mut events = collect_key_events(ctx, start, clipboard, project); events.retain(|(key, _, _, _)| (*key == egui::Key::Tab) == tab_only); - // Numpad * (or `*` typed with Shift+8): Add Marker (#275). egui has no key for it, so it - // comes as text. - if !tab_only && clipboard && app.dialog.is_none() && ctx.input(|i| i.events.iter().any(|e| matches!(e, egui::Event::Text(t) if t == "*"))) { - let bound = app.session.shortcuts().bindings().into_iter().find(|(k, _)| *k == "Num*").map(|(_, b)| (b.command.clone(), b.params.clone())); - // As in After Effects, it marks the selected layers; only with none selected does it add - // a composition marker (#976). - let bound = bound.map(|(id, params)| { - if id == "comp.addMarker" && !app.session.state.selected_layers.is_empty() { ("layer.addMarker".to_owned(), params) } else { (id, params) } - }); - if let Some((id, params)) = bound - && let Err(e) = invoke(app, ctx, &id, if params.is_null() { json!({}) } else { params }) - { - app.ui.status = e; - } + if !tab_only && clipboard && app.dialog.is_none() { + numpad_star_marker(app, ctx); } if events.is_empty() { return; diff --git a/crates/ui-egui/src/panels/anim_tools.rs b/crates/ui-egui/src/panels/anim_tools.rs index fe4c67895..b07da8567 100644 --- a/crates/ui-egui/src/panels/anim_tools.rs +++ b/crates/ui-egui/src/panels/anim_tools.rs @@ -33,9 +33,9 @@ fn choice(app: &mut EffectcraftApp, ui: &mut egui::Ui, r: Rect, auto: &str, opti /// A drag-to-change number field for an anim-tool setting: `v` clamped to `range`, `speed` points /// per pixel, `dec` decimals and a `suffix`; returns the new value, or `v` when it did not change. -fn number(app: &mut EffectcraftApp, ui: &mut egui::Ui, at: egui::Pos2, auto: &str, v: f64, speed: f64, range: (f64, f64), dec: usize, suffix: &str) -> f64 { +fn number(app: &mut EffectcraftApp, ui: &mut egui::Ui, at: egui::Pos2, auto: &str, v: f64, spec: widgets::HotSpec) -> f64 { let t = app.tokens; - let (r, nv, _) = widgets::hot_number_at(ui, at, egui::Id::unique(("animtool-n", auto)), v, speed, range, dec, suffix, &t); + let (r, nv, _) = widgets::hot_number_at(ui, at, egui::Id::unique(("animtool-n", auto)), v, &spec, &t); app.auto.add(auto, r, &format!("{v}")); nv.unwrap_or(v) } @@ -105,10 +105,24 @@ pub fn wiggler(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } y += ROW + 4.0; label(&p, x0, y, "Frequency:", &t); - st.wiggle_frequency = number(app, ui, pos2(xv, y), "wiggler.frequency", st.wiggle_frequency, 0.1, (0.1, 100.0), 1, " per second"); + st.wiggle_frequency = number( + app, + ui, + pos2(xv, y), + "wiggler.frequency", + st.wiggle_frequency, + widgets::HotSpec { speed: 0.1, range: (0.1, 100.0), decimals: 1, suffix: " per second" }, + ); y += ROW; label(&p, x0, y, "Magnitude:", &t); - st.wiggle_magnitude = number(app, ui, pos2(xv, y), "wiggler.magnitude", st.wiggle_magnitude, 0.5, (0.0, 100_000.0), 1, ""); + st.wiggle_magnitude = number( + app, + ui, + pos2(xv, y), + "wiggler.magnitude", + st.wiggle_magnitude, + widgets::HotSpec { speed: 0.5, range: (0.0, 100_000.0), decimals: 1, suffix: "" }, + ); y += ROW + 8.0; app.ui.anim_tools = st.clone(); let b = Rect::from_min_size(pos2(rect.max.x - 92.0, y), vec2(80.0, 24.0)); @@ -149,7 +163,14 @@ pub fn smoother(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { p.text(pos2(xv, y + 9.0), Align2::LEFT_CENTER, txt, Tokens::ui(12.0), t.text); y += ROW; label(&p, x0, y, "Tolerance:", &t); - let tol = number(app, ui, pos2(xv, y), "smoother.tolerance", app.ui.anim_tools.smooth_tolerance, 0.05, (0.0, 10_000.0), 2, ""); + let tol = number( + app, + ui, + pos2(xv, y), + "smoother.tolerance", + app.ui.anim_tools.smooth_tolerance, + widgets::HotSpec { speed: 0.05, range: (0.0, 10_000.0), decimals: 2, suffix: "" }, + ); app.ui.anim_tools.smooth_tolerance = tol; y += ROW + 8.0; let b = Rect::from_min_size(pos2(rect.max.x - 92.0, y), vec2(80.0, 24.0)); @@ -170,10 +191,24 @@ pub fn motion_sketch(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let mut y = rect.min.y + 10.0; let mut st = app.ui.anim_tools.clone(); label(&p, x0, y, "Capture speed at:", &t); - st.sketch_speed = number(app, ui, pos2(xv, y), "motionSketch.captureSpeed", st.sketch_speed, 1.0, (1.0, 10_000.0), 0, " %"); + st.sketch_speed = number( + app, + ui, + pos2(xv, y), + "motionSketch.captureSpeed", + st.sketch_speed, + widgets::HotSpec { speed: 1.0, range: (1.0, 10_000.0), decimals: 0, suffix: " %" }, + ); y += ROW; label(&p, x0, y, "Smoothing:", &t); - st.sketch_smoothing = number(app, ui, pos2(xv, y), "motionSketch.smoothing", st.sketch_smoothing, 0.1, (0.0, 100.0), 1, ""); + st.sketch_smoothing = number( + app, + ui, + pos2(xv, y), + "motionSketch.smoothing", + st.sketch_smoothing, + widgets::HotSpec { speed: 0.1, range: (0.0, 100.0), decimals: 1, suffix: "" }, + ); y += ROW + 2.0; p.text(pos2(x0, y + 8.0), Align2::LEFT_CENTER, tr("Show:"), Tokens::ui(12.0), t.text_dim); y += ROW - 6.0; @@ -328,7 +363,14 @@ pub fn mask_interpolation(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rec changes.insert("keyframeRate".into(), if i == 0 { json!("auto") } else { json!(fps) }); } if let Some(r) = o.keyframe_rate { - let nr = number(app, ui, pos2(xv + 78.0, y), "maskInterp.rate", r, 0.5, (0.1, 1000.0), 1, " per second"); + let nr = number( + app, + ui, + pos2(xv + 78.0, y), + "maskInterp.rate", + r, + widgets::HotSpec { speed: 0.5, range: (0.1, 1000.0), decimals: 1, suffix: " per second" }, + ); if (nr - r).abs() > 1e-9 { changes.insert("keyframeRate".into(), json!(nr)); } @@ -346,13 +388,20 @@ pub fn mask_interpolation(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rec check(app, y, "linearVertexPaths", "Use \"Linear\" Vertex Paths", o.linear_vertex_paths); y += ROW; label(&p, x0, y, "Bending Resistance:", &t); - let br = number(app, ui, pos2(xv, y), "maskInterp.bendingResistance", o.bending_resistance, 0.5, (0.0, 100.0), 0, ""); + let br = number( + app, + ui, + pos2(xv, y), + "maskInterp.bendingResistance", + o.bending_resistance, + widgets::HotSpec { speed: 0.5, range: (0.0, 100.0), decimals: 0, suffix: "" }, + ); if (br - o.bending_resistance).abs() > 1e-9 { changes.insert("bendingResistance".into(), json!(br)); } y += ROW; label(&p, x0, y, "Quality:", &t); - let q = number(app, ui, pos2(xv, y), "maskInterp.quality", o.quality, 0.5, (0.0, 100.0), 0, ""); + let q = number(app, ui, pos2(xv, y), "maskInterp.quality", o.quality, widgets::HotSpec { speed: 0.5, range: (0.0, 100.0), decimals: 0, suffix: "" }); if (q - o.quality).abs() > 1e-9 { changes.insert("quality".into(), json!(q)); } @@ -363,7 +412,8 @@ pub fn mask_interpolation(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rec } y += ROW; if let Some(v) = o.add_vertices { - let nv = number(app, ui, pos2(x0 + 22.0, y), "maskInterp.addVertices", v, 0.2, (0.1, 2000.0), 1, ""); + let nv = + number(app, ui, pos2(x0 + 22.0, y), "maskInterp.addVertices", v, widgets::HotSpec { speed: 0.2, range: (0.1, 2000.0), decimals: 1, suffix: "" }); if (nv - v).abs() > 1e-9 { changes.insert("addVertices".into(), json!(nv)); } diff --git a/crates/ui-egui/src/panels/effect_controls/common.rs b/crates/ui-egui/src/panels/effect_controls/common.rs index b96ed2180..ab46a07ac 100644 --- a/crates/ui-egui/src/panels/effect_controls/common.rs +++ b/crates/ui-egui/src/panels/effect_controls/common.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{EffectcraftApp, Layer, Node, ParamUi, PropGroup, Value, json}; pub(super) type Actions = Vec<(String, serde_json::Value)>; diff --git a/crates/ui-egui/src/panels/effect_controls/editors.rs b/crates/ui-egui/src/panels/effect_controls/editors.rs index 3e79c7bdd..c95c90c07 100644 --- a/crates/ui-egui/src/panels/effect_controls/editors.rs +++ b/crates/ui-egui/src/panels/effect_controls/editors.rs @@ -1,5 +1,5 @@ use super::common::{Actions, gesture_key}; -use super::*; +use super::{Align2, Color32, EffectcraftApp, EvalCtx, Layer, PropGroup, Rect, Sense, Tokens, Value, fw, json, pos2, vec2, widgets}; /// Warp Stabilizer's Analyze / Cancel buttons and analysis status. pub(super) fn warp_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, layer: &Layer, g: &PropGroup, r: Rect, actions: &mut Actions) { diff --git a/crates/ui-egui/src/panels/effect_controls/panel.rs b/crates/ui-egui/src/panels/effect_controls/panel.rs index 30bb5804f..39cdee76c 100644 --- a/crates/ui-egui/src/panels/effect_controls/panel.rs +++ b/crates/ui-egui/src/panels/effect_controls/panel.rs @@ -1,7 +1,7 @@ use super::common::{Actions, PARAM_NAME_X, ROW, effect_active, selected_layer}; use super::editors::{curves_editor, editor_height, histogram_only, levels_editor, object_matte_editor, roto_editor, warp_editor}; use super::rows::{FxScope, group_rows, toggle_closed}; -use super::*; +use super::{Align2, Color32, EffectcraftApp, EvalCtx, GroupKind, Icon, Layer, PropGroup, Rect, Sense, Stroke, Tokens, json, pos2, vec2, widgets}; // --------------------------------------------------------------------------------------------- @@ -13,6 +13,140 @@ pub fn start_rename(ctx: &egui::Context, uid: u64, name: &str) { ctx.memory_mut(|m| m.request_focus(id.with("field"))); } +/// Where an effect header's parts sit, for its rename field. +struct EffectHdr<'a> { + g: &'a PropGroup, + shown: &'a str, + r: Rect, + tw: Rect, + reset: Rect, + rename_id: egui::Id, + layer_id: u64, +} + +/// The effect header's name field while renaming: Enter or blur commits, Escape cancels. +fn rename_field(app: &mut EffectcraftApp, ui: &mut egui::Ui, h: &EffectHdr, mut value: String, actions: &mut Actions) { + let EffectHdr { g, shown, r, tw, reset, rename_id, layer_id } = *h; + let ctx = ui.ctx().clone(); + let name_rect = Rect::from_min_max(pos2(tw.max.x + 6.0, r.min.y + 2.0), pos2(reset.min.x - 6.0, r.max.y - 2.0)); + let response = ui.put(name_rect, egui::TextEdit::singleline(&mut value).id(rename_id.with("field")).font(Tokens::semibold(12.0))); + // Focus the field the frame it first exists: focusing an id not yet on screen leaves + // the accessibility tree with a focus it doesn't contain. + if ctx.data_mut(|d| d.remove_temp::(rename_id.with("start"))).is_some() { + crate::widgets::select_all(&ctx, response.id, &value); + response.request_focus(); + } + app.auto.add(&format!("effectControls.effect.{}.nameEdit", g.uid), name_rect, "Effect name"); + if ctx.data_mut(|d| d.remove_temp::(rename_id.with("select"))).unwrap_or(false) { + widgets::select_all(&ctx, response.id, &value); + } + // Escape cancels. egui drops the field's focus on Escape in the same frame, so the + // key is checked on the focus being lost as well as held. + let escape = (response.has_focus() || response.lost_focus()) && ui.input(|i| i.key_pressed(egui::Key::Escape)); + let commit = response.lost_focus() || (response.has_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter))); + if escape || commit { + ctx.data_mut(|d| d.remove::(rename_id)); + // The field opens with the drawn name, so committing it unchanged is not a rename. + if !escape && !value.trim().is_empty() && value != shown { + actions.push(("prop.renameGroup".into(), json!({"layer": layer_id, "prop": g.uid, "name": value}))); + } + } else { + ctx.data_mut(|d| d.insert_temp(rename_id, value)); + } +} + +struct EditorCx<'a> { + bp: &'a egui::Painter, + layer: &'a Layer, + ectx: &'a EvalCtx<'a>, +} + +/// An effect's custom editor (curves, levels, roto...), or the histogram-only fallback. +fn effect_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, cx: &EditorCx, g: &PropGroup, er: Rect, actions: &mut Actions) { + let GroupKind::Effect { effect } = &g.kind else { return }; + match effect.as_str() { + "ec.color.curves" => curves_editor(app, ui, cx.bp, cx.layer, g, cx.ectx, er, actions), + effectcraft_engine::effects::warp_stab::ID => warp_editor(app, ui, cx.bp, cx.layer, g, er, actions), + effectcraft_engine::effects::camera_tracker::ID => crate::panels::camera_tracker_ui::editor(app, ui, cx.bp, cx.layer, g, er, actions), + effectcraft_engine::effects::roto::ID => roto_editor(app, ui, cx.bp, cx.layer, g, er, actions), + effectcraft_engine::effects::object_matte::ID => object_matte_editor(app, ui, cx.bp, cx.layer, g, er, actions), + "ec.color.levels" | "ec.color.levelsic" => levels_editor(app, ui, cx.bp, cx.layer, g, effect, cx.ectx, er, actions), + crate::panels::fx_editors::GLOW | crate::panels::fx_editors::RESHAPE | crate::panels::fx_editors::EXTRACTOR => { + crate::panels::fx_editors::header_editor(app, ui, cx.bp, cx.layer, g, effect, cx.ectx, er, actions) + } + _ => histogram_only(app, ui, cx.bp, g, er), + } +} + +struct ReorderDrag<'a> { + tops: &'a [(u64, f32)], + y: f32, + du: u64, + drag_id: egui::Id, + layer_id: u64, +} + +/// Drag an effect header to reorder: the insertion line between headers, and the drop. +fn reorder_drag(ctx: &egui::Context, bp: &egui::Painter, body: Rect, t: &Tokens, st: &ReorderDrag, actions: &mut Actions) { + let ReorderDrag { tops, y, du, drag_id, layer_id } = *st; + if let Some(pos) = ctx.pointer_interact_pos() { + let slot = tops.iter().position(|(_, ty)| pos.y < *ty + (ROW + 2.0) / 2.0).unwrap_or(tops.len()); + let ly = tops.get(slot).map(|(_, ty)| *ty).unwrap_or(y - 4.0); + bp.line_segment([pos2(body.min.x + 4.0, ly), pos2(body.max.x - 4.0, ly)], Stroke::new(2.0, t.accent)); + if ctx.input(|i| i.pointer.any_released()) { + if let Some(from) = tops.iter().position(|(u, _)| *u == du) { + let to = if slot > from { slot - 1 } else { slot }; + if to != from { + actions.push(("effect.reorder".into(), json!({"layer": layer_id, "effect": du, "index": to + 1}))); + } + } + ctx.data_mut(|d| d.remove::(drag_id)); + } + } + if !ctx.input(|i| i.pointer.any_down()) { + ctx.data_mut(|d| d.remove::(drag_id)); + } +} + +/// Drop effects and animation presets here. +fn drop_payload(ui: &egui::Ui, rect: Rect, p: &egui::Painter, t: &Tokens, layer_id: u64, actions: &mut Actions) { + let ctx = ui.ctx().clone(); + if let Some(payload) = egui::DragAndDrop::payload::(&ctx) + && ui.rect_contains_pointer(rect) + && let Some((id, params)) = match payload.as_ref() { + crate::panels::DragPayload::Effect(e) => Some(("effect.apply", json!({"effect": e, "layers": [layer_id]}))), + crate::panels::DragPayload::Preset { preset, .. } => Some(preset.apply_to(layer_id)), + _ => None, + } + { + p.rect_stroke(rect, 0.0, Stroke::new(2.0, t.accent), egui::StrokeKind::Inside); + if ctx.input(|i| i.pointer.any_released()) { + actions.push((id.into(), params)); + egui::DragAndDrop::clear_payload(&ctx); + } + } +} + +fn about_button(app: &mut EffectcraftApp, ui: &mut egui::Ui, bp: &egui::Painter, t: &Tokens, effect: &str, g: &PropGroup, r: Rect) { + // About…: the effect's name and category (AE's About dialog). + let about = Rect::from_min_size(pos2(r.max.x - 50.0, r.center().y - 9.0), vec2(44.0, 18.0)); + let aresp = ui.interact(about, egui::Id::unique(("ec-about", g.uid)), Sense::click()); + bp.text(about.center(), Align2::CENTER_CENTER, crate::i18n::tr("About..."), Tokens::ui(11.5), if aresp.hovered() { t.hot_text } else { t.text_dim }); + app.auto.add(&format!("effectControls.effect.{}.about", g.uid), about, "About"); + let apop = egui::Id::unique(("ec-about-pop", g.uid)); + if aresp.clicked() { + widgets::open_popup(ui, apop); + } + if let Some(spec) = effectcraft_engine::effects::find(effect) { + let lines = vec![ + crate::i18n::effects::name(spec.id, spec.name), + format!("Category: {}", crate::i18n::effects::category(spec.category)), + "EffectCraft built-in effect (MIT OR Apache-2.0)".to_string(), + ]; + let _ = widgets::popup_menu(ui, apop, about.left_bottom(), &lines, None); + } +} + /// The Effect Controls panel for the selected layer: a header with the comp and layer names, then /// one header per effect (Fx toggle, twirl, rename, Reset, About, label bar, reorder drag and its /// right-click menu) followed by that effect's custom editor and its property rows. Dragging @@ -99,32 +233,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let rename_id = egui::Id::unique(("ec-rename", g.uid)); let editing: Option = ctx.data(|d| d.get_temp(rename_id)); let reset = Rect::from_min_size(pos2(r.max.x - 96.0, r.center().y - 9.0), vec2(40.0, 18.0)); - if let Some(mut value) = editing.as_ref().cloned() { - let name_rect = Rect::from_min_max(pos2(tw.max.x + 6.0, r.min.y + 2.0), pos2(reset.min.x - 6.0, r.max.y - 2.0)); - let response = ui.put(name_rect, egui::TextEdit::singleline(&mut value).id(rename_id.with("field")).font(Tokens::semibold(12.0))); - // Focus the field the frame it first exists: focusing an id not yet on screen leaves - // the accessibility tree with a focus it doesn't contain. - if ctx.data_mut(|d| d.remove_temp::(rename_id.with("start"))).is_some() { - crate::widgets::select_all(&ctx, response.id, &value); - response.request_focus(); - } - app.auto.add(&format!("effectControls.effect.{}.nameEdit", g.uid), name_rect, "Effect name"); - if ctx.data_mut(|d| d.remove_temp::(rename_id.with("select"))).unwrap_or(false) { - widgets::select_all(&ctx, response.id, &value); - } - // Escape cancels. egui drops the field's focus on Escape in the same frame, so the - // key is checked on the focus being lost as well as held. - let escape = (response.has_focus() || response.lost_focus()) && ui.input(|i| i.key_pressed(egui::Key::Escape)); - let commit = response.lost_focus() || (response.has_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter))); - if escape || commit { - ctx.data_mut(|d| d.remove::(rename_id)); - // The field opens with the drawn name, so committing it unchanged is not a rename. - if !escape && !value.trim().is_empty() && value != shown { - actions.push(("prop.renameGroup".into(), json!({"layer": layer.id.0, "prop": g.uid, "name": value}))); - } - } else { - ctx.data_mut(|d| d.insert_temp(rename_id, value)); - } + let hdr_cx = EffectHdr { g, shown: &shown, r, tw, reset, rename_id, layer_id: layer.id.0 }; + if let Some(value) = editing.as_ref().cloned() { + rename_field(app, ui, &hdr_cx, value, &mut actions); } else { let name = bp.text(pos2(tw.max.x + 6.0, r.center().y), Align2::LEFT_CENTER, &shown, Tokens::semibold(12.0), t.text); app.auto.add(&format!("effectControls.effect.{}.name", g.uid), name, &g.name); @@ -135,23 +246,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if rresp.clicked() { actions.push(("effect.reset".into(), json!({"layer": layer.id.0, "effect": g.uid}))); } - // About…: the effect's name and category (AE's About dialog). - let about = Rect::from_min_size(pos2(r.max.x - 50.0, r.center().y - 9.0), vec2(44.0, 18.0)); - let aresp = ui.interact(about, egui::Id::unique(("ec-about", g.uid)), Sense::click()); - bp.text(about.center(), Align2::CENTER_CENTER, crate::i18n::tr("About..."), Tokens::ui(11.5), if aresp.hovered() { t.hot_text } else { t.text_dim }); - app.auto.add(&format!("effectControls.effect.{}.about", g.uid), about, "About"); - let apop = egui::Id::unique(("ec-about-pop", g.uid)); - if aresp.clicked() { - widgets::open_popup(ui, apop); - } - if let Some(spec) = effectcraft_engine::effects::find(effect) { - let lines = vec![ - crate::i18n::effects::name(spec.id, spec.name), - format!("Category: {}", crate::i18n::effects::category(spec.category)), - "EffectCraft built-in effect (MIT OR Apache-2.0)".to_string(), - ]; - let _ = widgets::popup_menu(ui, apop, about.left_bottom(), &lines, None); - } + about_button(app, ui, &bp, &t, effect, g, r); let hresp = ui.interact( Rect::from_min_max(pos2(tw.max.x, r.min.y), pos2(reset.min.x - 4.0, r.max.y)), egui::Id::unique(("ec-hdr", g.uid)), @@ -234,18 +329,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if ui.input(|i| i.pointer.primary_pressed()) && ui.rect_contains_pointer(er) { actions.push(("prop.select".into(), json!({"layer":layer.id.0,"prop":g.uid}))); } - 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 => crate::panels::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), - effectcraft_engine::effects::object_matte::ID => object_matte_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), - crate::panels::fx_editors::GLOW | crate::panels::fx_editors::RESHAPE | crate::panels::fx_editors::EXTRACTOR => { - crate::panels::fx_editors::header_editor(app, ui, &bp, &layer, g, effect, &ectx, er, &mut actions) - } - _ => histogram_only(app, ui, &bp, g, er), - } + effect_editor(app, ui, &EditorCx { bp: &bp, layer: &layer, ectx: &ectx }, g, er, &mut actions); } } let scope = FxScope { effect: effect.as_str(), root: g, path: String::new() }; @@ -264,25 +348,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } y += 4.0; } - // Drag an effect header to reorder: insertion line between headers. if let Some(du) = dragging { - if let Some(pos) = ctx.pointer_interact_pos() { - let slot = tops.iter().position(|(_, ty)| pos.y < *ty + (ROW + 2.0) / 2.0).unwrap_or(tops.len()); - let ly = tops.get(slot).map(|(_, ty)| *ty).unwrap_or(y - 4.0); - bp.line_segment([pos2(body.min.x + 4.0, ly), pos2(body.max.x - 4.0, ly)], Stroke::new(2.0, t.accent)); - if ctx.input(|i| i.pointer.any_released()) { - if let Some(from) = tops.iter().position(|(u, _)| *u == du) { - let to = if slot > from { slot - 1 } else { slot }; - if to != from { - actions.push(("effect.reorder".into(), json!({"layer": layer.id.0, "effect": du, "index": to + 1}))); - } - } - ctx.data_mut(|d| d.remove::(drag_id)); - } - } - if !ctx.input(|i| i.pointer.any_down()) { - ctx.data_mut(|d| d.remove::(drag_id)); - } + reorder_drag(&ctx, &bp, body, &t, &ReorderDrag { tops: &tops, y, du, drag_id, layer_id: layer.id.0 }, &mut actions); } // Right-click anywhere a control has no menu of its own: the Effect menu, which applies to // the selected layers (After Effects). Drawn after the rows, whose menus open first. @@ -297,21 +364,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { ui.set_clip_rect(panel_clip); let content_h = y + scroll.offset - body.min.y; scroll.end(ui, &mut app.auto, "effectControls.scroll", content_h, &t); - // Drop effects and animation presets here. - if let Some(payload) = egui::DragAndDrop::payload::(&ctx) - && ui.rect_contains_pointer(rect) - && let Some((id, params)) = match payload.as_ref() { - crate::panels::DragPayload::Effect(e) => Some(("effect.apply", json!({"effect": e, "layers": [layer.id.0]}))), - crate::panels::DragPayload::Preset { preset, .. } => Some(preset.apply_to(layer.id.0)), - _ => None, - } - { - p.rect_stroke(rect, 0.0, Stroke::new(2.0, t.accent), egui::StrokeKind::Inside); - if ctx.input(|i| i.pointer.any_released()) { - actions.push((id.into(), params)); - egui::DragAndDrop::clear_payload(&ctx); - } - } + drop_payload(ui, rect, &p, &t, layer.id.0, &mut actions); if ui.input(|i| i.pointer.primary_down()) && actions.iter().any(|(id, _)| id == "prop.set") { app.ui.viewer.property_interacting = true; ctx.request_repaint(); diff --git a/crates/ui-egui/src/panels/effect_controls/rows.rs b/crates/ui-egui/src/panels/effect_controls/rows.rs index 2ea1f6e0b..05fea44c9 100644 --- a/crates/ui-egui/src/panels/effect_controls/rows.rs +++ b/crates/ui-egui/src/panels/effect_controls/rows.rs @@ -1,27 +1,27 @@ use super::common::{Actions, DIAL_ROW, PARAM_NAME_X, ROW, TREE_INDENT, gesture_key}; -use super::*; +use super::{ + Align2, Color32, EffectcraftApp, EvalCtx, FxPick, Icon, Layer, Node, ParamUi, PropGroup, Property, Rect, Sense, Stroke, Tokens, Value, fw, icons, json, + pos2, vec2, widgets, +}; fn triangle(p: &egui::Painter, c: egui::Pos2, left: bool, col: Color32) { let (dx, h) = (if left { -3.5 } else { 3.5 }, 4.0); p.add(egui::Shape::convex_polygon(vec![pos2(c.x + dx, c.y), pos2(c.x - dx, c.y - h), pos2(c.x - dx, c.y + h)], col, Stroke::NONE)); } +/// The layer and whether the time is on a key, for the key navigator. +#[derive(Clone, Copy)] +struct KeyNav { + layer_id: u64, + on_key: bool, +} + /// ◀ ◆ ▶ at the right end of an animated parameter's row. -fn key_navigator( - app: &mut EffectcraftApp, - ui: &mut egui::Ui, - p: &egui::Painter, - layer: &Layer, - prop: &Property, - ectx: &EvalCtx, - r: Rect, - actions: &mut Actions, -) { +fn key_navigator(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, nav: KeyNav, prop: &Property, r: Rect, actions: &mut Actions) { + let KeyNav { layer_id, on_key } = nav; let t = app.tokens; let cy = r.center().y; let uid = prop.uid; - let half = ectx.comp.frame_duration().0 / 2; - let on_key = prop.keys.iter().any(|k| (layer.comp_time(k.time).0 - ectx.time.0).abs() <= half); for (what, dx) in [("prev", -44.0), ("key", -32.0), ("next", -20.0)] { let cr = Rect::from_center_size(pos2(r.max.x + dx, cy), vec2(12.0, 16.0)); let resp = ui.interact(cr, egui::Id::unique(("ec-nav", uid, what)), Sense::click()); @@ -48,7 +48,7 @@ fn key_navigator( match what { "prev" => actions.push(("time.go".into(), json!({"to": "prevKey", "prop": uid}))), "next" => actions.push(("time.go".into(), json!({"to": "nextKey", "prop": uid}))), - _ => actions.push(("prop.toggleKey".into(), json!({"layer": layer.id.0, "prop": uid, "selected": true}))), + _ => actions.push(("prop.toggleKey".into(), json!({"layer": layer_id, "prop": uid, "selected": true}))), } } } @@ -71,6 +71,44 @@ pub(crate) fn toggle_pick(app: &mut EffectcraftApp, kind: &str, layer: &Layer, p } } +/// What the value controls of one property row share. +#[derive(Clone, Copy)] +struct PropCx<'a> { + p: &'a egui::Painter, + layer: &'a Layer, + group: &'a PropGroup, + prop: &'a Property, + r: Rect, + vx: f32, + cy: f32, + swr: Rect, + t: Tokens, + uid: u64, + scrub: bool, +} + +impl PropCx<'_> { + fn set(&self, actions: &mut Actions, v: serde_json::Value) { + let (layer, uid, merge, scrub) = (self.layer, self.uid, format!("ec-{}", self.uid), self.scrub); + actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": uid, "value": v, "merge": merge, "selected": true, "offset": scrub}))) + } + + fn twirl_open(&self, app: &mut EffectcraftApp, ui: &mut egui::Ui) { + let PropCx { swr, cy, uid, prop, t, .. } = self; + let (cy, uid) = (*cy, *uid); + let open = app.ui.fx_slider_open.contains(&uid); + let tw = Rect::from_center_size(pos2(swr.min.x - 9.0, cy), vec2(10.0, 10.0)); + if widgets::twirl(ui, tw, open, egui::Id::unique(("ec-stw", uid)), t).clicked() { + if open { + app.ui.fx_slider_open.remove(&uid); + } else { + app.ui.fx_slider_open.insert(uid); + } + } + app.auto.add(&format!("effectControls.prop.{uid}.twirl"), tw, &prop.name); + } +} + /// One property row: stopwatch, name, value control (and the keyframe navigator). Returns the /// value fields of a vector, one per dimension, for the pick whip. #[allow(clippy::too_many_arguments)] @@ -115,7 +153,9 @@ fn prop_row( } } if prop.is_animated() { - key_navigator(app, ui, p, layer, prop, ectx, r, actions); + let half = ectx.comp.frame_duration().0 / 2; + let on_key = prop.keys.iter().any(|k| (layer.comp_time(k.time).0 - ectx.time.0).abs() <= half); + key_navigator(app, ui, p, KeyNav { layer_id: layer.id.0, on_key }, prop, r, actions); } let value = ectx.value(layer, prop); // Keep a shared value column while names and their icons follow the group hierarchy. @@ -150,260 +190,320 @@ fn prop_row( app.show_panel(crate::dock::PanelKind::Timeline); } app.auto.add(&format!("effectControls.prop.{uid}.name"), name_clip, &prop.name); - let merge = format!("ec-{uid}"); // With several layers selected the edit applies to the same effect property of each (#491). let scrub = ui.ctx().dragged_id().is_some() && ui.input(|i| i.pointer.is_decidedly_dragging()); - let set = |actions: &mut Actions, v: serde_json::Value| { - actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": uid, "value": v, "merge": merge, "selected": true, "offset": scrub}))) - }; - let twirl_open = |app: &mut EffectcraftApp, ui: &mut egui::Ui| { - let open = app.ui.fx_slider_open.contains(&uid); - let tw = Rect::from_center_size(pos2(swr.min.x - 9.0, cy), vec2(10.0, 10.0)); - if widgets::twirl(ui, tw, open, egui::Id::unique(("ec-stw", uid)), &t).clicked() { - if open { - app.ui.fx_slider_open.remove(&uid); - } else { - app.ui.fx_slider_open.insert(uid); - } - } - app.auto.add(&format!("effectControls.prop.{uid}.twirl"), tw, &prop.name); - }; + let cx = PropCx { p, layer, group, prop, r, vx, cy, swr, t, uid, scrub }; match &value { - Value::Scalar(v) if matches!(prop.ui, ParamUi::Mask) => { - // A mask of this layer ("Path" popup). - let mut opts = vec!["None".to_string()]; - if let Some(m) = layer.masks() { - opts.extend(m.groups().map(|g| g.name.clone())); - } - let i = v.round().max(0.0) as usize; - let label = opts.get(i).cloned().unwrap_or_else(|| format!("Mask {i}")); - let dr = Rect::from_min_size(pos2(vx, cy - 9.0), vec2((r.max.x - vx - 56.0).clamp(80.0, 200.0), 18.0)); - let pop = egui::Id::unique(("ec-mpop", uid)); - if widgets::dropdown(ui, dr, &label, &t, egui::Id::unique(("ec-m", uid))).clicked() { - widgets::open_popup(ui, pop); - } - app.auto.add(&format!("effectControls.prop.{uid}.value"), dr, &prop.name); - if let Some(ni) = widgets::popup_menu(ui, pop, dr.left_bottom(), &opts, Some(i)) { - set(actions, json!(ni as f64)); - } - } - Value::Scalar(v) if matches!(prop.ui, ParamUi::Angle) => { - let (rr, dr, nv) = fw::angle_field(ui, pos2(vx, cy - 9.0), egui::Id::unique(("ec-v", uid)), *v, 1, &t); - app.auto.add(&format!("effectControls.prop.{uid}.value"), rr.union(dr), &fw::format_angle(*v, 1)); - app.auto.add(&format!("effectControls.prop.{uid}.revolutions"), rr, &prop.name); - if let Some(nv) = nv { - set(actions, json!(nv)); - } - twirl_open(app, ui); - } - Value::Scalar(v) => { - let (min, max, dec) = match &prop.ui { - ParamUi::Slider { min, max, decimals, .. } => (*min, *max, *decimals as usize), - _ => (-1e9, 1e9, 1), - }; - let suffix = match prop.ui { - ParamUi::Percent => "%", - _ => "", - }; - let speed = widgets::slider_scrub_speed(&prop.ui); - let (vr, nv, _) = widgets::hot_number_at( - ui, - pos2(vx, cy - 9.0), - egui::Id::unique(("ec-v", uid)), - *v, - speed, - (min, max), - dec.max(if dec == 0 { 0 } else { 1 }), - suffix, - &t, - ); - app.auto.add(&format!("effectControls.prop.{uid}.value"), vr, &prop.name); - if let Some(nv) = nv { - set(actions, json!(nv)); - } - // AE hides a slider param's slider until its twirl is opened (see `slider_row`). - if matches!(prop.ui, ParamUi::Slider { .. }) { - twirl_open(app, ui); - } - } - Value::Vec2(_) | Value::Vec3(_) => { - let c = value.components(); - let n = shown_dims(layer, prop, &value); - let mut x = vx; - if matches!(prop.ui, ParamUi::Point | ParamUi::Point3) { - let cr = Rect::from_center_size(pos2(x + 8.0, cy), vec2(18.0, 18.0)); - let active = app.ui.fx_pick.as_ref().is_some_and(|k| k.prop == uid && k.kind == "point"); - if fw::crosshair_button(ui, cr, active, egui::Id::unique(("ec-xh", uid)), &t) - .on_hover_text(crate::i18n::tr("Click, then click in the Composition panel")) - .clicked() - { - toggle_pick(app, "point", layer, prop); - } - app.auto.add(&format!("effectControls.prop.{uid}.crosshair"), cr, &prop.name); - x += 22.0; - } - for d in 0..n.min(c.len()) { - let (vr, nv, _) = widgets::hot_number_at(ui, pos2(x, cy - 9.0), egui::Id::unique(("ec-v", uid, d)), c[d], 1.0, (-1e9, 1e9), 1, "", &t); - app.auto.add(&format!("effectControls.prop.{uid}.value.{d}"), vr, &prop.name); - dims.push(vr); - if let Some(nv) = nv { - let mut nc = c.clone(); - nc[d] = nv; - set(actions, json!(nc)); - // Typed into this component only: other selected layers keep the rest of - // their own value (#1434). - if let Some((_, a)) = actions.last_mut() { - a["dims"] = json!([d]); - } - } - x = vr.max.x + 8.0; - } - } - Value::Color(c) => { - let sr = Rect::from_min_size(pos2(vx, cy - 8.0), vec2(30.0, 16.0)); - let pop = egui::Id::unique(("ec-cpop", uid)); - if widgets::swatch(ui, sr, [c[0] as f32, c[1] as f32, c[2] as f32, 1.0], egui::Id::unique(("ec-c", uid)), &t).clicked() { - widgets::open_popup(ui, pop); - } - app.auto.add(&format!("effectControls.prop.{uid}.value"), sr, &prop.name); - let er = Rect::from_center_size(pos2(sr.max.x + 14.0, cy), vec2(18.0, 18.0)); - let active = app.ui.fx_pick.as_ref().is_some_and(|k| k.prop == uid && k.kind == "color"); - if fw::eyedropper_button(ui, er, active, egui::Id::unique(("ec-eye", uid)), &t) - .on_hover_text(crate::i18n::tr("Eyedropper: click, then click in the Composition panel")) - .clicked() - { - toggle_pick(app, "color", layer, prop); - } - app.auto.add(&format!("effectControls.prop.{uid}.eyedropper"), er, &prop.name); - let mut rgb = [c[0] as f32, c[1] as f32, c[2] as f32]; - if crate::widgets::color_popup(ui, pop, sr.left_bottom(), &mut rgb) { - set(actions, json!([rgb[0], rgb[1], rgb[2], 1.0])); - } - } - Value::Bool(b) => { - let cr = Rect::from_min_size(pos2(vx, cy - 8.0), vec2(16.0, 16.0)); - if widgets::checkbox(ui, cr, *b, &t, egui::Id::unique(("ec-b", uid))).clicked() { - set(actions, json!(!b)); - } - p.text(pos2(cr.max.x + 6.0, cy), Align2::LEFT_CENTER, &prop.name, Tokens::ui(12.0), t.text_dim); - app.auto.add(&format!("effectControls.prop.{uid}.value"), cr, &prop.name); - } - Value::Enum(i) => { - if let ParamUi::Popup { options } = &prop.ui { - let widest = options.iter().map(|o| p.layout_no_wrap(o.clone(), Tokens::ui(11.5), t.text).size().x).fold(0.0, f32::max); - let dr = Rect::from_min_size(pos2(vx, cy - 9.0), vec2((widest + 30.0).clamp(80.0, (r.max.x - vx - 56.0).max(80.0)), 18.0)); - let pop = egui::Id::unique(("ec-epop", uid)); - if widgets::dropdown(ui, dr, options.get(*i as usize).map(String::as_str).unwrap_or(""), &t, egui::Id::unique(("ec-e", uid))).clicked() { - widgets::open_popup(ui, pop); - } - app.auto.add(&format!("effectControls.prop.{uid}.value"), dr, &prop.name); - if let Some(ni) = widgets::popup_menu(ui, pop, dr.left_bottom(), options, Some(*i as usize)) { - set(actions, json!(ni)); - } - } - } - Value::Layer(l) => { - let comp = app.session.active_comp_arc(); - if let Some(comp) = comp { - let dr = Rect::from_min_size(pos2(vx, cy - 9.0), vec2(((r.max.x - vx) * 0.55).clamp(90.0, 170.0), 18.0)); - if let Some(chosen) = widgets::layer_picker(ui, dr, &comp.layers, *l, &t, egui::Id::unique(("ec-l", uid))) { - set(actions, chosen.map(|id| json!(id)).unwrap_or(serde_json::Value::Null)); - } - app.auto.add(&format!("effectControls.prop.{uid}.value"), dr, &prop.name); - // Source / Masks / Effects & Masks (the hidden companion parameter). - if let Some(src) = group.get(&effectcraft_engine::effects::layer_source_id(&prop.match_id)) { - let si = src.value.as_enum() as usize; - let opts: Vec = effectcraft_engine::effects::LAYER_SOURCE_OPTIONS.iter().map(|s| s.to_string()).collect(); - let sr = Rect::from_min_size(pos2(dr.max.x + 6.0, cy - 9.0), vec2(((r.max.x - dr.max.x) - 64.0).clamp(70.0, 120.0), 18.0)); - let spop = egui::Id::unique(("ec-lspop", uid)); - if widgets::dropdown(ui, sr, opts.get(si).map(String::as_str).unwrap_or(""), &t, egui::Id::unique(("ec-ls", uid))).clicked() { - widgets::open_popup(ui, spop); - } - app.auto.add(&format!("effectControls.prop.{uid}.source"), sr, &src.name); - if let Some(ni) = widgets::popup_menu(ui, spop, sr.left_bottom(), &opts, Some(si)) { - actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": src.uid, "value": ni}))); - } - } - } - } + Value::Scalar(_) if matches!(prop.ui, ParamUi::Mask) => value_mask(app, ui, &cx, &value, actions), + Value::Scalar(_) if matches!(prop.ui, ParamUi::Angle) => value_angle(app, ui, &cx, &value, actions), + Value::Scalar(_) => value_scalar(app, ui, &cx, &value, actions), + Value::Vec2(_) | Value::Vec3(_) => dims = value_vector(app, ui, &cx, &value, actions), + Value::Color(_) => value_color(app, ui, &cx, &value, actions), + Value::Bool(_) => value_toggle(app, ui, &cx, &value, actions), + Value::Enum(_) => value_popup(app, ui, &cx, &value, actions), + Value::Layer(_) => value_layer_ref(app, ui, &cx, &value, actions), // EXtractoR's red / green / blue / alpha: a popup of the footage's OpenEXR channels (#295). - Value::Str(s) if crate::panels::fx_editors::is_extractor_channel(group, prop) => { - let dr = Rect::from_min_size(pos2(vx, cy - 9.0), vec2((r.max.x - vx - 56.0).clamp(80.0, 200.0), 18.0)); - crate::panels::fx_editors::channel_popup(app, ui, layer, prop, s, dr, actions); - } + Value::Str(_) if crate::panels::fx_editors::is_extractor_channel(group, prop) => value_extractor(app, ui, &cx, &value, actions), // A file parameter (Apply Color LUT's LUT, an OCIO config): its file name and Choose…, // as in After Effects. - Value::Str(s) if matches!(prop.ui, ParamUi::File { .. }) => { - let bw = 70.0; - let br = Rect::from_min_size(pos2(vx, cy - 9.0), vec2(bw, 18.0)); - if widgets::text_button(ui, br, crate::i18n::tr("Choose..."), false, &t, egui::Id::unique(("ec-file", uid))).clicked() { - let filters: Vec<&str> = match &prop.ui { - ParamUi::File { filters } => filters.iter().map(String::as_str).collect(), - _ => vec![], - }; - match app.hooks.pick_files.as_ref() { - Some(pick) => { - if let Some(path) = pick(&filters).into_iter().next() { - actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": uid, "value": path}))); - } - } - None => app.ui.status = "no file dialog available: set the path with prop.set".into(), - } - } - app.auto.add(&format!("effectControls.prop.{uid}.choose"), br, &prop.name); - let shown = if s.trim().is_empty() { - crate::i18n::tr("None").to_string() - } else if s.contains('\n') { - prop.name.clone() - } else { - effectcraft_engine::sequence::file_name(s) - }; - let nr = Rect::from_min_max(pos2(br.max.x + 8.0, r.min.y), pos2(r.max.x - 8.0, r.max.y)); - let np = p.with_clip_rect(nr.intersect(p.clip_rect())); - np.text(pos2(nr.min.x, cy), Align2::LEFT_CENTER, &shown, Tokens::ui(12.0), if s.trim().is_empty() { t.text_dim } else { t.text }); - let hover = ui.interact(nr, egui::Id::unique(("ec-file-name", uid)), Sense::hover()); - if !s.trim().is_empty() && !s.contains('\n') { - hover.on_hover_text(s.as_str()); - } - app.auto.add(&format!("effectControls.prop.{uid}.value"), nr, &shown); - } + Value::Str(_) if matches!(prop.ui, ParamUi::File { .. }) => value_file(app, ui, &cx, &value, actions), // A text parameter (Cryptomatte's Selection, Colorama's Output Cycle, config names): // typed in a field, set when it loses the focus (Esc keeps the old text). - Value::Str(s) if matches!(prop.ui, ParamUi::Text) => { - let fr = Rect::from_min_size(pos2(vx, cy - 9.0), vec2((r.max.x - vx - 12.0).clamp(80.0, 320.0), 18.0)); - let bid = egui::Id::unique(("ec-text", uid)); - let mut buf: String = ui.data(|d| d.get_temp(bid)).unwrap_or_else(|| s.clone()); - let resp = widgets::text_field(ui, fr, &mut buf, "", &t); - // The field has no visible hint: give the screen reader the parameter's name. - resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::TextInput, ui.is_enabled(), &prop.name)); - app.auto.add(&format!("effectControls.prop.{uid}.value"), fr, &prop.name); - if resp.lost_focus() { - if buf != *s && !ui.input(|i| i.key_pressed(egui::Key::Escape)) { - actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": uid, "value": buf}))); - } - ui.data_mut(|d| d.remove::(bid)); - } else if resp.has_focus() { - ui.data_mut(|d| d.insert_temp(bid, buf)); - } - } - Value::Gradient(g) => { - let gr = Rect::from_min_size(pos2(vx, cy - 7.0), vec2((r.max.x - vx - 56.0).clamp(60.0, 180.0), 14.0)); - let n = 48; - for k in 0..n { - let f = k as f64 / (n - 1) as f64; - let c = gradient_at(g, f); - let x0 = gr.min.x + gr.width() * k as f32 / n as f32; - let x1 = gr.min.x + gr.width() * (k + 1) as f32 / n as f32; - p.rect_filled(Rect::from_min_max(pos2(x0, gr.min.y), pos2(x1, gr.max.y)), 0.0, c); - } - p.rect_stroke(gr, 2.0, Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside); - app.auto.add(&format!("effectControls.prop.{uid}.value"), gr, &prop.name); - } + Value::Str(_) if matches!(prop.ui, ParamUi::Text) => value_text(app, ui, &cx, &value, actions), + Value::Gradient(_) => value_gradient(app, &cx, &value), _ => {} } dims } +fn value_mask(app: &mut EffectcraftApp, ui: &mut egui::Ui, cx: &PropCx, value: &Value, actions: &mut Actions) { + let Value::Scalar(v) = value else { + return; + }; + let PropCx { t, uid, cy, vx, r, layer, prop, .. } = *cx; + // A mask of this layer ("Path" popup). + let mut opts = vec!["None".to_string()]; + if let Some(m) = layer.masks() { + opts.extend(m.groups().map(|g| g.name.clone())); + } + let i = v.round().max(0.0) as usize; + let label = opts.get(i).cloned().unwrap_or_else(|| format!("Mask {i}")); + let dr = Rect::from_min_size(pos2(vx, cy - 9.0), vec2((r.max.x - vx - 56.0).clamp(80.0, 200.0), 18.0)); + let pop = egui::Id::unique(("ec-mpop", uid)); + if widgets::dropdown(ui, dr, &label, &t, egui::Id::unique(("ec-m", uid))).clicked() { + widgets::open_popup(ui, pop); + } + app.auto.add(&format!("effectControls.prop.{uid}.value"), dr, &prop.name); + if let Some(ni) = widgets::popup_menu(ui, pop, dr.left_bottom(), &opts, Some(i)) { + cx.set(actions, json!(ni as f64)); + } +} + +fn value_angle(app: &mut EffectcraftApp, ui: &mut egui::Ui, cx: &PropCx, value: &Value, actions: &mut Actions) { + let Value::Scalar(v) = value else { + return; + }; + let PropCx { t, uid, cy, vx, prop, .. } = *cx; + let (rr, dr, nv) = fw::angle_field(ui, pos2(vx, cy - 9.0), egui::Id::unique(("ec-v", uid)), *v, 1, &t); + app.auto.add(&format!("effectControls.prop.{uid}.value"), rr.union(dr), &fw::format_angle(*v, 1)); + app.auto.add(&format!("effectControls.prop.{uid}.revolutions"), rr, &prop.name); + if let Some(nv) = nv { + cx.set(actions, json!(nv)); + } + cx.twirl_open(app, ui); +} + +fn value_scalar(app: &mut EffectcraftApp, ui: &mut egui::Ui, cx: &PropCx, value: &Value, actions: &mut Actions) { + let Value::Scalar(v) = value else { + return; + }; + let PropCx { t, uid, cy, vx, prop, .. } = *cx; + let (min, max, dec) = match &prop.ui { + ParamUi::Slider { min, max, decimals, .. } => (*min, *max, *decimals as usize), + _ => (-1e9, 1e9, 1), + }; + let suffix = match prop.ui { + ParamUi::Percent => "%", + _ => "", + }; + let speed = widgets::slider_scrub_speed(&prop.ui); + let (vr, nv, _) = widgets::hot_number_at( + ui, + pos2(vx, cy - 9.0), + egui::Id::unique(("ec-v", uid)), + *v, + &widgets::HotSpec { speed, range: (min, max), decimals: dec.max(if dec == 0 { 0 } else { 1 }), suffix }, + &t, + ); + app.auto.add(&format!("effectControls.prop.{uid}.value"), vr, &prop.name); + if let Some(nv) = nv { + cx.set(actions, json!(nv)); + } + // AE hides a slider param's slider until its twirl is opened (see `slider_row`). + if matches!(prop.ui, ParamUi::Slider { .. }) { + cx.twirl_open(app, ui); + } +} + +fn value_vector(app: &mut EffectcraftApp, ui: &mut egui::Ui, cx: &PropCx, value: &Value, actions: &mut Actions) -> Vec { + let mut dims = vec![]; + let PropCx { t, uid, cy, vx, layer, prop, .. } = *cx; + let c = value.components(); + let n = shown_dims(layer, prop, value); + let mut x = vx; + if matches!(prop.ui, ParamUi::Point | ParamUi::Point3) { + let cr = Rect::from_center_size(pos2(x + 8.0, cy), vec2(18.0, 18.0)); + let active = app.ui.fx_pick.as_ref().is_some_and(|k| k.prop == uid && k.kind == "point"); + if fw::crosshair_button(ui, cr, active, egui::Id::unique(("ec-xh", uid)), &t) + .on_hover_text(crate::i18n::tr("Click, then click in the Composition panel")) + .clicked() + { + toggle_pick(app, "point", layer, prop); + } + app.auto.add(&format!("effectControls.prop.{uid}.crosshair"), cr, &prop.name); + x += 22.0; + } + for d in 0..n.min(c.len()) { + let (vr, nv, _) = widgets::hot_number_at( + ui, + pos2(x, cy - 9.0), + egui::Id::unique(("ec-v", uid, d)), + c[d], + &widgets::HotSpec { speed: 1.0, range: (-1e9, 1e9), decimals: 1, suffix: "" }, + &t, + ); + app.auto.add(&format!("effectControls.prop.{uid}.value.{d}"), vr, &prop.name); + dims.push(vr); + if let Some(nv) = nv { + let mut nc = c.clone(); + nc[d] = nv; + cx.set(actions, json!(nc)); + // Typed into this component only: other selected layers keep the rest of + // their own value (#1434). + if let Some((_, a)) = actions.last_mut() { + a["dims"] = json!([d]); + } + } + x = vr.max.x + 8.0; + } + dims +} + +fn value_color(app: &mut EffectcraftApp, ui: &mut egui::Ui, cx: &PropCx, value: &Value, actions: &mut Actions) { + let Value::Color(c) = value else { + return; + }; + let PropCx { t, uid, cy, vx, layer, prop, .. } = *cx; + let sr = Rect::from_min_size(pos2(vx, cy - 8.0), vec2(30.0, 16.0)); + let pop = egui::Id::unique(("ec-cpop", uid)); + if widgets::swatch(ui, sr, [c[0] as f32, c[1] as f32, c[2] as f32, 1.0], egui::Id::unique(("ec-c", uid)), &t).clicked() { + widgets::open_popup(ui, pop); + } + app.auto.add(&format!("effectControls.prop.{uid}.value"), sr, &prop.name); + let er = Rect::from_center_size(pos2(sr.max.x + 14.0, cy), vec2(18.0, 18.0)); + let active = app.ui.fx_pick.as_ref().is_some_and(|k| k.prop == uid && k.kind == "color"); + if fw::eyedropper_button(ui, er, active, egui::Id::unique(("ec-eye", uid)), &t) + .on_hover_text(crate::i18n::tr("Eyedropper: click, then click in the Composition panel")) + .clicked() + { + toggle_pick(app, "color", layer, prop); + } + app.auto.add(&format!("effectControls.prop.{uid}.eyedropper"), er, &prop.name); + let mut rgb = [c[0] as f32, c[1] as f32, c[2] as f32]; + if crate::widgets::color_popup(ui, pop, sr.left_bottom(), &mut rgb) { + cx.set(actions, json!([rgb[0], rgb[1], rgb[2], 1.0])); + } +} + +fn value_toggle(app: &mut EffectcraftApp, ui: &mut egui::Ui, cx: &PropCx, value: &Value, actions: &mut Actions) { + let Value::Bool(b) = value else { + return; + }; + let PropCx { t, uid, cy, vx, p, prop, .. } = *cx; + let cr = Rect::from_min_size(pos2(vx, cy - 8.0), vec2(16.0, 16.0)); + if widgets::checkbox(ui, cr, *b, &t, egui::Id::unique(("ec-b", uid))).clicked() { + cx.set(actions, json!(!b)); + } + p.text(pos2(cr.max.x + 6.0, cy), Align2::LEFT_CENTER, &prop.name, Tokens::ui(12.0), t.text_dim); + app.auto.add(&format!("effectControls.prop.{uid}.value"), cr, &prop.name); +} + +fn value_popup(app: &mut EffectcraftApp, ui: &mut egui::Ui, cx: &PropCx, value: &Value, actions: &mut Actions) { + let Value::Enum(i) = value else { + return; + }; + let PropCx { t, uid, cy, vx, r, p, prop, .. } = *cx; + if let ParamUi::Popup { options } = &prop.ui { + let widest = options.iter().map(|o| p.layout_no_wrap(o.clone(), Tokens::ui(11.5), t.text).size().x).fold(0.0, f32::max); + let dr = Rect::from_min_size(pos2(vx, cy - 9.0), vec2((widest + 30.0).clamp(80.0, (r.max.x - vx - 56.0).max(80.0)), 18.0)); + let pop = egui::Id::unique(("ec-epop", uid)); + if widgets::dropdown(ui, dr, options.get(*i as usize).map(String::as_str).unwrap_or(""), &t, egui::Id::unique(("ec-e", uid))).clicked() { + widgets::open_popup(ui, pop); + } + app.auto.add(&format!("effectControls.prop.{uid}.value"), dr, &prop.name); + if let Some(ni) = widgets::popup_menu(ui, pop, dr.left_bottom(), options, Some(*i as usize)) { + cx.set(actions, json!(ni)); + } + } +} + +fn value_layer_ref(app: &mut EffectcraftApp, ui: &mut egui::Ui, cx: &PropCx, value: &Value, actions: &mut Actions) { + let Value::Layer(l) = value else { + return; + }; + let PropCx { t, uid, cy, vx, r, layer, prop, group, .. } = *cx; + let comp = app.session.active_comp_arc(); + if let Some(comp) = comp { + let dr = Rect::from_min_size(pos2(vx, cy - 9.0), vec2(((r.max.x - vx) * 0.55).clamp(90.0, 170.0), 18.0)); + if let Some(chosen) = widgets::layer_picker(ui, dr, &comp.layers, *l, &t, egui::Id::unique(("ec-l", uid))) { + cx.set(actions, chosen.map(|id| json!(id)).unwrap_or(serde_json::Value::Null)); + } + app.auto.add(&format!("effectControls.prop.{uid}.value"), dr, &prop.name); + // Source / Masks / Effects & Masks (the hidden companion parameter). + if let Some(src) = group.get(&effectcraft_engine::effects::layer_source_id(&prop.match_id)) { + let si = src.value.as_enum() as usize; + let opts: Vec = effectcraft_engine::effects::LAYER_SOURCE_OPTIONS.iter().map(|s| s.to_string()).collect(); + let sr = Rect::from_min_size(pos2(dr.max.x + 6.0, cy - 9.0), vec2(((r.max.x - dr.max.x) - 64.0).clamp(70.0, 120.0), 18.0)); + let spop = egui::Id::unique(("ec-lspop", uid)); + if widgets::dropdown(ui, sr, opts.get(si).map(String::as_str).unwrap_or(""), &t, egui::Id::unique(("ec-ls", uid))).clicked() { + widgets::open_popup(ui, spop); + } + app.auto.add(&format!("effectControls.prop.{uid}.source"), sr, &src.name); + if let Some(ni) = widgets::popup_menu(ui, spop, sr.left_bottom(), &opts, Some(si)) { + actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": src.uid, "value": ni}))); + } + } + } +} + +fn value_extractor(app: &mut EffectcraftApp, ui: &mut egui::Ui, cx: &PropCx, value: &Value, actions: &mut Actions) { + let Value::Str(s) = value else { + return; + }; + let PropCx { cy, vx, r, layer, prop, .. } = *cx; + let dr = Rect::from_min_size(pos2(vx, cy - 9.0), vec2((r.max.x - vx - 56.0).clamp(80.0, 200.0), 18.0)); + crate::panels::fx_editors::channel_popup(app, ui, layer, prop, s, dr, actions); +} + +fn value_file(app: &mut EffectcraftApp, ui: &mut egui::Ui, cx: &PropCx, value: &Value, actions: &mut Actions) { + let Value::Str(s) = value else { + return; + }; + let PropCx { t, uid, cy, vx, r, p, layer, prop, .. } = *cx; + let bw = 70.0; + let br = Rect::from_min_size(pos2(vx, cy - 9.0), vec2(bw, 18.0)); + if widgets::text_button(ui, br, crate::i18n::tr("Choose..."), false, &t, egui::Id::unique(("ec-file", uid))).clicked() { + let filters: Vec<&str> = match &prop.ui { + ParamUi::File { filters } => filters.iter().map(String::as_str).collect(), + _ => vec![], + }; + match app.hooks.pick_files.as_ref() { + Some(pick) => { + if let Some(path) = pick(&filters).into_iter().next() { + actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": uid, "value": path}))); + } + } + None => app.ui.status = "no file dialog available: set the path with prop.set".into(), + } + } + app.auto.add(&format!("effectControls.prop.{uid}.choose"), br, &prop.name); + let shown = if s.trim().is_empty() { + crate::i18n::tr("None").to_string() + } else if s.contains('\n') { + prop.name.clone() + } else { + effectcraft_engine::sequence::file_name(s) + }; + let nr = Rect::from_min_max(pos2(br.max.x + 8.0, r.min.y), pos2(r.max.x - 8.0, r.max.y)); + let np = p.with_clip_rect(nr.intersect(p.clip_rect())); + np.text(pos2(nr.min.x, cy), Align2::LEFT_CENTER, &shown, Tokens::ui(12.0), if s.trim().is_empty() { t.text_dim } else { t.text }); + let hover = ui.interact(nr, egui::Id::unique(("ec-file-name", uid)), Sense::hover()); + if !s.trim().is_empty() && !s.contains('\n') { + hover.on_hover_text(s); + } + app.auto.add(&format!("effectControls.prop.{uid}.value"), nr, &shown); +} + +fn value_text(app: &mut EffectcraftApp, ui: &mut egui::Ui, cx: &PropCx, value: &Value, actions: &mut Actions) { + let Value::Str(s) = value else { + return; + }; + let PropCx { t, uid, cy, vx, r, layer, prop, .. } = *cx; + let fr = Rect::from_min_size(pos2(vx, cy - 9.0), vec2((r.max.x - vx - 12.0).clamp(80.0, 320.0), 18.0)); + let bid = egui::Id::unique(("ec-text", uid)); + let mut buf: String = ui.data(|d| d.get_temp(bid)).unwrap_or_else(|| s.to_owned()); + let resp = widgets::text_field(ui, fr, &mut buf, "", &t); + // The field has no visible hint: give the screen reader the parameter's name. + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::TextInput, ui.is_enabled(), &prop.name)); + app.auto.add(&format!("effectControls.prop.{uid}.value"), fr, &prop.name); + if resp.lost_focus() { + if buf != *s && !ui.input(|i| i.key_pressed(egui::Key::Escape)) { + actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": uid, "value": buf}))); + } + ui.data_mut(|d| d.remove::(bid)); + } else if resp.has_focus() { + ui.data_mut(|d| d.insert_temp(bid, buf)); + } +} + +fn value_gradient(app: &mut EffectcraftApp, cx: &PropCx, value: &Value) { + let Value::Gradient(g) = value else { + return; + }; + let PropCx { t, uid, cy, vx, r, p, prop, .. } = *cx; + let gr = Rect::from_min_size(pos2(vx, cy - 7.0), vec2((r.max.x - vx - 56.0).clamp(60.0, 180.0), 14.0)); + let n = 48; + for k in 0..n { + let f = k as f64 / (n - 1) as f64; + let c = gradient_at(g, f); + let x0 = gr.min.x + gr.width() * k as f32 / n as f32; + let x1 = gr.min.x + gr.width() * (k + 1) as f32 / n as f32; + p.rect_filled(Rect::from_min_max(pos2(x0, gr.min.y), pos2(x1, gr.max.y)), 0.0, c); + } + p.rect_stroke(gr, 2.0, Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside); + app.auto.add(&format!("effectControls.prop.{uid}.value"), gr, &prop.name); +} + /// How many of a vector parameter's values show (a 3D point's Z only on a 3D layer). fn shown_dims(layer: &Layer, prop: &Property, value: &Value) -> usize { let n = value.components().len(); diff --git a/crates/ui-egui/src/panels/effect_controls/viewer.rs b/crates/ui-egui/src/panels/effect_controls/viewer.rs index e71632180..b11b63a35 100644 --- a/crates/ui-egui/src/panels/effect_controls/viewer.rs +++ b/crates/ui-egui/src/panels/effect_controls/viewer.rs @@ -1,6 +1,10 @@ +use std::ops::ControlFlow; + use super::common::{Actions, effect_active, gesture_key}; use super::rows::FxScope; -use super::*; +use super::{ + Align2, Color32, EffectcraftApp, EvalCtx, GroupKind, Layer, Mat3, Node, ParamUi, PropGroup, Property, Rect, Sense, Stroke, Tokens, fw, json, vec2, +}; // --------------------------------------------------------------------------------------------- // Viewer hook @@ -22,6 +26,156 @@ fn visible_points<'a>(g: &'a PropGroup, scope: &FxScope<'_>, layer: &Layer, ectx } } +/// An armed crosshair / eyedropper pick: the hint chip, the cursor preview, and the click that +/// sets or samples. `Break` when the picked layer is gone; `Continue` carries the point preview. +fn fx_pick_session( + app: &mut EffectcraftApp, + ui: &mut egui::Ui, + painter: &egui::Painter, + map: &crate::panels::viewer::ViewerMap, + ectx: &EvalCtx, + l2c: &dyn Fn(&EvalCtx, &Layer) -> Mat3, + actions: &mut Actions, +) -> ControlFlow<(), Option> { + let t = app.tokens; + let ctx = ui.ctx().clone(); + let mut point_preview = None; + let Some(pick) = app.ui.fx_pick.clone() else { + return ControlFlow::Continue(None); + }; + let Some(layer) = ectx.comp.layer(effectcraft_engine::project::LayerId(pick.layer)).cloned() else { + app.ui.fx_pick = None; + return ControlFlow::Break(()); + }; + // Keyers pick from their input (the shown frame is already keyed): `effect.pickColor`. + let keyer = (pick.kind == "color") + .then(|| layer.effects()?.groups().find(|g| g.find(pick.prop).is_some())) + .flatten() + .filter(|g| effectcraft_engine::effects::find(&g.match_id).is_some_and(|s| s.category == "Keying")) + .map(|g| g.uid); + if pick.kind == "color" && keyer.is_none() { + // GPU frames: read the shown frame back for sampling. + app.viewer_pixels(); + } + let resp = ui.interact(map.area, egui::Id::unique("viewer-fx-pick"), Sense::click()); + app.auto.add("viewer.fxPick", map.area, &pick.name); + let hint = if pick.kind == "point" { format!("Click to set {}", pick.name) } else { format!("Click to sample {}", pick.name) }; + let chip = Rect::from_min_size(map.area.min + vec2(10.0, 10.0), vec2(220.0, 22.0)); + painter.rect_filled(chip, 11.0, Color32::from_black_alpha(180)); + painter.text(chip.center(), Align2::CENTER_CENTER, hint, Tokens::ui(11.5), t.text); + if let Some(hp) = resp.hover_pos() { + ctx.set_cursor_icon(egui::CursorIcon::Crosshair); + let c = map.to_comp(hp); + app.pointer_comp = Some([c[0] as f32, c[1] as f32]); + if pick.kind == "point" && fw::comp_to_layer(&l2c(ectx, &layer), c).is_some() { + point_preview = Some(hp); + } + if pick.kind == "color" + && keyer.is_none() + && let Some(img) = &app.viewer_image + && let Some(s) = fw::sample_frame(img, crate::panels::viewer_tools::shown_region(&ctx, map.comp), c) + { + let sw = Rect::from_min_size(hp + vec2(14.0, 14.0), vec2(26.0, 18.0)); + painter.rect_filled(sw, 2.0, Color32::from_rgb((s[0] * 255.0) as u8, (s[1] * 255.0) as u8, (s[2] * 255.0) as u8)); + painter.rect_stroke(sw, 2.0, Stroke::new(1.0, Color32::WHITE), egui::StrokeKind::Outside); + } + } + // No frame to sample yet (it is still rendering, or its readback is pending): the click + // leaves the eyedropper armed instead of using it up without a colour. + let unsampled = pick.kind == "color" && keyer.is_none() && app.viewer_image.is_none(); + if resp.clicked() + && !unsampled + && let Some(pos) = resp.interact_pointer_pos() + { + let c = map.to_comp(pos); + // Effect points and the keyer's input are in effect space. + let m = crate::panels::viewer::from_effect_space(ectx, &layer, l2c(ectx, &layer)); + if pick.kind == "point" { + if let Some(lp) = fw::comp_to_layer(&m, c) { + actions.push(("prop.set".into(), json!({"layer": pick.layer, "prop": pick.prop, "value": [lp[0], lp[1]]}))); + } + } else if let Some(fx) = keyer { + // Ctrl/Cmd+click averages the 5 × 5 pixels around the point. + if let Some(lp) = fw::comp_to_layer(&m, c) { + let average = ui.input(|i| i.modifiers.command); + actions.push(( + "effect.pickColor".into(), + json!({"layer": pick.layer, "effect": fx, "prop": pick.prop, "x": lp[0], "y": lp[1], "average": average}), + )); + } + } else if let Some(img) = &app.viewer_image + && let Some(s) = fw::sample_frame(img, crate::panels::viewer_tools::shown_region(&ctx, map.comp), c) + { + actions.push(("prop.set".into(), json!({"layer": pick.layer, "prop": pick.prop, "value": [s[0], s[1], s[2], 1.0]}))); + } + app.ui.fx_pick = None; + app.ui.status.clear(); + } + if ui.input(|i| i.key_pressed(egui::Key::Escape)) { + app.ui.fx_pick = None; + app.ui.status.clear(); + } + + ControlFlow::Continue(point_preview) +} + +/// An armed text-colour pick: sample the shown frame on click, Esc cancels. +fn text_color_pick( + app: &mut EffectcraftApp, + ui: &mut egui::Ui, + painter: &egui::Painter, + map: &crate::panels::viewer::ViewerMap, + ectx: &EvalCtx, + actions: &mut Actions, +) { + let t = app.tokens; + let ctx = ui.ctx().clone(); + let Some(pick) = app.ui.text_color_pick.clone() else { + return; + }; + if ectx.comp.layer(effectcraft_engine::project::LayerId(pick.layer)).is_none() { + app.ui.text_color_pick = None; + app.ui.status.clear(); + } else { + app.viewer_pixels(); + let resp = ui.interact(map.area, egui::Id::unique("viewer-text-color-pick"), Sense::click()); + app.auto.add("viewer.textColorPick", map.area, &pick.field); + let chip = Rect::from_min_size(map.area.min + vec2(10.0, 10.0), vec2(220.0, 22.0)); + painter.rect_filled(chip, 11.0, Color32::from_black_alpha(180)); + painter.text(chip.center(), Align2::CENTER_CENTER, crate::i18n::tr_args("Click to sample text {}", &[&pick.field]), Tokens::ui(11.5), t.text); + if let Some(pos) = resp.hover_pos() { + ctx.set_cursor_icon(egui::CursorIcon::Crosshair); + let c = map.to_comp(pos); + app.pointer_comp = Some([c[0] as f32, c[1] as f32]); + if let Some(img) = &app.viewer_image + && let Some(s) = fw::sample_frame(img, crate::panels::viewer_tools::shown_region(&ctx, map.comp), c) + { + let sw = Rect::from_min_size(pos + vec2(14.0, 14.0), vec2(26.0, 18.0)); + painter.rect_filled(sw, 2.0, Color32::from_rgb((s[0] * 255.0) as u8, (s[1] * 255.0) as u8, (s[2] * 255.0) as u8)); + painter.rect_stroke(sw, 2.0, Stroke::new(1.0, Color32::WHITE), egui::StrokeKind::Outside); + } + } + if resp.clicked() + && let Some(pos) = resp.interact_pointer_pos() + && let Some(img) = &app.viewer_image + && let Some(s) = fw::sample_frame(img, crate::panels::viewer_tools::shown_region(&ctx, map.comp), map.to_comp(pos)) + { + let mut params = json!({"layer": pick.layer}); + params[&pick.field] = json!([s[0], s[1], s[2]]); + if let Some(range) = pick.range { + params["range"] = json!(range); + } + actions.push(("layer.setText".into(), params)); + app.ui.text_color_pick = None; + app.ui.status.clear(); + } + if ui.input(|i| i.key_pressed(egui::Key::Escape)) { + app.ui.text_color_pick = None; + app.ui.status.clear(); + } + } +} + /// Effect Controls' part of the Composition viewer, called by the viewer after its own /// interaction (so these controls sit on top): /// @@ -42,124 +196,11 @@ pub fn viewer_hook( let t = app.tokens; let ctx = ui.ctx().clone(); let mut actions: Actions = vec![]; - let mut point_preview = None; - if let Some(pick) = app.ui.fx_pick.clone() { - let Some(layer) = ectx.comp.layer(effectcraft_engine::project::LayerId(pick.layer)).cloned() else { - app.ui.fx_pick = None; - return; - }; - // Keyers pick from their input (the shown frame is already keyed): `effect.pickColor`. - let keyer = (pick.kind == "color") - .then(|| layer.effects()?.groups().find(|g| g.find(pick.prop).is_some())) - .flatten() - .filter(|g| effectcraft_engine::effects::find(&g.match_id).is_some_and(|s| s.category == "Keying")) - .map(|g| g.uid); - if pick.kind == "color" && keyer.is_none() { - // GPU frames: read the shown frame back for sampling. - app.viewer_pixels(); - } - let resp = ui.interact(map.area, egui::Id::unique("viewer-fx-pick"), Sense::click()); - app.auto.add("viewer.fxPick", map.area, &pick.name); - let hint = if pick.kind == "point" { format!("Click to set {}", pick.name) } else { format!("Click to sample {}", pick.name) }; - let chip = Rect::from_min_size(map.area.min + vec2(10.0, 10.0), vec2(220.0, 22.0)); - painter.rect_filled(chip, 11.0, Color32::from_black_alpha(180)); - painter.text(chip.center(), Align2::CENTER_CENTER, hint, Tokens::ui(11.5), t.text); - if let Some(hp) = resp.hover_pos() { - ctx.set_cursor_icon(egui::CursorIcon::Crosshair); - let c = map.to_comp(hp); - app.pointer_comp = Some([c[0] as f32, c[1] as f32]); - if pick.kind == "point" && fw::comp_to_layer(&l2c(ectx, &layer), c).is_some() { - point_preview = Some(hp); - } - if pick.kind == "color" - && keyer.is_none() - && let Some(img) = &app.viewer_image - && let Some(s) = fw::sample_frame(img, crate::panels::viewer_tools::shown_region(&ctx, map.comp), c) - { - let sw = Rect::from_min_size(hp + vec2(14.0, 14.0), vec2(26.0, 18.0)); - painter.rect_filled(sw, 2.0, Color32::from_rgb((s[0] * 255.0) as u8, (s[1] * 255.0) as u8, (s[2] * 255.0) as u8)); - painter.rect_stroke(sw, 2.0, Stroke::new(1.0, Color32::WHITE), egui::StrokeKind::Outside); - } - } - // No frame to sample yet (it is still rendering, or its readback is pending): the click - // leaves the eyedropper armed instead of using it up without a colour. - let unsampled = pick.kind == "color" && keyer.is_none() && app.viewer_image.is_none(); - if resp.clicked() - && !unsampled - && let Some(pos) = resp.interact_pointer_pos() - { - let c = map.to_comp(pos); - // Effect points and the keyer's input are in effect space. - let m = crate::panels::viewer::from_effect_space(ectx, &layer, l2c(ectx, &layer)); - if pick.kind == "point" { - if let Some(lp) = fw::comp_to_layer(&m, c) { - actions.push(("prop.set".into(), json!({"layer": pick.layer, "prop": pick.prop, "value": [lp[0], lp[1]]}))); - } - } else if let Some(fx) = keyer { - // Ctrl/Cmd+click averages the 5 × 5 pixels around the point. - if let Some(lp) = fw::comp_to_layer(&m, c) { - let average = ui.input(|i| i.modifiers.command); - actions.push(( - "effect.pickColor".into(), - json!({"layer": pick.layer, "effect": fx, "prop": pick.prop, "x": lp[0], "y": lp[1], "average": average}), - )); - } - } else if let Some(img) = &app.viewer_image - && let Some(s) = fw::sample_frame(img, crate::panels::viewer_tools::shown_region(&ctx, map.comp), c) - { - actions.push(("prop.set".into(), json!({"layer": pick.layer, "prop": pick.prop, "value": [s[0], s[1], s[2], 1.0]}))); - } - app.ui.fx_pick = None; - app.ui.status.clear(); - } - if ui.input(|i| i.key_pressed(egui::Key::Escape)) { - app.ui.fx_pick = None; - app.ui.status.clear(); - } - } - if let Some(pick) = app.ui.text_color_pick.clone() { - if ectx.comp.layer(effectcraft_engine::project::LayerId(pick.layer)).is_none() { - app.ui.text_color_pick = None; - app.ui.status.clear(); - } else { - app.viewer_pixels(); - let resp = ui.interact(map.area, egui::Id::unique("viewer-text-color-pick"), Sense::click()); - app.auto.add("viewer.textColorPick", map.area, &pick.field); - let chip = Rect::from_min_size(map.area.min + vec2(10.0, 10.0), vec2(220.0, 22.0)); - painter.rect_filled(chip, 11.0, Color32::from_black_alpha(180)); - painter.text(chip.center(), Align2::CENTER_CENTER, crate::i18n::tr_args("Click to sample text {}", &[&pick.field]), Tokens::ui(11.5), t.text); - if let Some(pos) = resp.hover_pos() { - ctx.set_cursor_icon(egui::CursorIcon::Crosshair); - let c = map.to_comp(pos); - app.pointer_comp = Some([c[0] as f32, c[1] as f32]); - if let Some(img) = &app.viewer_image - && let Some(s) = fw::sample_frame(img, crate::panels::viewer_tools::shown_region(&ctx, map.comp), c) - { - let sw = Rect::from_min_size(pos + vec2(14.0, 14.0), vec2(26.0, 18.0)); - painter.rect_filled(sw, 2.0, Color32::from_rgb((s[0] * 255.0) as u8, (s[1] * 255.0) as u8, (s[2] * 255.0) as u8)); - painter.rect_stroke(sw, 2.0, Stroke::new(1.0, Color32::WHITE), egui::StrokeKind::Outside); - } - } - if resp.clicked() - && let Some(pos) = resp.interact_pointer_pos() - && let Some(img) = &app.viewer_image - && let Some(s) = fw::sample_frame(img, crate::panels::viewer_tools::shown_region(&ctx, map.comp), map.to_comp(pos)) - { - let mut params = json!({"layer": pick.layer}); - params[&pick.field] = json!([s[0], s[1], s[2]]); - if let Some(range) = pick.range { - params["range"] = json!(range); - } - actions.push(("layer.setText".into(), params)); - app.ui.text_color_pick = None; - app.ui.status.clear(); - } - if ui.input(|i| i.key_pressed(egui::Key::Escape)) { - app.ui.text_color_pick = None; - app.ui.status.clear(); - } - } - } + let point_preview = match fx_pick_session(app, ui, painter, map, ectx, l2c, &mut actions) { + ControlFlow::Break(()) => return, + ControlFlow::Continue(p) => p, + }; + text_color_pick(app, ui, painter, map, ectx, &mut actions); let picking = app.ui.fx_pick.is_some() || app.ui.text_color_pick.is_some(); let point_pick = app.ui.fx_pick.clone().filter(|p| p.kind == "point"); if app.ui.viewer.show_layer_controls || point_pick.is_some() { diff --git a/crates/ui-egui/src/panels/effects_presets.rs b/crates/ui-egui/src/panels/effects_presets.rs index fa7d2ac40..380d1d869 100644 --- a/crates/ui-egui/src/panels/effects_presets.rs +++ b/crates/ui-egui/src/panels/effects_presets.rs @@ -264,32 +264,13 @@ fn user_presets(ctx: &egui::Context, force: bool, scan: impl FnOnce() -> Vec
 bool {
     // Contents menu.
     let mr = Rect::from_center_size(pos2(rect.max.x - 18.0, sr.center().y), vec2(22.0, 22.0));
     let mpop = egui::Id::unique("fx-contents-menu");
-    if widgets::icon_button(ui, mr, Icon::Hamburger, false, &t, egui::Id::unique("fx-menu")).on_hover_text(tr("Effects & Presets options")).clicked() {
+    if widgets::icon_button(ui, mr, Icon::Hamburger, false, t, egui::Id::unique("fx-menu")).on_hover_text(tr("Effects & Presets options")).clicked() {
         widgets::open_popup(ui, mpop);
     }
     app.auto.add("effects.menu", mr, "Effects & Presets options");
@@ -339,7 +320,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         let v = &mut app.ui.effects_view;
         match i {
             i if menu.get(i) == Some(©_label) => {
-                if let Err(e) = crate::menus::invoke(app, &ctx, "effects.copyPresetsFolderPath", json!({})) {
+                if let Err(e) = crate::menus::invoke(app, ctx, "effects.copyPresetsFolderPath", json!({})) {
                     app.ui.status = e;
                 }
             }
@@ -354,6 +335,32 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
             _ => {}
         }
     }
+    force_scan
+}
+
+/// The Effects & Presets panel: search, a contents menu (colour-depth and GPU filters, whether
+/// presets show, categories or one alphabetical list, refresh and clear of the recently used
+/// list), then Favourites, Recently Used, Animation Presets and the effects by category or
+/// alphabetically, filtered by the query. Picking an effect or preset applies it to the selection
+/// (`effect.apply` or the preset's own command); applying an effect also records it as recently
+/// used and opens Effect Controls. Without a selected layer it only reports that in
+/// `app.ui.status`.
+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().clone();
+    // Track the last applied effect (menus, drag and drop, the control channel) as recently used.
+    if let Some(last) = app.session.state.last_effect.clone()
+        && app.ui.effects_recent.first() != Some(&last)
+    {
+        push_recent(&mut app.ui.effects_recent, &last);
+    }
+    let sr = Rect::from_min_size(rect.min + vec2(8.0, 8.0), vec2(rect.width() - 44.0, 24.0));
+    let mut q = app.ui.effects_search.clone();
+    widgets::search_field(ui, sr, &mut q, "Search effects and presets", &t);
+    app.ui.effects_search = q.clone();
+    app.auto.add("effects.search", sr, "Search");
+    let force_scan = contents_menu(app, ui, &ctx, rect, sr, &t);
     let view = app.ui.effects_view.clone();
     let list = Rect::from_min_max(pos2(rect.min.x, sr.max.y + 6.0), rect.max);
     let lp = p.with_clip_rect(list);
diff --git a/crates/ui-egui/src/panels/expr_editor.rs b/crates/ui-egui/src/panels/expr_editor.rs
index f342d5636..157288b94 100644
--- a/crates/ui-egui/src/panels/expr_editor.rs
+++ b/crates/ui-egui/src/panels/expr_editor.rs
@@ -331,8 +331,16 @@ fn layout(text: &str, font: FontId, base: Color32, sx: &Syntax, p: &Scripting, b
     job
 }
 
+/// The scripting preferences, text colour and row count an [`editor`] is drawn with.
+pub struct EditorOpts<'a> {
+    pub prefs: &'a Scripting,
+    pub color: Color32,
+    pub rows: usize,
+}
+
 /// The editor: returns the response; `buf` is edited in place.
-pub fn editor(ui: &mut egui::Ui, id: egui::Id, buf: &mut String, rect: Rect, p: &Scripting, color: Color32, rows: usize, t: &Tokens) -> egui::Response {
+pub fn editor(ui: &mut egui::Ui, id: egui::Id, buf: &mut String, rect: Rect, opts: EditorOpts, t: &Tokens) -> egui::Response {
+    let EditorOpts { prefs: p, color, rows } = opts;
     let size = (p.editor_font_size.clamp(8, 40) as f32) * 0.9;
     let font = Tokens::mono(size);
     // Line numbers in a gutter on the left.
diff --git a/crates/ui-egui/src/panels/forms.rs b/crates/ui-egui/src/panels/forms.rs
index 67128d04d..e121d5d02 100644
--- a/crates/ui-egui/src/panels/forms.rs
+++ b/crates/ui-egui/src/panels/forms.rs
@@ -206,6 +206,280 @@ fn value_fields(name: &str, v: &effectcraft_engine::keyframe::Value) -> Option (String, Vec) {
+    let names: Vec<(String, Value)> =
+        s.state.project_selection.iter().enumerate().filter_map(|(i, id)| s.project.item(*id).map(|it| (it.name.clone(), json!(i)))).collect();
+    let names: Vec<(&str, Value)> = names.iter().map(|(n, v)| (n.as_str(), v.clone())).collect();
+    let still = if s.prefs.import.still_footage == "seconds" { s.prefs.import.still_seconds } else { 10.0 };
+    (
+        "New Composition from Selection".into(),
+        vec![
+            Field::choice("single", "Create", &[("Single Composition", json!(true)), ("Multiple Compositions", json!(false))], 0),
+            Field::choice("dimensionsFrom", "Use Dimensions From", &names, 0),
+            Field::num("duration", "Still Duration (seconds)", still),
+            Field::bool("addToRenderQueue", "Add to Render Queue", false),
+            Field::bool("sequence", "Sequence Layers", false),
+            Field::bool("overlap", "Overlap", false),
+            Field::num("overlapDuration", "Duration (seconds)", 1.0),
+            Field::choice(
+                "transition",
+                "Transition",
+                &[("Off", json!("off")), ("Dissolve Front Layer", json!("dissolveFront")), ("Cross Dissolve Front and Back Layers", json!("crossDissolve"))],
+                0,
+            ),
+        ],
+    )
+}
+
+/// The Interpret Footage dialog for the selected footage (or its proxy), if there is one.
+fn interpret_footage_form(s: &effectcraft_engine::Session, proxy: bool) -> Option<(String, Vec)> {
+    let f = s.state.project_selection.first().and_then(|i| s.project.item(*i)).and_then(|it| match (&it.kind, &it.proxy) {
+        (_, Some(px)) if proxy => Some(px.footage.clone()),
+        (effectcraft_engine::project::ItemKind::Footage(f), _) if !proxy => Some(f.clone()),
+        _ => None,
+    });
+    let f = f?;
+    let hex = |c: [f32; 3]| format!("#{:02x}{:02x}{:02x}", (c[0] * 255.0).round() as u8, (c[1] * 255.0).round() as u8, (c[2] * 255.0).round() as u8);
+    let mut fields = vec![
+        // Main Options ▸ Alpha.
+        Field::choice(
+            "alpha",
+            "Alpha",
+            &[
+                ("Interpret Straight - Unmatted", json!("straight")),
+                ("Interpret Premultiplied - Matted With Color", json!("premultiplied")),
+                ("Ignore", json!("ignore")),
+                ("Guess", json!("guess")),
+            ],
+            f.alpha as usize,
+        ),
+        Field::text("matteColor", "Matte color (premultiplied)", &hex(f.premul_color)),
+        Field::bool("invertAlpha", "Invert Alpha", f.invert_alpha),
+        // Main Options ▸ Frame Rate, Fields and Pulldown, Other Options.
+        Field::num("frameRate", "Assume this frame rate", f.frame_rate.as_f64()),
+        Field::choice(
+            "fields",
+            "Separate Fields",
+            &[("Off", json!("off")), ("Upper Field First", json!("upper")), ("Lower Field First", json!("lower"))],
+            f.fields as usize,
+        ),
+        Field::num("pixelAspect", "Pixel Aspect Ratio", f.pixel_aspect),
+        Field::num("loop", "Loop (times)", f.loop_count as f64),
+        // Color.
+        Field::choice(
+            "colorProfile",
+            "Assign Profile",
+            &[
+                ("Embedded / sRGB", json!("auto")),
+                ("sRGB IEC61966-2.1", json!("srgb")),
+                ("HDTV (Rec. 709)", json!("rec709")),
+                ("Rec. 2020", json!("rec2020")),
+                ("Display P3", json!("p3")),
+                ("ACEScg", json!("acescg")),
+                ("ACES2065-1", json!("aces2065")),
+                ("Rec. 2100 PQ", json!("rec2100pq")),
+                ("Rec. 2100 HLG", json!("rec2100hlg")),
+            ],
+            f.color_profile.map_or(0, |c| 1 + effectcraft_engine::project::ColorSpace::ALL.iter().position(|x| *x == c).unwrap_or(0)),
+        ),
+        Field::bool("preserveRgb", "Preserve RGB", f.preserve_rgb),
+        Field::bool("linearLight", "Interpret As Linear Light", f.linear_light),
+    ];
+    // After Frame Rate: an image sequence's frames (missing ones show colour bars, as in
+    // After Effects), then Start Timecode.
+    let mut timing = Vec::new();
+    if f.kind == effectcraft_engine::project::FootageKind::Sequence {
+        let gaps = match effectcraft_engine::sequence::missing_report(&f.sequence) {
+            _ if f.alphabetical => "files in alphabetical order".into(),
+            Some(gaps) => format!("{gaps} shown as color bars"),
+            None => "no missing frames".into(),
+        };
+        timing.push(Field::note("sequenceInfo", &format!("Image sequence: {} files, {} frames; {gaps}.", f.sequence.len(), f.sequence_frames())));
+    }
+    if f.kind != effectcraft_engine::project::FootageKind::Still {
+        timing.push(Field::choice(
+            "overrideStart",
+            "Start Timecode",
+            &[("Use Source File Timecode", json!(false)), ("Override Start", json!(true))],
+            usize::from(f.start_timecode.is_some()),
+        ));
+        timing.push(Field::text("startTimecode", "Override Start", &f.timecode(0)));
+    }
+    let at = fields.iter().position(|x| x.key == "frameRate").map_or(fields.len(), |i| i + 1);
+    fields.splice(at..at, timing);
+    Some((if proxy { "Interpret Footage: Proxy".into() } else { "Interpret Footage".into() }, fields))
+}
+
+/// The Project Settings dialog's title and fields.
+fn project_settings_form(s: &effectcraft_engine::Session) -> (String, Vec) {
+    let st = &s.project.settings;
+    // The catalogue row is `Mercury GPU Acceleration ({})`, so the value goes through
+    // tr_args: wrapping the assembled string in tr() would look up the backend's name.
+    let gpu_label = match &s.accel {
+        Some(a) => tr_args("Mercury GPU Acceleration ({})", &[&a.name()]),
+        None => tr("Mercury GPU Acceleration (no GPU: software)").to_string(),
+    };
+    let depth = match st.bit_depth.label() {
+        l if l.starts_with("16") => 1,
+        l if l.starts_with("32") => 2,
+        _ => 0,
+    };
+    (
+        "Project Settings".into(),
+        vec![
+            Field::choice(
+                "bitDepth",
+                "Depth",
+                &[("8 bits per channel", json!(8)), ("16 bits per channel", json!(16)), ("32 bits per channel (float)", json!(32))],
+                depth,
+            ),
+            Field::choice(
+                "timeDisplay",
+                "Time display style",
+                &[
+                    ("Timecode", json!("timecode")),
+                    ("Frames", json!("frames")),
+                    ("Feet + Frames (35mm)", json!("feet35")),
+                    ("Feet + Frames (16mm)", json!("feet16")),
+                ],
+                {
+                    use effectcraft_engine::project::TimeDisplayStyle as T;
+                    match st.time_display {
+                        T::Timecode => 0,
+                        T::Frames => 1,
+                        T::Feet35 => 2,
+                        T::Feet16 => 3,
+                    }
+                },
+            ),
+            // Time Display Style ▸ Frames ▸ Frame Count (also numbers Feet + Frames).
+            Field::choice("frameStart", "Frame count", &[("Start at 0", json!(0)), ("Start at 1", json!(1))], usize::from(st.frame_start == 1)),
+            Field::choice(
+                "colorEngine",
+                "Color engine",
+                &[("Adobe-style built-in", json!("adobe")), ("OCIO (built-in ACES config)", json!("ocio"))],
+                usize::from(st.color_engine == effectcraft_engine::project::ColorEngine::Ocio),
+            ),
+            Field::choice(
+                "workingSpace",
+                "Working space",
+                &[
+                    ("None", json!("none")),
+                    ("sRGB IEC61966-2.1", json!("srgb")),
+                    ("HDTV (Rec. 709)", json!("rec709")),
+                    ("Rec. 2020", json!("rec2020")),
+                    ("Display P3", json!("p3")),
+                    ("ACEScg", json!("acescg")),
+                    ("ACES2065-1", json!("aces2065")),
+                ],
+                st.working_space.map_or(0, |c| 1 + effectcraft_engine::project::ColorSpace::WORKING.iter().position(|x| *x == c).unwrap_or(0)),
+            ),
+            // With Working Space None (After Effects' Working Gamma, 2.2 or 2.4).
+            Field::choice("workingGamma", "Working gamma", &[("2.2", json!(2.2)), ("2.4", json!(2.4))], usize::from(st.working_gamma_24())),
+            Field::bool("linearize", "Linearize working space", st.linearize),
+            Field::bool("blendLinear", "Blend colors using 1.0 gamma", st.blend_linear),
+            Field::choice(
+                "hdr",
+                "HDR on SDR displays and outputs",
+                &[("Clip", json!("clip")), ("Compand", json!("compand")), ("Tone map", json!("toneMap"))],
+                st.hdr as usize,
+            ),
+            Field::choice(
+                "outputSpace",
+                "Output color space",
+                &[
+                    ("sRGB IEC61966-2.1 (default)", json!("default")),
+                    ("HDTV (Rec. 709)", json!("rec709")),
+                    ("Rec. 2020", json!("rec2020")),
+                    ("Display P3", json!("p3")),
+                    ("Rec. 2100 PQ (HDR)", json!("rec2100pq")),
+                    ("Rec. 2100 HLG (HDR)", json!("rec2100hlg")),
+                ],
+                {
+                    use effectcraft_engine::project::ColorSpace as C;
+                    match st.output_space {
+                        Some(C::Rec709) => 1,
+                        Some(C::Rec2020) => 2,
+                        Some(C::DisplayP3) => 3,
+                        Some(C::Rec2100Pq) => 4,
+                        Some(C::Rec2100Hlg) => 5,
+                        _ => 0,
+                    }
+                },
+            ),
+            // Video Rendering and Effects ▸ Use.
+            Field::choice(
+                "renderer",
+                "Video rendering and effects",
+                &[(gpu_label.as_str(), json!("gpu")), ("Mercury Software Only", json!("software"))],
+                usize::from(!st.gpu_acceleration),
+            ),
+        ],
+    )
+}
+
+/// The Export Adobe Premiere Pro Project dialog's title and fields.
+fn export_timeline_form(s: &effectcraft_engine::Session) -> (String, Vec) {
+    let name = s.active_comp_id().and_then(|c| s.project.item(c)).map(|i| i.name.clone()).unwrap_or_else(|| "Composition".into());
+    let dir = s.path.as_deref().and_then(|p| std::path::Path::new(p).parent()).map(|d| d.to_path_buf()).unwrap_or_default();
+    let path = dir.join(format!("{name}.xml")).to_string_lossy().to_string();
+    (
+        "Export Adobe Premiere Pro Project".into(),
+        vec![
+            Field::note("note", "Writes Final Cut Pro XML (.xml), which Premiere Pro opens with File ▸ Import. Native .prproj files are not written."),
+            Field::save_path("path", "File", &path),
+            Field::choice(
+                "format",
+                "Format",
+                &[
+                    ("Final Cut Pro XML (.xml) – Premiere Pro", json!("xml")),
+                    ("FCPXML (.fcpxml)", json!("fcpxml")),
+                    ("OpenTimelineIO (.otio)", json!("otio")),
+                    ("CMX 3600 EDL (.edl, V1 only)", json!("edl")),
+                    ("AAF (.aaf)", json!("aaf")),
+                    ("OMF (.omf, audio)", json!("omf")),
+                ],
+                0,
+            ),
+            Field::choice(
+                "prerender",
+                "Pre-render (ProRes 4444)",
+                &[
+                    ("Layers Premiere can't show (text, shapes, effects…)", json!("unsupported")),
+                    ("All layers", json!("all")),
+                    ("None (leave them out)", json!("none")),
+                ],
+                0,
+            ),
+            Field::choice("precomps", "Precomps", &[("Nested sequences", json!("nest")), ("Pre-render", json!("prerender"))], 0),
+        ],
+    )
+}
+
+/// The Layer ▸ Transform value dialog for the property named by `p` (default Position).
+fn transform_form(p: &Value, layer: Option<&effectcraft_engine::project::Layer>, lt: effectcraft_engine::time::Tick) -> (String, Vec) {
+    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));
+    let v = cur.map(|c| c.as_vec3()).unwrap_or([0.0; 3]);
+    let three = layer.is_some_and(|l| l.is_3d());
+    let title = match prop {
+        "anchor" => "Anchor Point",
+        "position" => "Position",
+        "scale" => "Scale",
+        "orientation" => "Orientation",
+        "rotation" => "Rotation",
+        _ => "Opacity",
+    };
+    let fields = match prop {
+        "rotation" | "opacity" => vec![Field::num("value", if prop == "rotation" { "Degrees" } else { "Opacity (%)" }, v[0])],
+        "orientation" => vec![Field::num("value[0]", "X", v[0]), Field::num("value[1]", "Y", v[1]), Field::num("value[2]", "Z", v[2])],
+        _ if three => vec![Field::num("value[0]", "X", v[0]), Field::num("value[1]", "Y", v[1]), Field::num("value[2]", "Z", v[2])],
+        _ => vec![Field::num("value[0]", "X", v[0]), Field::num("value[1]", "Y", v[1])],
+    };
+    (title.into(), fields)
+}
+
 /// If `id` is a dialog command invoked without its parameters, open its form and return true.
 pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool {
     let s = &app.session;
@@ -281,27 +555,7 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool {
             let Some(fields) = value_fields(&pr.name, &pr.value_at(l.layer_time(t))) else { return false };
             (pr.name.clone(), fields)
         }
-        "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));
-            let v = cur.map(|c| c.as_vec3()).unwrap_or([0.0; 3]);
-            let three = layer.is_some_and(|l| l.is_3d());
-            let title = match prop {
-                "anchor" => "Anchor Point",
-                "position" => "Position",
-                "scale" => "Scale",
-                "orientation" => "Orientation",
-                "rotation" => "Rotation",
-                _ => "Opacity",
-            };
-            let fields = match prop {
-                "rotation" | "opacity" => vec![Field::num("value", if prop == "rotation" { "Degrees" } else { "Opacity (%)" }, v[0])],
-                "orientation" => vec![Field::num("value[0]", "X", v[0]), Field::num("value[1]", "Y", v[1]), Field::num("value[2]", "Z", v[2])],
-                _ if three => vec![Field::num("value[0]", "X", v[0]), Field::num("value[1]", "Y", v[1]), Field::num("value[2]", "Z", v[2])],
-                _ => vec![Field::num("value[0]", "X", v[0]), Field::num("value[1]", "Y", v[1])],
-            };
-            (title.into(), fields)
-        }
+        "layer.setTransform" if !has(p, &["value"]) => transform_form(p, layer, lt),
         "path.freeTransform" if !has(p, &["scale", "rotation", "offset"]) => (
             "Free Transform Points".into(),
             vec![
@@ -377,32 +631,7 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool {
         "file.newCompFromSelection"
             if s.state.project_selection.len() > 1 && !has(p, &["single", "dimensionsFrom", "duration", "sequence", "addToRenderQueue"]) =>
         {
-            let names: Vec<(String, Value)> =
-                s.state.project_selection.iter().enumerate().filter_map(|(i, id)| s.project.item(*id).map(|it| (it.name.clone(), json!(i)))).collect();
-            let names: Vec<(&str, Value)> = names.iter().map(|(n, v)| (n.as_str(), v.clone())).collect();
-            let still = if s.prefs.import.still_footage == "seconds" { s.prefs.import.still_seconds } else { 10.0 };
-            (
-                "New Composition from Selection".into(),
-                vec![
-                    Field::choice("single", "Create", &[("Single Composition", json!(true)), ("Multiple Compositions", json!(false))], 0),
-                    Field::choice("dimensionsFrom", "Use Dimensions From", &names, 0),
-                    Field::num("duration", "Still Duration (seconds)", still),
-                    Field::bool("addToRenderQueue", "Add to Render Queue", false),
-                    Field::bool("sequence", "Sequence Layers", false),
-                    Field::bool("overlap", "Overlap", false),
-                    Field::num("overlapDuration", "Duration (seconds)", 1.0),
-                    Field::choice(
-                        "transition",
-                        "Transition",
-                        &[
-                            ("Off", json!("off")),
-                            ("Dissolve Front Layer", json!("dissolveFront")),
-                            ("Cross Dissolve Front and Back Layers", json!("crossDissolve")),
-                        ],
-                        0,
-                    ),
-                ],
-            )
+            new_comp_from_selection_form(s)
         }
         "layer.sequence" if !has(p, &["overlap"]) => (
             "Sequence Layers".into(),
@@ -425,188 +654,10 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool {
             if !has(p, &["frameRate", "alpha", "loop", "pixelAspect", "colorProfile", "fields", "invertAlpha", "matteColor", "linearLight", "startFrame"]) =>
         {
             let proxy = id == "file.interpretProxy";
-            let f = s.state.project_selection.first().and_then(|i| s.project.item(*i)).and_then(|it| match (&it.kind, &it.proxy) {
-                (_, Some(px)) if proxy => Some(px.footage.clone()),
-                (effectcraft_engine::project::ItemKind::Footage(f), _) if !proxy => Some(f.clone()),
-                _ => None,
-            });
-            let Some(f) = f else { return false };
-            let hex = |c: [f32; 3]| format!("#{:02x}{:02x}{:02x}", (c[0] * 255.0).round() as u8, (c[1] * 255.0).round() as u8, (c[2] * 255.0).round() as u8);
-            let mut fields = vec![
-                // Main Options ▸ Alpha.
-                Field::choice(
-                    "alpha",
-                    "Alpha",
-                    &[
-                        ("Interpret Straight - Unmatted", json!("straight")),
-                        ("Interpret Premultiplied - Matted With Color", json!("premultiplied")),
-                        ("Ignore", json!("ignore")),
-                        ("Guess", json!("guess")),
-                    ],
-                    f.alpha as usize,
-                ),
-                Field::text("matteColor", "Matte color (premultiplied)", &hex(f.premul_color)),
-                Field::bool("invertAlpha", "Invert Alpha", f.invert_alpha),
-                // Main Options ▸ Frame Rate, Fields and Pulldown, Other Options.
-                Field::num("frameRate", "Assume this frame rate", f.frame_rate.as_f64()),
-                Field::choice(
-                    "fields",
-                    "Separate Fields",
-                    &[("Off", json!("off")), ("Upper Field First", json!("upper")), ("Lower Field First", json!("lower"))],
-                    f.fields as usize,
-                ),
-                Field::num("pixelAspect", "Pixel Aspect Ratio", f.pixel_aspect),
-                Field::num("loop", "Loop (times)", f.loop_count as f64),
-                // Color.
-                Field::choice(
-                    "colorProfile",
-                    "Assign Profile",
-                    &[
-                        ("Embedded / sRGB", json!("auto")),
-                        ("sRGB IEC61966-2.1", json!("srgb")),
-                        ("HDTV (Rec. 709)", json!("rec709")),
-                        ("Rec. 2020", json!("rec2020")),
-                        ("Display P3", json!("p3")),
-                        ("ACEScg", json!("acescg")),
-                        ("ACES2065-1", json!("aces2065")),
-                        ("Rec. 2100 PQ", json!("rec2100pq")),
-                        ("Rec. 2100 HLG", json!("rec2100hlg")),
-                    ],
-                    f.color_profile.map_or(0, |c| 1 + effectcraft_engine::project::ColorSpace::ALL.iter().position(|x| *x == c).unwrap_or(0)),
-                ),
-                Field::bool("preserveRgb", "Preserve RGB", f.preserve_rgb),
-                Field::bool("linearLight", "Interpret As Linear Light", f.linear_light),
-            ];
-            // After Frame Rate: an image sequence's frames (missing ones show colour bars, as in
-            // After Effects), then Start Timecode.
-            let mut timing = Vec::new();
-            if f.kind == effectcraft_engine::project::FootageKind::Sequence {
-                let gaps = match effectcraft_engine::sequence::missing_report(&f.sequence) {
-                    _ if f.alphabetical => "files in alphabetical order".into(),
-                    Some(gaps) => format!("{gaps} shown as color bars"),
-                    None => "no missing frames".into(),
-                };
-                timing.push(Field::note("sequenceInfo", &format!("Image sequence: {} files, {} frames; {gaps}.", f.sequence.len(), f.sequence_frames())));
-            }
-            if f.kind != effectcraft_engine::project::FootageKind::Still {
-                timing.push(Field::choice(
-                    "overrideStart",
-                    "Start Timecode",
-                    &[("Use Source File Timecode", json!(false)), ("Override Start", json!(true))],
-                    usize::from(f.start_timecode.is_some()),
-                ));
-                timing.push(Field::text("startTimecode", "Override Start", &f.timecode(0)));
-            }
-            let at = fields.iter().position(|x| x.key == "frameRate").map_or(fields.len(), |i| i + 1);
-            fields.splice(at..at, timing);
-            (if proxy { "Interpret Footage: Proxy".into() } else { "Interpret Footage".into() }, fields)
-        }
-        "file.projectSettings" if p.as_object().is_none_or(|m| m.is_empty()) => {
-            let st = &s.project.settings;
-            // The catalogue row is `Mercury GPU Acceleration ({})`, so the value goes through
-            // tr_args: wrapping the assembled string in tr() would look up the backend's name.
-            let gpu_label = match &s.accel {
-                Some(a) => tr_args("Mercury GPU Acceleration ({})", &[&a.name()]),
-                None => tr("Mercury GPU Acceleration (no GPU: software)").to_string(),
-            };
-            let depth = match st.bit_depth.label() {
-                l if l.starts_with("16") => 1,
-                l if l.starts_with("32") => 2,
-                _ => 0,
-            };
-            (
-                "Project Settings".into(),
-                vec![
-                    Field::choice(
-                        "bitDepth",
-                        "Depth",
-                        &[("8 bits per channel", json!(8)), ("16 bits per channel", json!(16)), ("32 bits per channel (float)", json!(32))],
-                        depth,
-                    ),
-                    Field::choice(
-                        "timeDisplay",
-                        "Time display style",
-                        &[
-                            ("Timecode", json!("timecode")),
-                            ("Frames", json!("frames")),
-                            ("Feet + Frames (35mm)", json!("feet35")),
-                            ("Feet + Frames (16mm)", json!("feet16")),
-                        ],
-                        {
-                            use effectcraft_engine::project::TimeDisplayStyle as T;
-                            match st.time_display {
-                                T::Timecode => 0,
-                                T::Frames => 1,
-                                T::Feet35 => 2,
-                                T::Feet16 => 3,
-                            }
-                        },
-                    ),
-                    // Time Display Style ▸ Frames ▸ Frame Count (also numbers Feet + Frames).
-                    Field::choice("frameStart", "Frame count", &[("Start at 0", json!(0)), ("Start at 1", json!(1))], usize::from(st.frame_start == 1)),
-                    Field::choice(
-                        "colorEngine",
-                        "Color engine",
-                        &[("Adobe-style built-in", json!("adobe")), ("OCIO (built-in ACES config)", json!("ocio"))],
-                        usize::from(st.color_engine == effectcraft_engine::project::ColorEngine::Ocio),
-                    ),
-                    Field::choice(
-                        "workingSpace",
-                        "Working space",
-                        &[
-                            ("None", json!("none")),
-                            ("sRGB IEC61966-2.1", json!("srgb")),
-                            ("HDTV (Rec. 709)", json!("rec709")),
-                            ("Rec. 2020", json!("rec2020")),
-                            ("Display P3", json!("p3")),
-                            ("ACEScg", json!("acescg")),
-                            ("ACES2065-1", json!("aces2065")),
-                        ],
-                        st.working_space.map_or(0, |c| 1 + effectcraft_engine::project::ColorSpace::WORKING.iter().position(|x| *x == c).unwrap_or(0)),
-                    ),
-                    // With Working Space None (After Effects' Working Gamma, 2.2 or 2.4).
-                    Field::choice("workingGamma", "Working gamma", &[("2.2", json!(2.2)), ("2.4", json!(2.4))], usize::from(st.working_gamma_24())),
-                    Field::bool("linearize", "Linearize working space", st.linearize),
-                    Field::bool("blendLinear", "Blend colors using 1.0 gamma", st.blend_linear),
-                    Field::choice(
-                        "hdr",
-                        "HDR on SDR displays and outputs",
-                        &[("Clip", json!("clip")), ("Compand", json!("compand")), ("Tone map", json!("toneMap"))],
-                        st.hdr as usize,
-                    ),
-                    Field::choice(
-                        "outputSpace",
-                        "Output color space",
-                        &[
-                            ("sRGB IEC61966-2.1 (default)", json!("default")),
-                            ("HDTV (Rec. 709)", json!("rec709")),
-                            ("Rec. 2020", json!("rec2020")),
-                            ("Display P3", json!("p3")),
-                            ("Rec. 2100 PQ (HDR)", json!("rec2100pq")),
-                            ("Rec. 2100 HLG (HDR)", json!("rec2100hlg")),
-                        ],
-                        {
-                            use effectcraft_engine::project::ColorSpace as C;
-                            match st.output_space {
-                                Some(C::Rec709) => 1,
-                                Some(C::Rec2020) => 2,
-                                Some(C::DisplayP3) => 3,
-                                Some(C::Rec2100Pq) => 4,
-                                Some(C::Rec2100Hlg) => 5,
-                                _ => 0,
-                            }
-                        },
-                    ),
-                    // Video Rendering and Effects ▸ Use.
-                    Field::choice(
-                        "renderer",
-                        "Video rendering and effects",
-                        &[(gpu_label.as_str(), json!("gpu")), ("Mercury Software Only", json!("software"))],
-                        usize::from(!st.gpu_acceleration),
-                    ),
-                ],
-            )
+            let Some(form) = interpret_footage_form(s, proxy) else { return false };
+            form
         }
+        "file.projectSettings" if p.as_object().is_none_or(|m| m.is_empty()) => project_settings_form(s),
         "layer.autoTrace" if !has(p, &["channel", "timeSpan", "threshold"]) => (
             "Auto-trace".into(),
             vec![
@@ -662,42 +713,7 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool {
             )
         }
         // File ▸ Export ▸ Adobe Premiere Pro Project…: Final Cut Pro XML (or FCPXML / OTIO / EDL).
-        "file.exportTimeline" if !has(p, &["path"]) => {
-            let name = s.active_comp_id().and_then(|c| s.project.item(c)).map(|i| i.name.clone()).unwrap_or_else(|| "Composition".into());
-            let dir = s.path.as_deref().and_then(|p| std::path::Path::new(p).parent()).map(|d| d.to_path_buf()).unwrap_or_default();
-            let path = dir.join(format!("{name}.xml")).to_string_lossy().to_string();
-            (
-                "Export Adobe Premiere Pro Project".into(),
-                vec![
-                    Field::note("note", "Writes Final Cut Pro XML (.xml), which Premiere Pro opens with File ▸ Import. Native .prproj files are not written."),
-                    Field::save_path("path", "File", &path),
-                    Field::choice(
-                        "format",
-                        "Format",
-                        &[
-                            ("Final Cut Pro XML (.xml) – Premiere Pro", json!("xml")),
-                            ("FCPXML (.fcpxml)", json!("fcpxml")),
-                            ("OpenTimelineIO (.otio)", json!("otio")),
-                            ("CMX 3600 EDL (.edl, V1 only)", json!("edl")),
-                            ("AAF (.aaf)", json!("aaf")),
-                            ("OMF (.omf, audio)", json!("omf")),
-                        ],
-                        0,
-                    ),
-                    Field::choice(
-                        "prerender",
-                        "Pre-render (ProRes 4444)",
-                        &[
-                            ("Layers Premiere can't show (text, shapes, effects…)", json!("unsupported")),
-                            ("All layers", json!("all")),
-                            ("None (leave them out)", json!("none")),
-                        ],
-                        0,
-                    ),
-                    Field::choice("precomps", "Precomps", &[("Nested sequences", json!("nest")), ("Pre-render", json!("prerender"))], 0),
-                ],
-            )
-        }
+        "file.exportTimeline" if !has(p, &["path"]) => export_timeline_form(s),
         "file.importPlaceholder" | "file.replaceWithPlaceholder" if p.as_object().is_none_or(|m| m.is_empty()) => (
             "New Placeholder".into(),
             vec![
diff --git a/crates/ui-egui/src/panels/fx_editors.rs b/crates/ui-egui/src/panels/fx_editors.rs
index b7b09dad4..4ed96797a 100644
--- a/crates/ui-egui/src/panels/fx_editors.rs
+++ b/crates/ui-egui/src/panels/fx_editors.rs
@@ -66,19 +66,9 @@ fn register_points(app: &mut EffectcraftApp, prefix: &str, gr: Rect, pts: &[[f32
 }
 
 /// Small coloured tabs (`names[i]`, swatch `cols[i]`); returns the clicked one.
-fn tabs(
-    app: &mut EffectcraftApp,
-    ui: &mut egui::Ui,
-    p: &egui::Painter,
-    x0: f32,
-    cy: f32,
-    cur: usize,
-    names: &[&str],
-    cols: &[Color32],
-    id: &str,
-) -> Option {
+fn tabs(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, at: Pos2, cur: usize, (names, cols): (&[&str], &[Color32]), id: &str) -> Option {
     let t = app.tokens;
-    let mut x = x0;
+    let (mut x, cy) = (at.x, at.y);
     let mut out = None;
     for (i, name) in names.iter().enumerate() {
         let w = if cols.is_empty() { 16.0 + name.chars().count() as f32 * 6.2 } else { 20.0 };
@@ -188,7 +178,7 @@ fn rgb_curves(
     let cy = r.min.y + 15.0;
     let cols = [Color32::from_gray(0xe8), Color32::from_rgb(0xf0, 0x50, 0x50), Color32::from_rgb(0x50, 0xd0, 0x60), Color32::from_rgb(0x50, 0x80, 0xff)];
     let names: Vec<&str> = RGB_CURVES.iter().map(|c| c.1).collect();
-    if let Some(n) = tabs(app, ui, p, x0, cy, ch, &names, &cols, &format!("{prefix}.channel")) {
+    if let Some(n) = tabs(app, ui, p, pos2(x0, cy), ch, (&names, &cols), &format!("{prefix}.channel")) {
         app.ui.fx_curve_channel.insert(g.uid, n);
     }
     let id = RGB_CURVES[ch].0;
@@ -299,7 +289,7 @@ fn huesat_curves(
     let x0 = r.min.x + 38.0;
     let cy = r.min.y + 15.0;
     let names: Vec<&str> = HUESAT.iter().map(|c| c.1).collect();
-    if let Some(n) = tabs(app, ui, p, x0, cy, k, &names, &[], &format!("{prefix}.curve")) {
+    if let Some(n) = tabs(app, ui, p, pos2(x0, cy), k, (&names, &[]), &format!("{prefix}.curve")) {
         app.ui.fx_curve_channel.insert(g.uid, n);
     }
     let (id, name, axis, periodic) = HUESAT[k];
@@ -724,7 +714,7 @@ fn glow_map(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, laye
     }
     let ch = app.ui.fx_curve_channel.get(&euid).copied().unwrap_or(0).min(2);
     let cols = [Color32::from_rgb(0xf0, 0x50, 0x50), Color32::from_rgb(0x50, 0xd0, 0x60), Color32::from_rgb(0x50, 0x80, 0xff)];
-    if let Some(n) = tabs(app, ui, p, x0, r.min.y + 15.0, ch, &["Red", "Green", "Blue"], &cols, &format!("{prefix}.channel")) {
+    if let Some(n) = tabs(app, ui, p, pos2(x0, r.min.y + 15.0), ch, (&["Red", "Green", "Blue"], &cols), &format!("{prefix}.channel")) {
         app.ui.fx_curve_channel.insert(euid, n);
     }
     if reset_link(app, ui, p, Rect::from_min_size(pos2(x0 + size - 44.0, r.min.y + 6.0), vec2(44.0, 18.0)), &format!("{prefix}.reset")) {
diff --git a/crates/ui-egui/src/panels/fx_widgets.rs b/crates/ui-egui/src/panels/fx_widgets.rs
index e917827cc..b23000d27 100644
--- a/crates/ui-egui/src/panels/fx_widgets.rs
+++ b/crates/ui-egui/src/panels/fx_widgets.rs
@@ -59,7 +59,8 @@ pub fn angle_field(ui: &mut egui::Ui, at: Pos2, id: egui::Id, v: f64, decimals:
     let (rev, deg) = split_angle(v);
     let (rr, new_rev, _) = widgets::hot_int_at(ui, at, id.with("rev"), rev, 0.1, "x", t);
     let sign = ui.painter().text(pos2(rr.max.x - 2.0, rr.center().y), Align2::LEFT_CENTER, if deg < 0.0 { "-" } else { "+" }, Tokens::ui(12.0), t.hot_text);
-    let (dr, new_deg, _) = widgets::hot_number_at(ui, pos2(sign.max.x - 2.0, at.y), id, deg.abs(), 0.5, (-1e9, 1e9), decimals, "°", t);
+    let (dr, new_deg, _) =
+        widgets::hot_number_at(ui, pos2(sign.max.x - 2.0, at.y), id, deg.abs(), &widgets::HotSpec { speed: 0.5, range: (-1e9, 1e9), decimals, suffix: "°" }, t);
     let dragging = new_deg.is_some() && ui.ctx().is_being_dragged(id);
     let out = match (new_rev, new_deg) {
         (Some(r), _) => Some(r as f64 * 360.0 + deg),
diff --git a/crates/ui-egui/src/panels/graph.rs b/crates/ui-egui/src/panels/graph.rs
index 66ba31470..24df22616 100644
--- a/crates/ui-egui/src/panels/graph.rs
+++ b/crates/ui-egui/src/panels/graph.rs
@@ -240,17 +240,16 @@ impl ButtonBar {
 /// at `y`: draws it, registers it for automation and advances the bar. Once a button does not fit
 /// (or a previous one overflowed), the button goes to the "»" menu instead and only the id the
 /// menu picked reports a click. Returns whether this button was chosen.
-fn text_button(
-    app: &mut EffectcraftApp,
-    ui: &mut egui::Ui,
-    p: &egui::Painter,
-    bar: &mut ButtonBar,
-    y: f32,
-    label: &str,
+/// One bar button for [`text_button`]: label, lit state, automation id and tooltip.
+struct BarBtn<'a> {
+    label: &'a str,
     on: bool,
-    id: &str,
-    tip: &str,
-) -> bool {
+    id: &'a str,
+    tip: &'a str,
+}
+
+fn text_button(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, bar: &mut ButtonBar, y: f32, btn: BarBtn) -> bool {
+    let BarBtn { label, on, id, tip } = btn;
     let t = app.tokens;
     let w = p.layout_no_wrap(label.to_string(), Tokens::ui(11.0), t.text).size().x + 14.0;
     // Once one button overflows, the rest follow it into the menu (they keep their order).
@@ -277,35 +276,44 @@ fn text_button(
     resp.clicked()
 }
 
-/// Draw the Graph Editor into `area` (the right side of the Timeline) and queue edits.
-pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, comp: &Comp, ectx: &EvalCtx, tm: TMap, area: Rect, actions: &mut Actions) {
-    let t = app.tokens;
-    let ctx = ui.ctx().clone();
-    let footer_h = 28.0;
-    let plot = Rect::from_min_max(area.min + vec2(0.0, 6.0), pos2(area.max.x, area.max.y - footer_h));
-    let bar = Rect::from_min_max(pos2(area.min.x, area.max.y - footer_h), area.max);
-    // Background first so keys and handles (added later) sit on top for hit-testing.
-    let bg = ui.interact(plot, egui::Id::unique("graph-bg"), Sense::click_and_drag());
-    p.rect_filled(area, 0.0, Color32::from_rgb(0x1d, 0x1d, 0x1d));
-    app.auto.add("timeline.graph", plot, &app.ui.timeline.graph_mode.clone());
-    let speed = speed_graph(app, comp);
-    let cs = curves(app, comp, ectx, tm, plot, speed);
-    // Just opened, or the graph type changed (value and speed have different units): frame the
-    // graphs (Fit Selection to View, else Fit All Graphs to View).
-    let was_speed_id = egui::Id::unique("graph-was-speed");
-    let type_changed = ctx.data(|d| d.get_temp::(was_speed_id)).is_some_and(|was| was != speed);
-    ctx.data_mut(|d| d.insert_temp(was_speed_id, speed));
-    if ctx.data_mut(|d| d.remove_temp::(fit_pending_id())).unwrap_or(false) || type_changed {
-        if cs.iter().any(|c| c.keys.iter().any(|k| k.sel)) {
-            fit_selection(app, &cs, tm, plot, speed);
-        } else {
-            fit_all(app);
+/// What the Graph Editor draws: the composition, its evaluation context and the time mapping.
+#[derive(Clone, Copy)]
+pub(crate) struct GraphView<'a> {
+    pub comp: &'a Comp,
+    pub ectx: &'a EvalCtx<'a>,
+    pub tm: TMap,
+}
+
+/// Show Reference Graph: the other graph type, faint, scaled to the plot.
+fn reference_graph(app: &EffectcraftApp, view: GraphView, plot: Rect, speed: bool, pc: &egui::Painter) {
+    let GraphView { comp, ectx, tm } = view;
+    let other = curves(app, comp, ectx, tm, plot, !speed);
+    let (mut rlo, mut rhi) = (f64::INFINITY, f64::NEG_INFINITY);
+    for c in &other {
+        for (_, v) in &c.pts {
+            rlo = rlo.min(*v);
+            rhi = rhi.max(*v);
         }
     }
+    if rlo.is_finite() {
+        let span = (rhi - rlo).max(1e-9);
+        let lines: Vec<(Color32, Vec)> = other
+            .iter()
+            .map(|c| {
+                (
+                    c.color,
+                    c.pts.iter().map(|(ct, v)| pos2(tm.x(*ct), plot.max.y - ((v - rlo) / span) as f32 * plot.height() * 0.9 - plot.height() * 0.05)).collect(),
+                )
+            })
+            .collect();
+        super::graph_tools::reference_graph(pc, plot, &lines);
+    }
+}
 
-    // Vertical range: auto-zoom to the curves + handles, or the stored manual range.
+/// The value range that fits every curve point and key handle (with a margin).
+fn auto_range(cs: &[Curve], speed: bool) -> (f64, f64) {
     let (mut lo, mut hi) = (f64::INFINITY, f64::NEG_INFINITY);
-    for c in &cs {
+    for c in cs {
         for (_, v) in &c.pts {
             lo = lo.min(*v);
             hi = hi.max(*v);
@@ -327,7 +335,78 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte
         hi = lo + 1.0;
     }
     let pad = (hi - lo) * 0.08;
-    let auto = (lo - pad, hi + pad);
+    (lo - pad, hi + pad)
+}
+
+/// Background: a click deselects keys, a drag box-selects the keys inside it.
+fn box_select(ui: &egui::Ui, pc: &egui::Painter, bg: &egui::Response, key_screens: &[(serde_json::Value, Pos2)], accent: Color32, actions: &mut Actions) {
+    let ctx = ui.ctx().clone();
+    if bg.clicked() {
+        actions.push(("keys.select".into(), json!({"keys": []})));
+    }
+    if bg.dragged()
+        && let (Some(o), Some(cur)) = (ctx.input(|i| i.pointer.press_origin()), bg.interact_pointer_pos())
+    {
+        let br = Rect::from_two_pos(o, cur);
+        pc.rect_filled(br, 0.0, accent.gamma_multiply(0.12));
+        pc.rect_stroke(br, 0.0, Stroke::new(1.0, accent), StrokeKind::Middle);
+        ctx.data_mut(|d| d.insert_temp(egui::Id::unique("graph-box"), br));
+    }
+    if bg.drag_stopped()
+        && let Some(br) = ctx.data(|d| d.get_temp::(egui::Id::unique("graph-box")))
+    {
+        ctx.data_mut(|d| d.remove::(egui::Id::unique("graph-box")));
+        let keys: Vec = key_screens.iter().filter(|(_, sp)| br.contains(*sp)).map(|(k, _)| k.clone()).collect();
+        actions.push(("keys.select".into(), json!({"keys": keys, "add": ui.input(|i| i.modifiers.shift)})));
+    }
+}
+
+/// Wheel over the graph with Ctrl zooms the value axis `(lo, hi)` (turns auto-zoom off).
+fn wheel_zoom(app: &mut EffectcraftApp, ui: &egui::Ui, plot: Rect, (lo, hi): (f64, f64)) {
+    if ui.rect_contains_pointer(plot) {
+        let (dy, zoom) = ui.input(|i| (i.smooth_scroll_delta.y, i.modifiers.ctrl || i.modifiers.command));
+        if zoom && dy.abs() > 0.0 {
+            let k = (-dy as f64 / 200.0).exp();
+            let mid = (lo + hi) / 2.0;
+            app.ui.timeline.graph_auto_zoom = false;
+            app.ui.timeline.graph_range = Some((mid - (mid - lo) * k, mid + (hi - mid) * k));
+        }
+    }
+}
+
+/// Just opened, or the graph type changed (value and speed have different units): frame the
+/// graphs (Fit Selection to View, else Fit All Graphs to View).
+fn fit_on_open(app: &mut EffectcraftApp, ctx: &egui::Context, cs: &[Curve], tm: TMap, plot: Rect, speed: bool) {
+    let was_speed_id = egui::Id::unique("graph-was-speed");
+    let type_changed = ctx.data(|d| d.get_temp::(was_speed_id)).is_some_and(|was| was != speed);
+    ctx.data_mut(|d| d.insert_temp(was_speed_id, speed));
+    if ctx.data_mut(|d| d.remove_temp::(fit_pending_id())).unwrap_or(false) || type_changed {
+        if cs.iter().any(|c| c.keys.iter().any(|k| k.sel)) {
+            fit_selection(app, cs, tm, plot, speed);
+        } else {
+            fit_all(app);
+        }
+    }
+}
+
+/// Draw the Graph Editor into `area` (the right side of the Timeline) and queue edits.
+pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, view: GraphView, area: Rect, actions: &mut Actions) {
+    let GraphView { comp, ectx, tm } = view;
+    let t = app.tokens;
+    let ctx = ui.ctx().clone();
+    let footer_h = 28.0;
+    let plot = Rect::from_min_max(area.min + vec2(0.0, 6.0), pos2(area.max.x, area.max.y - footer_h));
+    let bar = Rect::from_min_max(pos2(area.min.x, area.max.y - footer_h), area.max);
+    // Background first so keys and handles (added later) sit on top for hit-testing.
+    let bg = ui.interact(plot, egui::Id::unique("graph-bg"), Sense::click_and_drag());
+    p.rect_filled(area, 0.0, Color32::from_rgb(0x1d, 0x1d, 0x1d));
+    app.auto.add("timeline.graph", plot, &app.ui.timeline.graph_mode.clone());
+    let speed = speed_graph(app, comp);
+    let cs = curves(app, comp, ectx, tm, plot, speed);
+    fit_on_open(app, &ctx, &cs, tm, plot, speed);
+
+    // Vertical range: auto-zoom to the curves + handles, or the stored manual range.
+    let auto = auto_range(&cs, speed);
     // While a key or handle is being dragged the scale stays put (no feedback loop).
     let drag_id = egui::Id::unique("graph-dragging");
     let dragging = ctx.data(|d| d.get_temp::(drag_id)).unwrap_or(false) && ctx.input(|i| i.pointer.any_down());
@@ -358,32 +437,8 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte
         );
     }
 
-    // Show Reference Graph: the other graph type, faint, scaled to the plot.
     if app.ui.timeline.graph_reference && !cs.is_empty() {
-        let other = curves(app, comp, ectx, tm, plot, !speed);
-        let (mut rlo, mut rhi) = (f64::INFINITY, f64::NEG_INFINITY);
-        for c in &other {
-            for (_, v) in &c.pts {
-                rlo = rlo.min(*v);
-                rhi = rhi.max(*v);
-            }
-        }
-        if rlo.is_finite() {
-            let span = (rhi - rlo).max(1e-9);
-            let lines: Vec<(Color32, Vec)> = other
-                .iter()
-                .map(|c| {
-                    (
-                        c.color,
-                        c.pts
-                            .iter()
-                            .map(|(ct, v)| pos2(tm.x(*ct), plot.max.y - ((v - rlo) / span) as f32 * plot.height() * 0.9 - plot.height() * 0.05))
-                            .collect(),
-                    )
-                })
-                .collect();
-            super::graph_tools::reference_graph(&pc, plot, &lines);
-        }
+        reference_graph(app, view, plot, speed, &pc);
     }
     // Curves.
     for c in &cs {
@@ -523,37 +578,19 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte
 
     super::graph_tools::transform_box(app, ui, &pc, &sel_keys, tm, &ymap, &vmap, speed, true, actions);
 
-    // Background: click deselects keys, drag box-selects.
-    if bg.clicked() {
-        actions.push(("keys.select".into(), json!({"keys": []})));
-    }
-    if bg.dragged()
-        && let (Some(o), Some(cur)) = (ctx.input(|i| i.pointer.press_origin()), bg.interact_pointer_pos())
-    {
-        let br = Rect::from_two_pos(o, cur);
-        pc.rect_filled(br, 0.0, t.accent.gamma_multiply(0.12));
-        pc.rect_stroke(br, 0.0, Stroke::new(1.0, t.accent), StrokeKind::Middle);
-        ctx.data_mut(|d| d.insert_temp(egui::Id::unique("graph-box"), br));
-    }
-    if bg.drag_stopped()
-        && let Some(br) = ctx.data(|d| d.get_temp::(egui::Id::unique("graph-box")))
-    {
-        ctx.data_mut(|d| d.remove::(egui::Id::unique("graph-box")));
-        let keys: Vec = key_screens.iter().filter(|(_, sp)| br.contains(*sp)).map(|(k, _)| k.clone()).collect();
-        actions.push(("keys.select".into(), json!({"keys": keys, "add": ui.input(|i| i.modifiers.shift)})));
-    }
-    // Wheel over the graph with Alt/Ctrl zooms the value axis (turns auto-zoom off).
-    if ui.rect_contains_pointer(plot) {
-        let (dy, zoom) = ui.input(|i| (i.smooth_scroll_delta.y, i.modifiers.ctrl || i.modifiers.command));
-        if zoom && dy.abs() > 0.0 {
-            let k = (-dy as f64 / 200.0).exp();
-            let mid = (lo + hi) / 2.0;
-            app.ui.timeline.graph_auto_zoom = false;
-            app.ui.timeline.graph_range = Some((mid - (mid - lo) * k, mid + (hi - mid) * k));
-        }
+    box_select(ui, &pc, &bg, &key_screens, t.accent, actions);
+    wheel_zoom(app, ui, plot, (lo, hi));
+    if button_bar(app, ui, p, comp, bar, speed, actions) {
+        fit_selection(app, &cs, tm, plot, speed);
     }
+}
 
-    // Button bar.
+/// The footer button bar: graph type, display toggles, fit, snap and keyframe interpolation.
+/// Returns whether Fit Selection was pressed.
+fn button_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, comp: &Comp, bar: Rect, speed: bool, actions: &mut Actions) -> bool {
+    let t = app.tokens;
+    let ctx = ui.ctx().clone();
+    let mut fit_sel = false;
     p.rect_filled(bar, 0.0, t.panel_bg);
     p.line_segment([bar.left_top(), bar.right_top()], Stroke::new(1.0, t.separator));
     let y = bar.center().y;
@@ -566,48 +603,71 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte
         p,
         &mut buttons,
         y,
-        "Auto",
-        auto_type,
-        "autoSelectGraphType",
-        "Auto-Select Graph Type (speed graph for spatial properties, else value graph)",
+        BarBtn {
+            label: "Auto",
+            on: auto_type,
+            id: "autoSelectGraphType",
+            tip: "Auto-Select Graph Type (speed graph for spatial properties, else value graph)",
+        },
     ) {
         app.ui.timeline.graph_mode = if auto_type { if speed { "speed" } else { "value" } } else { "auto" }.into();
     }
     // Choosing a graph type turns Auto-Select off, as in After Effects.
-    if text_button(app, ui, p, &mut buttons, y, "Value", !speed, "valueGraph", "Edit Value Graph") {
+    if text_button(app, ui, p, &mut buttons, y, BarBtn { label: "Value", on: !speed, id: "valueGraph", tip: "Edit Value Graph" }) {
         app.ui.timeline.graph_mode = "value".into();
     }
-    if text_button(app, ui, p, &mut buttons, y, "Speed", speed, "speedGraph", "Edit Speed Graph") {
+    if text_button(app, ui, p, &mut buttons, y, BarBtn { label: "Speed", on: speed, id: "speedGraph", tip: "Edit Speed Graph" }) {
         app.ui.timeline.graph_mode = "speed".into();
     }
     buttons.x += 6.0;
-    if text_button(app, ui, p, &mut buttons, y, "Selected", show_sel, "showSelected", "Show Selected Properties (else all animated properties)") {
+    if text_button(
+        app,
+        ui,
+        p,
+        &mut buttons,
+        y,
+        BarBtn { label: "Selected", on: show_sel, id: "showSelected", tip: "Show Selected Properties (else all animated properties)" },
+    ) {
         app.ui.timeline.graph_show_selected = !show_sel;
     }
-    if text_button(app, ui, p, &mut buttons, y, "Auto-zoom", auto_on, "autoZoom", "Auto-zoom graph height") {
+    if text_button(app, ui, p, &mut buttons, y, BarBtn { label: "Auto-zoom", on: auto_on, id: "autoZoom", tip: "Auto-zoom graph height" }) {
         app.ui.timeline.graph_auto_zoom = !auto_on;
     }
-    if text_button(app, ui, p, &mut buttons, y, "Fit", false, "fit", "Fit all graphs to view") {
+    if text_button(app, ui, p, &mut buttons, y, BarBtn { label: "Fit", on: false, id: "fit", tip: "Fit all graphs to view" }) {
         fit_all(app);
     }
-    if text_button(app, ui, p, &mut buttons, y, "Fit Selection", false, "fitSelection", "Fit selection to view") {
-        fit_selection(app, &cs, tm, plot, speed);
+    if text_button(app, ui, p, &mut buttons, y, BarBtn { label: "Fit Selection", on: false, id: "fitSelection", tip: "Fit selection to view" }) {
+        fit_sel = true;
     }
     buttons.x += 6.0;
     let (snap, reference, tbox) = (app.ui.timeline.graph_snap, app.ui.timeline.graph_reference, app.ui.timeline.graph_transform_box);
-    if text_button(app, ui, p, &mut buttons, y, "Snap", snap, "snap", "Snap (key drags snap to the current time and other keys)") {
+    if text_button(app, ui, p, &mut buttons, y, BarBtn { label: "Snap", on: snap, id: "snap", tip: "Snap (key drags snap to the current time and other keys)" })
+    {
         app.ui.timeline.graph_snap = !snap;
     }
-    if text_button(app, ui, p, &mut buttons, y, "Reference", reference, "reference", "Show Reference Graph") {
+    if text_button(app, ui, p, &mut buttons, y, BarBtn { label: "Reference", on: reference, id: "reference", tip: "Show Reference Graph" }) {
         app.ui.timeline.graph_reference = !reference;
     }
-    if text_button(app, ui, p, &mut buttons, y, "Transform Box", tbox, "transformBox", "Show Transform Box when multiple keys are selected") {
+    if text_button(
+        app,
+        ui,
+        p,
+        &mut buttons,
+        y,
+        BarBtn { label: "Transform Box", on: tbox, id: "transformBox", tip: "Show Transform Box when multiple keys are selected" },
+    ) {
         app.ui.timeline.graph_transform_box = !tbox;
     }
     buttons.x += 6.0;
     let pos_sel = app.session.state.selected_props.iter().find_map(|(l, u)| super::timeline::is_position(comp.layer(*l)?, *u).then_some(l.0));
-    if text_button(app, ui, p, &mut buttons, y, "Separate Dimensions", false, "separateDimensions", "Separate Dimensions (Position)")
-        && let Some(l) = pos_sel.or(app.session.state.selected_layers.first().map(|l| l.0))
+    if text_button(
+        app,
+        ui,
+        p,
+        &mut buttons,
+        y,
+        BarBtn { label: "Separate Dimensions", on: false, id: "separateDimensions", tip: "Separate Dimensions (Position)" },
+    ) && let Some(l) = pos_sel.or(app.session.state.selected_layers.first().map(|l| l.0))
     {
         actions.push(("prop.separateDimensions".into(), json!({"layer": l})));
     }
@@ -621,11 +681,12 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte
         ("Ease Out", "keys.easyEaseOut", json!({}), "Easy Ease Out (Ctrl+Shift+F9)"),
     ] {
         let id = cmd.trim_start_matches("keys.").to_string() + label.split_whitespace().next().unwrap_or("");
-        if text_button(app, ui, p, &mut buttons, y, label, false, &id, tip) {
+        if text_button(app, ui, p, &mut buttons, y, BarBtn { label, on: false, id: &id, tip }) {
             actions.push((cmd.into(), params));
         }
     }
     buttons.more(app, ui, p, bar);
+    fit_sel
 }
 
 /// Fit All Graphs to View: the whole comp in time, auto-zoomed values.
diff --git a/crates/ui-egui/src/panels/key_dialogs.rs b/crates/ui-egui/src/panels/key_dialogs.rs
index ab26d700f..22e31c468 100644
--- a/crates/ui-egui/src/panels/key_dialogs.rs
+++ b/crates/ui-egui/src/panels/key_dialogs.rs
@@ -125,8 +125,16 @@ fn buttons(ui: &mut egui::Ui, app: &mut EffectcraftApp, t: &Tokens, prefix: &str
     (ok, cancel)
 }
 
-fn drag(ui: &mut egui::Ui, app: &mut EffectcraftApp, id: &str, v: &mut f64, speed: f64, range: std::ops::RangeInclusive, suffix: &str, enabled: bool) {
-    let r = ui.add_enabled(enabled, crate::widgets::drag_value(egui::DragValue::new(v).speed(speed).range(range).max_decimals(2).suffix(suffix)));
+/// Drag speed, clamp range and suffix of a [`drag`] field.
+struct DragSpec<'a> {
+    speed: f64,
+    range: std::ops::RangeInclusive,
+    suffix: &'a str,
+}
+
+fn drag(ui: &mut egui::Ui, app: &mut EffectcraftApp, id: &str, v: &mut f64, spec: DragSpec, enabled: bool) {
+    let r =
+        ui.add_enabled(enabled, crate::widgets::drag_value(egui::DragValue::new(v).speed(spec.speed).range(spec.range).max_decimals(2).suffix(spec.suffix)));
     app.auto.add(id, r.rect, id);
 }
 
@@ -147,13 +155,13 @@ pub fn velocity(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
                     ui.horizontal(|ui| {
                         ui.label(tr("Speed:"));
                         let v = if out { &mut d.out_speed[dim] } else { &mut d.in_speed[dim] };
-                        drag(ui, app, &format!("dialog.velocity.{side}Speed.{dim}"), v, 1.0, -1e7..=1e7, "", enabled);
+                        drag(ui, app, &format!("dialog.velocity.{side}Speed.{dim}"), v, DragSpec { speed: 1.0, range: -1e7..=1e7, suffix: "" }, enabled);
                         ui.label(egui::RichText::new(&d.units).color(t.text_dim));
                     });
                     ui.horizontal(|ui| {
                         ui.label(tr("Influence:"));
                         let v = if out { &mut d.out_inf[dim] } else { &mut d.in_inf[dim] };
-                        drag(ui, app, &format!("dialog.velocity.{side}Influence.{dim}"), v, 0.2, 0.1..=100.0, " %", enabled);
+                        drag(ui, app, &format!("dialog.velocity.{side}Influence.{dim}"), v, DragSpec { speed: 0.2, range: 0.1..=100.0, suffix: " %" }, enabled);
                     });
                     ui.end_row();
                 }
@@ -254,14 +262,14 @@ pub fn time_stretch(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
             ui.end_row();
             ui.label(tr("Stretch Factor:"));
             let before = d.percent;
-            drag(ui, app, "dialog.timeStretch.percent", &mut d.percent, 0.5, -10000.0..=10000.0, " %", true);
+            drag(ui, app, "dialog.timeStretch.percent", &mut d.percent, DragSpec { speed: 0.5, range: -10000.0..=10000.0, suffix: " %" }, true);
             if (before - d.percent).abs() > 1e-9 && d.orig_percent.abs() > 1e-9 {
                 d.duration = d.orig_duration * d.percent.abs() / d.orig_percent.abs();
             }
             ui.end_row();
             ui.label(tr("New Duration:"));
             let before = d.duration;
-            drag(ui, app, "dialog.timeStretch.duration", &mut d.duration, 0.05, 0.01..=100000.0, " s", true);
+            drag(ui, app, "dialog.timeStretch.duration", &mut d.duration, DragSpec { speed: 0.05, range: 0.01..=100000.0, suffix: " s" }, true);
             if (before - d.duration).abs() > 1e-9 && d.orig_duration > 1e-9 {
                 d.percent = d.orig_percent.signum() * d.orig_percent.abs() * d.duration / d.orig_duration;
             }
diff --git a/crates/ui-egui/src/panels/layer_panel.rs b/crates/ui-egui/src/panels/layer_panel.rs
index f6157da47..5fb4d703c 100644
--- a/crates/ui-egui/src/panels/layer_panel.rs
+++ b/crates/ui-egui/src/panels/layer_panel.rs
@@ -87,6 +87,141 @@ fn default_view(layer: &Layer, tool: Tool) -> usize {
     }
 }
 
+/// The layer's rendered view `view` at `scale`, cached in egui's temp data per revision, layer,
+/// time, view and scale (the layer cache makes repeats cheap).
+fn render_tex(
+    app: &EffectcraftApp,
+    ctx: &egui::Context,
+    cid: effectcraft_engine::project::ItemId,
+    comp: &effectcraft_engine::project::Comp,
+    layer: &Layer,
+    view: usize,
+    scale: f64,
+) -> Option {
+    let time = app.session.time();
+    let key = (app.session.revision, layer.id.0, time.0, view, (scale * 1000.0) as u32);
+    let cached: Option = ctx.data(|d| d.get_temp(tex_id()));
+    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(), 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
+            })
+        }
+    }
+}
+
+/// What [`paint_stroke`] needs about the brush and the layer <-> screen mapping.
+#[derive(Clone, Copy)]
+struct StrokeCtx<'a> {
+    layer: u64,
+    kind: &'a str,
+    zoom: f64,
+    did: egui::Id,
+    diameter: f64,
+    color: [f64; 4],
+    to_layer: &'a dyn Fn(Pos2) -> [f64; 2],
+    to_screen: &'a dyn Fn([f64; 2]) -> Pos2,
+}
+
+/// Records a paint stroke's layer-space points while dragging, previews it, and runs
+/// `paint.stroke` on release (or on a click).
+fn paint_stroke(app: &mut EffectcraftApp, ui: &egui::Ui, painter: &egui::Painter, resp: &egui::Response, sc: &StrokeCtx) {
+    let StrokeCtx { layer, kind, zoom, did, diameter, color, to_layer, to_screen } = *sc;
+    let ctx = ui.ctx().clone();
+    let now = ui.input(|i| i.time);
+    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, points: vec![to_layer(pos)], started: now }));
+    }
+    if let Some(mut d) = ctx.data(|d| d.get_temp::(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((color[0] * 255.0) as u8, (color[1] * 255.0) as u8, (color[2] * 255.0) as u8, 200)
+        };
+        let width = ((diameter * zoom) as f32).max(1.0);
+        let pts: Vec = 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::(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, "kind": kind, "points": [to_layer(pos)], "duration": 0.0}));
+        if let Err(e) = r {
+            app.ui.status = e.to_string();
+        }
+    }
+}
+
+/// The bottom bar: the magnification menu and the View menu over `names` (Masks, then the
+/// effects). Returns where the View dropdown ends.
+fn bottom_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, bar: Rect, names: &[String], view: usize, (zoom, fit): (f64, f32)) -> f32 {
+    let t = app.tokens;
+    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 vr = Rect::from_min_size(pos2(vx + 36.0, bar.center().y - 10.0), vec2(150.0, 20.0));
+    let pop = egui::Id::unique("layer-view-pop");
+    if widgets::dropdown(ui, vr, &names[view.min(names.len() - 1)], &t, egui::Id::unique("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);
+    }
+    vr.max.x
+}
+
 /// The Layer panel: one layer in its own space with its masks and effects up to the View menu's
 /// choice, rendered synchronously at a zoom/pan the panel keeps (the last Fit magnification is
 /// published for the View menu). Wheel or pinch zooms about the pointer, the Hand tool or
@@ -130,44 +265,17 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
     };
     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::unique("layer-view-pop");
-    if widgets::dropdown(ui, vr, &names[view.min(names.len() - 1)], &t, egui::Id::unique("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 vr_right = bottom_bar(app, ui, &p, bar, &names, view, (zoom, fit));
+
     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);
+        super::roto_tool::bar_buttons(app, ui, &p, bar, vr_right + 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);
     }
@@ -190,27 +298,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
     } else {
         0.25
     };
-    let key = (app.session.revision, layer.id.0, time.0, view, (scale * 1000.0) as u32);
-    let cached: Option = 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
-            })
-        }
-    };
+    let tex = render_tex(app, &ctx, cid, &comp, &layer, view, scale);
     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);
@@ -271,7 +359,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
     }
     // 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);
+        super::roto_tool::interact(app, ui, &painter, area, &layer, zoom, super::roto_tool::LayerXform { to_screen: &to_screen, to_layer: &to_layer });
         return;
     }
     // Interaction: paint tools.
@@ -314,7 +402,6 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         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
@@ -328,49 +415,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
     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::(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 = 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::(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();
-            }
-        }
+        let stroke = StrokeCtx { layer: layer.id.0, kind, zoom, did, diameter: o.diameter, color: o.color, to_layer: &to_layer, to_screen: &to_screen };
+        paint_stroke(app, ui, &painter, &resp, &stroke);
     }
 }
diff --git a/crates/ui-egui/src/panels/media_panels.rs b/crates/ui-egui/src/panels/media_panels.rs
index e07b25766..ffe2cc1f7 100644
--- a/crates/ui-egui/src/panels/media_panels.rs
+++ b/crates/ui-egui/src/panels/media_panels.rs
@@ -63,6 +63,27 @@ fn thumbnail(app: &EffectcraftApp, ctx: &egui::Context, path: &str, budget: &mut
     thumb
 }
 
+/// The Favorites column: one clickable row per place, the current folder `dir` highlighted.
+fn places_column(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, side: Rect, places: &[(String, String)], dir: &str) {
+    let t = app.tokens;
+    let mut fy = side.min.y + 22.0;
+    for (i, (name, path)) in places.iter().enumerate() {
+        let r = Rect::from_min_size(pos2(side.min.x, fy), vec2(side.width(), 20.0));
+        let resp = ui.interact(r, egui::Id::unique(("mb-fav", i)), Sense::click());
+        if path == dir {
+            p.rect_filled(r, 0.0, t.row_selected);
+        } else if resp.hovered() {
+            p.rect_filled(r, 0.0, t.hover);
+        }
+        p.text(pos2(r.min.x + 14.0, r.center().y), Align2::LEFT_CENTER, name, Tokens::ui(12.0), t.text);
+        app.auto.add(&format!("mediaBrowser.place.{i}"), r, name);
+        if resp.clicked() {
+            kit::exec(app, "mediaBrowser.go", json!({"path": path}));
+        }
+        fy += 20.0;
+    }
+}
+
 /// The Media Browser panel: a toolbar (up, home, favourite, "Media only" filter and the browser's
 /// own actions), the current path, the favourite places and a tile grid of the folder's entries
 /// with thumbnails for images and videos. Double-click opens a folder or imports a file; files
@@ -136,26 +157,11 @@ pub fn media_browser(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
     let side = Rect::from_min_max(pos2(rect.min.x, rect.min.y + 54.0), pos2(rect.min.x + 160.0_f32.min(rect.width() * 0.3), rect.max.y));
     p.line_segment([side.right_top(), side.right_bottom()], Stroke::new(1.0, t.separator));
     p.text(pos2(side.min.x + 10.0, side.min.y + 10.0), Align2::LEFT_CENTER, tr("Favorites"), Tokens::semibold(11.5), t.text_dim);
-    let mut fy = side.min.y + 22.0;
     let mut places: Vec<(String, String)> = b.places();
     places.extend(
         st.favorites.iter().map(|f| (std::path::Path::new(f).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| f.clone()), f.clone())),
     );
-    for (i, (name, path)) in places.iter().enumerate() {
-        let r = Rect::from_min_size(pos2(side.min.x, fy), vec2(side.width(), 20.0));
-        let resp = ui.interact(r, egui::Id::unique(("mb-fav", i)), Sense::click());
-        if *path == dir {
-            p.rect_filled(r, 0.0, t.row_selected);
-        } else if resp.hovered() {
-            p.rect_filled(r, 0.0, t.hover);
-        }
-        p.text(pos2(r.min.x + 14.0, r.center().y), Align2::LEFT_CENTER, name, Tokens::ui(12.0), t.text);
-        app.auto.add(&format!("mediaBrowser.place.{i}"), r, name);
-        if resp.clicked() {
-            kit::exec(app, "mediaBrowser.go", json!({"path": path}));
-        }
-        fy += 20.0;
-    }
+    places_column(app, ui, &p, side, &places, &dir);
     // Files.
     let list = Rect::from_min_max(pos2(side.max.x + 1.0, side.min.y), rect.max);
     let entries = match entries.as_ref() {
diff --git a/crates/ui-egui/src/panels/misc.rs b/crates/ui-egui/src/panels/misc.rs
index bd0e716b4..dfa9a42e0 100644
--- a/crates/ui-egui/src/panels/misc.rs
+++ b/crates/ui-egui/src/panels/misc.rs
@@ -117,7 +117,14 @@ pub fn preview(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         for (k, (key, text, v)) in [("preRoll", "Pre-roll", o.pre_roll), ("postRoll", "Post-roll", o.post_roll)].into_iter().enumerate() {
             let lx = xv + k as f32 * 100.0;
             p.text(pos2(lx, yy), Align2::LEFT_CENTER, text, Tokens::ui(11.5), t.text_dim);
-            let (r, nv, _) = widgets::hot_number_at(ui, pos2(lx + 52.0, yy - 9.0), egui::Id::unique(("pv-roll", key)), v, 0.05, (0.0, 600.0), 1, "s", &t);
+            let (r, nv, _) = widgets::hot_number_at(
+                ui,
+                pos2(lx + 52.0, yy - 9.0),
+                egui::Id::unique(("pv-roll", key)),
+                v,
+                &widgets::HotSpec { speed: 0.05, range: (0.0, 600.0), decimals: 1, suffix: "s" },
+                &t,
+            );
             app.auto.add(&format!("preview.{key}"), r, text);
             if let Some(nv) = nv {
                 changes.push(json!({"shortcut": current.id(), key: nv}));
@@ -160,7 +167,14 @@ pub fn preview(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
     }
     let sx = xv + 106.0;
     p.text(pos2(sx, yy), Align2::LEFT_CENTER, tr("Skip"), Tokens::ui(12.0), t.text_dim);
-    let (r, nv, _) = widgets::hot_number_at(ui, pos2(sx + 30.0, yy - 9.0), egui::Id::unique("pv-skip"), o.skip as f64, 0.1, (0.0, 99.0), 0, "", &t);
+    let (r, nv, _) = widgets::hot_number_at(
+        ui,
+        pos2(sx + 30.0, yy - 9.0),
+        egui::Id::unique("pv-skip"),
+        o.skip as f64,
+        &widgets::HotSpec { speed: 0.1, range: (0.0, 99.0), decimals: 0, suffix: "" },
+        &t,
+    );
     app.auto.add("preview.skip", r, "Skip");
     if let Some(nv) = nv {
         changes.push(json!({"shortcut": current.id(), "skip": nv.round() as u32}));
@@ -181,8 +195,14 @@ pub fn preview(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         changes.push(json!({"shortcut": current.id(), "resolution": res_ids[i]}));
     }
     if o.resolution == PreviewResolution::Custom {
-        let (r, nv, _) =
-            widgets::hot_number_at(ui, pos2(xv + 106.0, yy - 9.0), egui::Id::unique("pv-custom-res"), o.custom_resolution as f64, 0.1, (1.0, 40.0), 0, "", &t);
+        let (r, nv, _) = widgets::hot_number_at(
+            ui,
+            pos2(xv + 106.0, yy - 9.0),
+            egui::Id::unique("pv-custom-res"),
+            o.custom_resolution as f64,
+            &widgets::HotSpec { speed: 0.1, range: (1.0, 40.0), decimals: 0, suffix: "" },
+            &t,
+        );
         app.auto.add("preview.customResolution", r, "Custom Resolution");
         if let Some(nv) = nv {
             changes.push(json!({"shortcut": current.id(), "customResolution": nv.round() as u32}));
diff --git a/crates/ui-egui/src/panels/paint_panels.rs b/crates/ui-egui/src/panels/paint_panels.rs
index 43e4d438c..c867eb63a 100644
--- a/crates/ui-egui/src/panels/paint_panels.rs
+++ b/crates/ui-egui/src/panels/paint_panels.rs
@@ -36,7 +36,14 @@ fn number(
     let t = app.tokens;
     p.text(pos2(x, y + 9.0), Align2::LEFT_CENTER, label, Tokens::ui(11.5), if enabled { t.text_dim } else { t.text_faint });
     let lw = p.layout_no_wrap(label.to_string(), Tokens::ui(11.5), t.text).size().x;
-    let (r, nv, _) = widgets::hot_number_at(ui, pos2(x + lw + 6.0, y), egui::Id::unique(("paint-num", key)), v, 0.5, range, 0, suffix, &t);
+    let (r, nv, _) = widgets::hot_number_at(
+        ui,
+        pos2(x + lw + 6.0, y),
+        egui::Id::unique(("paint-num", key)),
+        v,
+        &widgets::HotSpec { speed: 0.5, range, decimals: 0, suffix },
+        &t,
+    );
     app.auto.add(key, r, label);
     nv.filter(|_| enabled)
 }
@@ -83,6 +90,48 @@ fn paint_swap_icon(p: &egui::Painter, rect: Rect, color: Color32) {
     p.arrow(pos2(right, bottom), vec2(left - right, 0.0), stroke);
 }
 
+/// The foreground-over-background swatches (each opens a colour popup) with Swap and Reset.
+fn colour_swatches(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, x0: f32, y: f32, color: [f64; 4], background: [f64; 4]) {
+    let t = app.tokens;
+    let fg = Rect::from_min_size(pos2(x0, y), vec2(26.0, 20.0));
+    let bg = Rect::from_min_size(pos2(x0 + 14.0, y + 10.0), vec2(26.0, 20.0));
+    widgets::swatch(ui, bg, [background[0] as f32, background[1] as f32, background[2] as f32, 1.0], egui::Id::unique("paint-bg"), &t);
+    let bresp = ui.interact(Rect::from_min_max(pos2(fg.max.x, bg.min.y), bg.max), egui::Id::unique("paint-bg-hit"), Sense::click());
+    if bresp.clicked() {
+        widgets::open_popup(ui, egui::Id::unique("paint-bg-pop"));
+    }
+    if widgets::swatch(ui, fg, [color[0] as f32, color[1] as f32, color[2] as f32, 1.0], egui::Id::unique("paint-fg"), &t).clicked() {
+        widgets::open_popup(ui, egui::Id::unique("paint-fg-pop"));
+    }
+    app.auto.add("paint.color", fg, "Foreground color");
+    app.auto.add("paint.background", bg, "Background color");
+    let mut fc = rgb(color);
+    if crate::widgets::color_popup(ui, egui::Id::unique("paint-fg-pop"), fg.left_bottom(), &mut fc) {
+        set(app, json!({"color": [fc[0], fc[1], fc[2], 1.0]}));
+    }
+    let mut bc = rgb(background);
+    if crate::widgets::color_popup(ui, egui::Id::unique("paint-bg-pop"), bg.left_bottom(), &mut bc) {
+        set(app, json!({"background": [bc[0], bc[1], bc[2], 1.0]}));
+    }
+    let swap = Rect::from_min_size(pos2(x0 + 46.0, y), vec2(18.0, 14.0));
+    let sresp = ui.interact(swap, egui::Id::unique("paint-swap"), Sense::click());
+    // Two arrows (foreground over background): drawn, so the icon does not depend on a font
+    // carrying U+21C4 (the bundled fonts do not).
+    paint_swap_icon(p, swap, if sresp.hovered() { t.text } else { t.text_dim });
+    app.auto.add("paint.swapColors", swap, "Swap colors");
+    if sresp.on_hover_text(tr("Swap Paint Colors (X)")).clicked() {
+        set(app, json!({"color": background, "background": color}));
+    }
+    let reset = Rect::from_min_size(pos2(x0 + 46.0, y + 18.0), vec2(18.0, 14.0));
+    let rresp = ui.interact(reset, egui::Id::unique("paint-reset-colors"), Sense::click());
+    p.rect_filled(Rect::from_min_size(reset.min + vec2(4.0, 3.0), vec2(7.0, 6.0)), 0.0, Color32::WHITE);
+    p.rect_stroke(Rect::from_min_size(reset.min + vec2(8.0, 6.0), vec2(7.0, 6.0)), 0.0, Stroke::new(1.0, t.text_dim), StrokeKind::Inside);
+    app.auto.add("paint.resetColors", reset, "Default colors");
+    if rresp.on_hover_text(tr("Set Paint Colors to Black and White (D)")).clicked() {
+        set(app, json!({"color": [1.0, 1.0, 1.0, 1.0], "background": [0.0, 0.0, 0.0, 1.0]}));
+    }
+}
+
 /// The Paint panel: opacity and flow, foreground/background colours with swap and reset, mode,
 /// channels, duration, erase mode and the Clone Stamp options. Every change is sent as a
 /// `paint.options` command. Mode and Channels are disabled for the Eraser; Erase is enabled
@@ -106,43 +155,7 @@ pub fn paint(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
     }
     y += 28.0;
     // Colour swatches (foreground over background) with swap / reset.
-    let fg = Rect::from_min_size(pos2(x0, y), vec2(26.0, 20.0));
-    let bg = Rect::from_min_size(pos2(x0 + 14.0, y + 10.0), vec2(26.0, 20.0));
-    widgets::swatch(ui, bg, [o.background[0] as f32, o.background[1] as f32, o.background[2] as f32, 1.0], egui::Id::unique("paint-bg"), &t);
-    let bresp = ui.interact(Rect::from_min_max(pos2(fg.max.x, bg.min.y), bg.max), egui::Id::unique("paint-bg-hit"), Sense::click());
-    if bresp.clicked() {
-        widgets::open_popup(ui, egui::Id::unique("paint-bg-pop"));
-    }
-    if widgets::swatch(ui, fg, [o.color[0] as f32, o.color[1] as f32, o.color[2] as f32, 1.0], egui::Id::unique("paint-fg"), &t).clicked() {
-        widgets::open_popup(ui, egui::Id::unique("paint-fg-pop"));
-    }
-    app.auto.add("paint.color", fg, "Foreground color");
-    app.auto.add("paint.background", bg, "Background color");
-    let mut fc = rgb(o.color);
-    if crate::widgets::color_popup(ui, egui::Id::unique("paint-fg-pop"), fg.left_bottom(), &mut fc) {
-        set(app, json!({"color": [fc[0], fc[1], fc[2], 1.0]}));
-    }
-    let mut bc = rgb(o.background);
-    if crate::widgets::color_popup(ui, egui::Id::unique("paint-bg-pop"), bg.left_bottom(), &mut bc) {
-        set(app, json!({"background": [bc[0], bc[1], bc[2], 1.0]}));
-    }
-    let swap = Rect::from_min_size(pos2(x0 + 46.0, y), vec2(18.0, 14.0));
-    let sresp = ui.interact(swap, egui::Id::unique("paint-swap"), Sense::click());
-    // Two arrows (foreground over background): drawn, so the icon does not depend on a font
-    // carrying U+21C4 (the bundled fonts do not).
-    paint_swap_icon(&p, swap, if sresp.hovered() { t.text } else { t.text_dim });
-    app.auto.add("paint.swapColors", swap, "Swap colors");
-    if sresp.on_hover_text(tr("Swap Paint Colors (X)")).clicked() {
-        set(app, json!({"color": o.background, "background": o.color}));
-    }
-    let reset = Rect::from_min_size(pos2(x0 + 46.0, y + 18.0), vec2(18.0, 14.0));
-    let rresp = ui.interact(reset, egui::Id::unique("paint-reset-colors"), Sense::click());
-    p.rect_filled(Rect::from_min_size(reset.min + vec2(4.0, 3.0), vec2(7.0, 6.0)), 0.0, Color32::WHITE);
-    p.rect_stroke(Rect::from_min_size(reset.min + vec2(8.0, 6.0), vec2(7.0, 6.0)), 0.0, Stroke::new(1.0, t.text_dim), StrokeKind::Inside);
-    app.auto.add("paint.resetColors", reset, "Default colors");
-    if rresp.on_hover_text(tr("Set Paint Colors to Black and White (D)")).clicked() {
-        set(app, json!({"color": [1.0, 1.0, 1.0, 1.0], "background": [0.0, 0.0, 0.0, 1.0]}));
-    }
+    colour_swatches(app, ui, &p, x0, y, o.color, o.background);
     y += 40.0;
 
     // Mode / Channels / Duration.
@@ -167,10 +180,7 @@ pub fn paint(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
             pos2(x0 + w - 52.0, y + 1.0),
             egui::Id::unique("paint-custom-frames"),
             o.custom_frames as f64,
-            0.2,
-            (1.0, 10000.0),
-            0,
-            " f",
+            &widgets::HotSpec { speed: 0.2, range: (1.0, 10000.0), decimals: 0, suffix: " f" },
             &t,
         );
         app.auto.add("paint.customFrames", r, "Custom duration (frames)");
@@ -239,7 +249,14 @@ pub fn paint(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
     let off = o.clone_offset.unwrap_or([0.0, 0.0]);
     p.text(pos2(x0, y + 9.0), Align2::LEFT_CENTER, tr("Offset:"), Tokens::ui(11.5), t.text_dim);
     for (d, v) in off.iter().enumerate() {
-        let (r, nv, _) = widgets::hot_number_at(ui, pos2(x0 + 56.0 + d as f32 * 64.0, y), egui::Id::unique(("clone-off", d)), *v, 1.0, (-1e5, 1e5), 0, "", &t);
+        let (r, nv, _) = widgets::hot_number_at(
+            ui,
+            pos2(x0 + 56.0 + d as f32 * 64.0, y),
+            egui::Id::unique(("clone-off", d)),
+            *v,
+            &widgets::HotSpec { speed: 1.0, range: (-1e5, 1e5), decimals: 0, suffix: "" },
+            &t,
+        );
         app.auto.add(&format!("paint.cloneOffset.{d}"), r, "Clone offset");
         if let Some(nv) = nv {
             let mut no = off;
@@ -354,7 +371,14 @@ pub fn brushes(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
     // Spacing (checkbox in AE turns spacing on; we always space dabs, so it shows the value).
     p.text(pos2(x0, y + 9.0), Align2::LEFT_CENTER, tr("Spacing:"), Tokens::ui(11.5), t.text_dim);
     let _ = r;
-    let (sr, nv, _) = widgets::hot_number_at(ui, pos2(x0 + 58.0, y), egui::Id::unique("brushes-spacing"), o.spacing, 0.5, (1.0, 1000.0), 0, " %", &t);
+    let (sr, nv, _) = widgets::hot_number_at(
+        ui,
+        pos2(x0 + 58.0, y),
+        egui::Id::unique("brushes-spacing"),
+        o.spacing,
+        &widgets::HotSpec { speed: 0.5, range: (1.0, 1000.0), decimals: 0, suffix: " %" },
+        &t,
+    );
     app.auto.add("brushes.spacing", sr, "Spacing");
     if let Some(nv) = nv {
         set(app, json!({"spacing": nv}));
diff --git a/crates/ui-egui/src/panels/panel_kit.rs b/crates/ui-egui/src/panels/panel_kit.rs
index 99554ec57..501ddf64f 100644
--- a/crates/ui-egui/src/panels/panel_kit.rs
+++ b/crates/ui-egui/src/panels/panel_kit.rs
@@ -47,7 +47,7 @@ pub fn label(ui: &egui::Ui, at: egui::Pos2, text: &str, t: &Tokens) {
 #[allow(clippy::too_many_arguments)]
 pub fn number(app: &mut EffectcraftApp, ui: &mut egui::Ui, at: egui::Pos2, auto: &str, v: f64, speed: f64, range: (f64, f64), dec: usize, suffix: &str) -> f64 {
     let t = app.tokens;
-    let (r, nv, _) = widgets::hot_number_at(ui, at, egui::Id::unique(("kit-n", auto)), v, speed, range, dec, suffix, &t);
+    let (r, nv, _) = widgets::hot_number_at(ui, at, egui::Id::unique(("kit-n", auto)), v, &widgets::HotSpec { speed, range, decimals: dec, suffix }, &t);
     app.auto.add(auto, r, &format!("{v}"));
     nv.unwrap_or(v)
 }
diff --git a/crates/ui-egui/src/panels/project.rs b/crates/ui-egui/src/panels/project.rs
index 0d2769541..1944f333f 100644
--- a/crates/ui-egui/src/panels/project.rs
+++ b/crates/ui-egui/src/panels/project.rs
@@ -381,17 +381,9 @@ pub(crate) fn reveal(app: &mut EffectcraftApp, ctx: &egui::Context, items: &[Ite
     app.show_panel(crate::dock::PanelKind::Project);
 }
 
-/// The Project panel: the selected item's thumbnail and details (with the comps that use it), the
-/// search field, sortable column headers, and the item rows (folders, comps, footage and solids)
-/// with selection, Shift/Cmd ranges, keyboard navigation, inline rename, drag-and-drop move,
-/// marquee selection and context menus. Only the rows in view are drawn. File commands are queued
-/// and run through [`crate::menus::invoke`] at the end, with failures shown in `app.ui.status`.
-pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
+/// The selected item's thumbnail and details (with the comps that use it), or a hint.
+fn details_header(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, ctx: &egui::Context, head: Rect) {
     let t = app.tokens;
-    let p = ui.painter().clone();
-    let ctx = ui.ctx().clone();
-    // Preview header.
-    let head = Rect::from_min_size(rect.min, vec2(rect.width(), 74.0));
     let sel = app.session.state.project_selection.first().copied();
     if let Some(it) = sel.and_then(|i| app.session.project.item(i)).cloned() {
         let thumb = Rect::from_min_size(head.min + vec2(10.0, 10.0), vec2(96.0, 54.0));
@@ -400,12 +392,12 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
             ItemKind::Solid(s) => {
                 p.rect_filled(thumb.shrink(4.0), 2.0, Color32::from_rgb((s.color[0] * 255.0) as u8, (s.color[1] * 255.0) as u8, (s.color[2] * 255.0) as u8));
             }
-            _ => match thumbnail(app, &ctx, &it) {
+            _ => match thumbnail(app, ctx, &it) {
                 Some(tex) => {
                     let [w, h] = tex.size();
                     p.image(tex.id(), fit(thumb, w as f32, h as f32), Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
                 }
-                None => icons::paint(&p, Rect::from_center_size(thumb.center(), vec2(24.0, 24.0)), item_icon(&it), t.text_dim),
+                None => icons::paint(p, Rect::from_center_size(thumb.center(), vec2(24.0, 24.0)), item_icon(&it), t.text_dim),
             },
         }
         app.auto.add("project.thumbnail", thumb, &it.name);
@@ -418,7 +410,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         let count = users.iter().fold(0usize, |n, (_, k)| n.saturating_add(*k));
         let name_y = thumb.min.y + 6.0;
         if count == 0 {
-            let r = widgets::text_fit(&p, pos2(tx, name_y), Align2::LEFT_CENTER, &it.name, Tokens::semibold(12.0), max_w, t.text);
+            let r = widgets::text_fit(p, pos2(tx, name_y), Align2::LEFT_CENTER, &it.name, Tokens::semibold(12.0), max_w, t.text);
             app.auto.add("project.details.name", r, &it.name);
         } else {
             let used = if count == 1 { crate::i18n::tr("used once").to_owned() } else { crate::i18n::tr_args("used {} times", &[&count]) };
@@ -427,17 +419,17 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
             let arrow_w = 14.0;
             // The name gives way first, but keeps at least half the room; the count is cut after it.
             let name_max = (max_w - arrow_w - used_w).max(max_w * 0.5);
-            let r = widgets::text_fit(&p, pos2(tx, name_y), Align2::LEFT_CENTER, &it.name, Tokens::semibold(12.0), name_max, t.text);
+            let r = widgets::text_fit(p, pos2(tx, name_y), Align2::LEFT_CENTER, &it.name, Tokens::semibold(12.0), name_max, t.text);
             app.auto.add("project.details.name", r, &it.name);
             let ar = Rect::from_min_size(pos2(r.max.x + 1.0, name_y - 7.0), vec2(arrow_w, 14.0));
             let tip = crate::i18n::tr("Compositions that use this item");
             let aresp = ui.interact(ar, egui::Id::unique("proj-used-in"), Sense::click()).on_hover_text(tip);
-            icons::paint(&p, Rect::from_center_size(ar.center(), vec2(9.0, 9.0)), Icon::ChevronDown, if aresp.hovered() { t.text } else { t.text_dim });
+            icons::paint(p, Rect::from_center_size(ar.center(), vec2(9.0, 9.0)), Icon::ChevronDown, if aresp.hovered() { t.text } else { t.text_dim });
             app.auto.add("project.details.usedIn", ar, tip);
             // The comma sits right after the arrow (its glyph ends short of the hit area).
             let ux = ar.max.x - 3.0;
             let room = (head.max.x - 10.0 - ux).max(0.0);
-            let ur = widgets::text_fit(&p, pos2(ux, name_y), Align2::LEFT_CENTER, &used, Tokens::ui(11.0), room, t.text_dim);
+            let ur = widgets::text_fit(p, pos2(ux, name_y), Align2::LEFT_CENTER, &used, Tokens::ui(11.0), room, t.text_dim);
             app.auto.add("project.details.used", ur, &used);
             let names: Vec<(u64, String, usize)> =
                 users.iter().map(|(cid, n)| (cid.0, app.session.project.item(*cid).map(|c| c.name.clone()).unwrap_or_default(), *n)).collect();
@@ -454,7 +446,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
                 }
             });
             if let Some(cid) = open
-                && let Err(e) = crate::menus::invoke(app, &ctx, "comp.open", json!({"comp": cid}))
+                && let Err(e) = crate::menus::invoke(app, ctx, "comp.open", json!({"comp": cid}))
             {
                 app.ui.status = e;
             }
@@ -471,23 +463,37 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
             lines.push(f.codec.clone());
         }
         for (i, l) in lines.iter().enumerate() {
-            let r = widgets::text_fit(&p, pos2(tx, thumb.min.y + 22.0 + 13.0 * i as f32), Align2::LEFT_CENTER, l, Tokens::ui(11.0), max_w, t.text_dim);
+            let r = widgets::text_fit(p, pos2(tx, thumb.min.y + 22.0 + 13.0 * i as f32), Align2::LEFT_CENTER, l, Tokens::ui(11.0), max_w, t.text_dim);
             app.auto.add(&format!("project.details.{i}"), r, l);
         }
     } else {
         let hint = "Select an item to see its details";
-        let r = widgets::text_fit(&p, head.center(), Align2::CENTER_CENTER, crate::i18n::tr(hint), Tokens::ui(11.0), head.width() - 20.0, t.text_faint);
+        let r = widgets::text_fit(p, head.center(), Align2::CENTER_CENTER, crate::i18n::tr(hint), Tokens::ui(11.0), head.width() - 20.0, t.text_faint);
         app.auto.add("project.details.hint", r, hint);
     }
-    // Search.
-    let sr = Rect::from_min_size(pos2(rect.min.x + 8.0, head.max.y + 2.0), vec2(rect.width() - 16.0, 22.0));
-    let mut q = app.ui.project_search.clone();
-    widgets::search_field(ui, sr, &mut q, crate::i18n::tr("Search project"), &t);
-    app.ui.project_search = q;
-    app.auto.add("project.search", sr, "Search");
-    // Column header: Name, Label, then the visible optional columns.
-    let hdr = Rect::from_min_size(pos2(rect.min.x, sr.max.y + 6.0), vec2(rect.width(), 20.0));
-    p.line_segment([hdr.left_bottom(), hdr.right_bottom()], Stroke::new(1.0, t.separator));
+}
+
+/// Column positions: Name and Label, then the visible optional columns (scrolled by `hscroll`).
+struct Columns {
+    cols: Vec<(&'static str, &'static str, f32, f32)>,
+    name_w: f32,
+    label_x: f32,
+    opt_x0: f32,
+    /// How far the optional columns overflow the panel.
+    overflow: f32,
+    hscroll: f32,
+    /// Where the last column ends.
+    end_x: f32,
+}
+
+impl Columns {
+    /// `r` cut off under the frozen Name and Label columns.
+    fn opt_clip(&self, r: Rect) -> Rect {
+        Rect::from_min_max(pos2(r.min.x.max(self.opt_x0 - 4.0), r.min.y), r.max)
+    }
+}
+
+fn layout_columns(app: &mut EffectcraftApp, rect: Rect) -> Columns {
     let name_w = (rect.width() * 0.40).clamp(120.0, 260.0);
     let label_x = rect.min.x + name_w + 14.0;
     let mut cols: Vec<(&'static str, &'static str, f32, f32)> = vec![("name", "Name", rect.min.x + 26.0, name_w - 26.0), ("label", "", label_x - 8.0, 18.0)];
@@ -505,11 +511,18 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
             x += w + 4.0;
         }
     }
-    let opt_clip = |r: Rect| Rect::from_min_max(pos2(r.min.x.max(opt_x0 - 4.0), r.min.y), r.max);
+    Columns { cols, name_w, label_x, opt_x0, overflow, hscroll, end_x: x }
+}
+
+/// The column header row: sort labels with the sort arrow, and the Columns menu.
+fn draw_header(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, hdr: Rect, cl: &Columns) {
+    let t = app.tokens;
+    let Columns { cols, label_x, end_x, .. } = cl;
+    let (label_x, end_x) = (*label_x, *end_x);
     let hp = p.with_clip_rect(hdr);
-    for (key, label, x, w) in &cols {
+    for (key, label, x, w) in cols {
         let hr = Rect::from_min_size(pos2(x - 4.0, hdr.min.y), vec2(*w, hdr.height()));
-        let hr = if matches!(*key, "name" | "label") { hr } else { opt_clip(hr) };
+        let hr = if matches!(*key, "name" | "label") { hr } else { cl.opt_clip(hr) };
         if hr.width() <= 0.0 {
             continue;
         }
@@ -553,446 +566,202 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         }
         column_menu(app, &resp);
     }
-    let hresp = ui.interact(Rect::from_min_max(pos2(x, hdr.min.y), hdr.max), egui::Id::unique("proj-hdr-rest"), Sense::CLICK);
+    let hresp = ui.interact(Rect::from_min_max(pos2(end_x, hdr.min.y), hdr.max), egui::Id::unique("proj-hdr-rest"), Sense::CLICK);
     column_menu(app, &hresp);
     app.auto.add("project.columns", hdr, "Columns (right-click to show or hide)");
-    // Rows.
-    let footer_h = 28.0;
-    let list = Rect::from_min_max(pos2(rect.min.x, hdr.max.y), pos2(rect.max.x, rect.max.y - footer_h));
-    let lp = p.with_clip_rect(list);
-    // The list's empty area (under the rows and the scroll bar, which take their own clicks):
-    // a click deselects, a drag draws a selection box, a double-click imports, as in After
-    // Effects (File ▸ Import ▸ File...).
-    // Not a Tab stop: the keyboard reaches the rows, and nothing else here takes a key.
-    let empty = ui.interact(list, egui::Id::unique("proj-empty"), Sense::CLICK | Sense::DRAG);
-    app.auto.add("project.empty", list, "Double-click to import files; drag to select items; right-click for project actions");
-    if overflow > 0.0 && ui.rect_contains_pointer(list) {
-        let (dx, dy, shift) = ui.input(|i| (i.smooth_scroll_delta.x, i.smooth_scroll_delta.y, i.modifiers.shift));
-        let d = if dx.abs() > 0.0 {
-            dx
-        } else if shift {
-            dy
-        } else {
-            0.0
-        };
-        app.ui.project_hscroll = (app.ui.project_hscroll - d).clamp(0.0, overflow);
+}
+
+/// An item row's context menu: Composition Settings, Delete, Rename, Duplicate, labels and the
+/// footage commands.
+fn item_menu(
+    app: &mut EffectcraftApp,
+    ui: &mut egui::Ui,
+    ctx: &egui::Context,
+    it: &effectcraft_engine::project::Item,
+    id: ItemId,
+    selected: bool,
+    actions: &mut Vec<(String, serde_json::Value)>,
+) {
+    let t = app.tokens;
+    let footage = matches!(it.kind, ItemKind::Footage(_));
+    // First on a comp, as in After Effects; for this comp, not the viewer's (#1058).
+    if matches!(it.kind, ItemKind::Comp(_)) && ui.button(crate::i18n::tr("Composition Settings…")).clicked() {
+        actions.push(("comp.settings".into(), json!({"comp": id.0})));
+        ui.close();
     }
-    let moved = keyboard_nav(app, &ctx);
-    let rows = visible_rows(app);
-    // Enter renames the selected item (Project panel focused, not typing).
-    if app.ui.focused == crate::dock::PanelKind::Project
-        && app.dialog.is_none()
-        && keys_reach_rows(app, &ctx)
-        && ctx.data(|d| d.get_temp::(edit_id())).is_none()
-        && ctx.input(|i| i.key_pressed(egui::Key::Enter))
-    {
-        begin_rename(app, &ctx);
+    if ui.button(crate::i18n::tr("Delete")).clicked() {
+        actions.push(("project.delete".into(), json!({"items": targets(app, id, selected)})));
+        ui.close();
     }
-    let editing: Option = ctx.data(|d| d.get_temp(edit_id()));
-    let dragging = egui::DragAndDrop::payload::(&ctx).and_then(|p| match *p {
-        DragPayload::Item(i) => Some(i),
-        _ => None,
+    if matches!(it.kind, ItemKind::Comp(_)) && ui.button(crate::i18n::tr("Open Composition")).clicked() {
+        actions.push(("comp.open".into(), json!({"comp": id.0})));
+        ui.close();
+    }
+    if ui.button(crate::i18n::tr("Rename")).clicked() {
+        ctx.data_mut(|d| d.insert_temp::(edit_id(), (id.0, "name".into(), it.name.clone())));
+        ui.close();
+    }
+    let duplicate_items = targets(app, id, selected);
+    // Like Edit > Duplicate: folders disable the whole operation, including mixed selections.
+    let can_duplicate = !duplicate_items.is_empty() && duplicate_items.iter().all(|id| app.session.project.item(ItemId(*id)).is_some_and(|i| !i.is_folder()));
+    let mut duplicate = egui::Button::new(crate::i18n::tr("Duplicate"));
+    if let Some(shortcut) = app.session.shortcuts().shortcut_of("edit.duplicate", &serde_json::Value::Null) {
+        duplicate = duplicate.shortcut_text(crate::menus::shortcut_text(shortcut));
+    }
+    let response = ui.add_enabled(can_duplicate, duplicate);
+    app.auto.add("project.context.duplicate", response.rect, "Duplicate");
+    if response.clicked() {
+        actions.push(("project.duplicate".into(), json!({"items": duplicate_items})));
+        ui.close();
+    }
+    if it.parent.is_some() && ui.button(crate::i18n::tr("Move to Project Root")).clicked() {
+        actions.push(("project.move".into(), json!({"items": targets(app, id, selected), "folder": null})));
+        ui.close();
+    }
+    if !it.is_folder() && ui.button(crate::i18n::tr("Add to Composition")).clicked() {
+        actions.push(("layer.addItem".into(), json!({"item": id.0})));
+        ui.close();
+    }
+    // File ▸ New Comp from Selection on the selection (the right-clicked row joins it
+    // above): several items ask how in its dialog, as in After Effects (#360).
+    if ui.button(crate::i18n::tr("New Comp from Selection")).clicked() {
+        actions.push(("file.newCompFromSelection".into(), json!({})));
+        ui.close();
+    }
+    // File ▸ Replace Footage ▸ With Precomposition: every layer using the item uses a
+    // new composition holding it instead. The label says how many layers that changes.
+    let uses: usize = app.session.project.item_users(id).iter().map(|(_, n)| n).sum();
+    let label = match uses {
+        0 => crate::i18n::tr("Replace with Precomposition").to_owned(),
+        1 => format!("{} ({})", crate::i18n::tr("Replace with Precomposition"), crate::i18n::tr("used once")),
+        n => format!("{} ({})", crate::i18n::tr("Replace with Precomposition"), crate::i18n::tr_args("used {} times", &[&n])),
+    };
+    if (footage || matches!(it.kind, ItemKind::Solid(_))) && ui.button(label).clicked() {
+        actions.push(("file.replaceWithPrecomp".into(), json!({"item": id.0})));
+        ui.close();
+    }
+    ui.separator();
+    ui.menu_button(crate::i18n::tr("Label"), |ui| {
+        for l in Label::ALL {
+            if widgets::label_entry(ui, l, &app.session.prefs.label_name(l), l == it.label, &t).clicked() {
+                actions.push(("project.setLabel".into(), json!({"items": targets(app, id, selected), "label": label_index(l)})));
+                ui.close();
+            }
+        }
     });
-    let hover_y = ctx.pointer_hover_pos().filter(|p| list.contains(*p)).map(|p| p.y);
-    let mut drop_row: Option = None;
-    let mut actions: Vec<(String, serde_json::Value)> = vec![];
-    // Vertical scrolling: only the rows in view are drawn (and registered), so a project with
-    // thousands of items costs what one screenful does.
-    let content_h = rows.len() as f32 * ROW_H;
-    let max_scroll = (content_h - list.height() + ROW_H).max(0.0);
-    if ui.rect_contains_pointer(list) {
-        let (dy, shift) = ui.input(|i| (i.smooth_scroll_delta.y, i.modifiers.shift));
-        if !shift && dy != 0.0 {
-            app.ui.project_scroll -= dy;
-        }
+    // Footage: the File menu's footage commands (Interpret Footage, Replace Footage…).
+    if footage {
+        let mut chosen = None;
+        crate::menus::submenu_at(app, ui, &["File", "Interpret Footage"], &mut chosen);
+        crate::menus::submenu_at(app, ui, &["File", "Replace Footage"], &mut chosen);
+        crate::menus::entry_for(app, ui, "file.reloadFootage", &mut chosen);
+        crate::menus::entry_for(app, ui, "file.revealInFinder", &mut chosen);
+        actions.extend(chosen);
     }
-    // A selection made elsewhere (a command, an agent, the viewer) scrolls into view.
-    let sel_key = selection_seen_id();
-    let sel_now = app.session.state.project_selection.last().map(|i| i.0);
-    if ctx.data(|d| d.get_temp::>(sel_key)) != Some(sel_now) {
-        ctx.data_mut(|d| d.insert_temp(sel_key, sel_now));
-        if let Some(row) = sel_now.and_then(|s| rows.iter().position(|(id, _)| id.0 == s)) {
-            let top = row as f32 * ROW_H;
-            if top < app.ui.project_scroll {
-                app.ui.project_scroll = top;
-            } else if top + ROW_H > app.ui.project_scroll + list.height() {
-                app.ui.project_scroll = top + ROW_H - list.height();
-            }
-        }
+}
+
+/// The inline rename / comment field over `cell` when it is item `id`'s `field` being edited;
+/// returns whether it took the cell.
+fn cell_edit(
+    app: &mut EffectcraftApp,
+    ui: &mut egui::Ui,
+    actions: &mut Vec<(String, serde_json::Value)>,
+    editing: &Option,
+    id: u64,
+    field: &str,
+    cell: Rect,
+) -> bool {
+    let ctx = ui.ctx().clone();
+    let Some((eid, ef, mut buf)) = editing.clone().filter(|(e, f, _)| *e == id && f == field) else { return false };
+    let mut child = ui.new_child(egui::UiBuilder::new().max_rect(cell));
+    // The field keeps Tab (egui would move the keyboard to the next widget, where Enter no
+    // longer renames, #881): Tab commits like Enter and gives the keyboard back to the panel.
+    let filter = egui::EventFilter { tab: true, horizontal_arrows: true, vertical_arrows: true, ..Default::default() };
+    let resp = child.add(egui::TextEdit::singleline(&mut buf).desired_width(cell.width()).font(Tokens::ui(12.0)).event_filter(filter));
+    app.auto.add(&format!("project.item.{}.{field}Edit", id), cell, field);
+    let tab = resp.has_focus() && ctx.input(|i| i.key_pressed(egui::Key::Tab));
+    if tab {
+        resp.surrender_focus();
     }
-    // The arrow keys' item scrolls into view (Shift+Up extends upward, past the selection's end).
-    if let Some(row) = moved.and_then(|m| rows.iter().position(|(id, _)| *id == m)) {
-        let top = row as f32 * ROW_H;
-        // Not `clamp`: a panel shorter than a row would make its bounds cross. The row's top wins.
-        app.ui.project_scroll = app.ui.project_scroll.max(top + ROW_H - list.height()).min(top);
-    }
-    app.ui.project_scroll = app.ui.project_scroll.clamp(0.0, max_scroll);
-    let scroll = app.ui.project_scroll;
-    let first = ((scroll / ROW_H).floor() as usize).min(rows.len());
-    let mut y = list.min.y - (scroll - first as f32 * ROW_H);
-    let project = app.session.project.clone();
-    for (i, (id, depth)) in rows.iter().enumerate().skip(first) {
-        let Some(it) = project.item(*id) else { continue };
-        let r = Rect::from_min_size(pos2(list.min.x, y), vec2(list.width(), ROW_H));
-        y += ROW_H;
-        if r.min.y > list.max.y {
-            break;
-        }
-        if hover_y.is_some_and(|hy| hy >= r.min.y && hy < r.max.y) {
-            drop_row = Some(*id);
-        }
-        let selected = app.session.state.project_selection.contains(id);
-        lp.rect_filled(
-            r,
-            0.0,
-            // Flat rows like After Effects' Project panel (no stripes).
-            if selected { t.row_selected } else { t.row },
-        );
-        // The row's click / drag area goes first, so the twirl, proxy, label and comment widgets
-        // on top of it take their own clicks.
-        let mut resp = ui.interact(r.intersect(list), row_id(*id), Sense::click_and_drag());
-        app.auto.add(&format!("project.item.{}", id.0), r, &it.name);
-        // For screen readers: a tree item named by the item and its type, with its selection,
-        // depth and (a folder) open state. One Tab stop per row; the panel's keys then apply.
-        let open = it.is_folder().then(|| app.ui.project_open_folders.contains(&id.0) || !app.ui.project_search.is_empty());
-        resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::TreeItem, true, format!("{}, {}", it.name, crate::i18n::tr(it.type_name()))));
-        ctx.accesskit_node_builder(resp.id, |n| {
-            n.set_selected(selected);
-            n.set_level(depth.saturating_add(1));
-            if let Some(open) = open {
-                n.set_expanded(open);
-            }
-        });
-        if resp.has_focus() {
-            ctx.data_mut(|d| d.insert_temp(focused_row_id(), *id));
-        }
-        if resp.gained_focus() {
-            app.ui.focused = crate::dock::PanelKind::Project;
-        }
-        let _ = i;
-        let x0 = r.min.x + 8.0 + 14.0 * *depth as f32;
-        if it.is_folder() {
-            let open = app.ui.project_open_folders.contains(&id.0);
-            let tw = Rect::from_center_size(pos2(x0 + 4.0, r.center().y), vec2(12.0, 12.0));
-            let tresp = widgets::twirl(ui, tw, open, egui::Id::unique(("pf", id.0)), &t);
-            tresp.widget_info(|| {
-                let name = crate::i18n::tr_args(if open { "Close folder {}" } else { "Open folder {}" }, &[&it.name]);
-                egui::WidgetInfo::labeled(egui::Role::Button, true, name)
-            });
-            if tresp.clicked() {
-                // Alt/Cmd-click twirls every folder inside it too.
-                let mut ids = vec![*id];
-                if ui.input(|i| i.modifiers.alt || i.modifiers.command) {
-                    let mut at = 0;
-                    while let Some(f) = ids.get(at).copied() {
-                        ids.extend(app.session.project.children(Some(f)).into_iter().filter(|c| c.is_folder()).map(|c| c.id));
-                        at += 1;
-                    }
-                }
-                for f in ids {
-                    if open {
-                        app.ui.project_open_folders.remove(&f.0);
-                    } else {
-                        app.ui.project_open_folders.insert(f.0);
-                    }
-                }
-            }
-            app.auto.add(&format!("project.item.{}.twirl", id.0), tw, &it.name);
-        }
-        icons::paint(&lp, Rect::from_center_size(pos2(x0 + 18.0, r.center().y), vec2(14.0, 14.0)), item_icon(it), t.text_dim);
-        // Proxy indicator: filled = the proxy is used, hollow = set but off. Click to switch.
-        if let Some(px) = it.proxy.as_ref() {
-            let pr = Rect::from_center_size(pos2(x0 + 4.0, r.center().y), vec2(9.0, 9.0));
-            if px.enabled {
-                lp.rect_filled(pr, 1.0, t.text);
+    if resp.lost_focus() || tab {
+        ctx.data_mut(|d| d.remove::(edit_id()));
+        if !ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
+            let (cmd, params) = if ef == "name" {
+                ("project.rename", json!({"item": eid, "name": buf}))
             } else {
-                lp.rect_stroke(pr, 1.0, Stroke::new(1.0, t.text), egui::StrokeKind::Inside);
-            }
-            let presp = ui.interact(pr.expand(2.0).intersect(list), egui::Id::unique(("pproxy", id.0)), Sense::click()).on_hover_text(format!(
-                "Proxy: {} ({})",
-                px.footage.path,
-                if px.enabled { "in use" } else { "off" }
-            ));
-            app.auto.add(&format!("project.item.{}.proxy", id.0), pr, if px.enabled { "Proxy in use" } else { "Proxy off" });
-            presp.widget_info(|| {
-                let name = crate::i18n::tr_args(if px.enabled { "Proxy of {}: in use" } else { "Proxy of {}: off" }, &[&it.name]);
-                egui::WidgetInfo::labeled(egui::Role::Button, true, name)
-            });
-            if presp.clicked() {
-                actions.push(("file.useProxy".into(), json!({"item": id.0})));
-            }
-        }
-        // Footage that can't be decoded (no decoder for its codec / profile): a warning marker
-        // at the end of the name cell, the reason on hover.
-        let problem = match &it.kind {
-            ItemKind::Footage(f) => app.session.footage.problem(f),
-            _ => None,
-        };
-        let name_clip =
-            Rect::from_min_max(pos2(x0 + 30.0, r.min.y), pos2(r.min.x + name_w + 4.0 - if problem.is_some() { 16.0 } else { 0.0 }, r.max.y)).intersect(list);
-        if let Some(why) = &problem {
-            let m = Rect::from_center_size(pos2(r.min.x + name_w - 8.0, r.center().y), vec2(13.0, 12.0));
-            lp.add(egui::Shape::convex_polygon(vec![m.center_top(), m.right_bottom(), m.left_bottom()], t.warning, Stroke::NONE));
-            lp.text(m.center() + vec2(0.0, 1.5), Align2::CENTER_CENTER, "!", Tokens::semibold(9.0), Color32::BLACK);
-            ui.interact(m.expand(2.0).intersect(list), egui::Id::unique(("pproblem", id.0)), Sense::hover()).on_hover_text(why.as_str());
-            app.auto.add(&format!("project.item.{}.problem", id.0), m, why);
-        }
-        let name_rect = Rect::from_min_max(pos2(x0 + 28.0, r.min.y + 2.0), pos2(r.min.x + name_w + 2.0, r.max.y - 2.0));
-        // Inline edits (rename / comment).
-        let cell_edit = |app: &mut EffectcraftApp, ui: &mut egui::Ui, actions: &mut Vec<(String, serde_json::Value)>, field: &str, cell: Rect| -> bool {
-            let Some((eid, ef, mut buf)) = editing.clone().filter(|(e, f, _)| *e == id.0 && f == field) else { return false };
-            let mut child = ui.new_child(egui::UiBuilder::new().max_rect(cell));
-            // The field keeps Tab (egui would move the keyboard to the next widget, where Enter no
-            // longer renames, #881): Tab commits like Enter and gives the keyboard back to the panel.
-            let filter = egui::EventFilter { tab: true, horizontal_arrows: true, vertical_arrows: true, ..Default::default() };
-            let resp = child.add(egui::TextEdit::singleline(&mut buf).desired_width(cell.width()).font(Tokens::ui(12.0)).event_filter(filter));
-            app.auto.add(&format!("project.item.{}.{field}Edit", id.0), cell, field);
-            let tab = resp.has_focus() && ctx.input(|i| i.key_pressed(egui::Key::Tab));
-            if tab {
-                resp.surrender_focus();
-            }
-            if resp.lost_focus() || tab {
-                ctx.data_mut(|d| d.remove::(edit_id()));
-                if !ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
-                    let (cmd, params) = if ef == "name" {
-                        ("project.rename", json!({"item": eid, "name": buf}))
-                    } else {
-                        ("project.setComment", json!({"items": [eid], "comment": buf}))
-                    };
-                    actions.push((cmd.into(), params));
-                }
-            } else {
-                widgets::keep_focus(&ctx, &resp, &buf);
-                ctx.data_mut(|d| d.insert_temp::(edit_id(), (eid, ef, buf)));
-            }
-            true
-        };
-        if !cell_edit(app, ui, &mut actions, "name", name_rect) {
-            // The selected item's name sits in a light cell with dark text.
-            if selected {
-                let g = lp.layout_no_wrap(it.name.clone(), Tokens::ui(12.0), Color32::BLACK);
-                let cell = Rect::from_min_max(pos2(x0 + 27.0, r.min.y + 1.0), pos2((x0 + 33.0 + g.size().x).min(name_clip.max.x), r.max.y - 1.0));
-                lp.with_clip_rect(name_clip.expand2(vec2(3.0, 0.0))).rect_filled(cell, 0.0, Color32::from_rgb(0xa6, 0xa6, 0xa6));
-            }
-            // Long names end in "…" before the label column instead of being cut mid-glyph.
-            widgets::text_fit(
-                &lp.with_clip_rect(name_clip),
-                pos2(x0 + 30.0, r.center().y),
-                Align2::LEFT_CENTER,
-                &it.name,
-                Tokens::ui(12.0),
-                name_clip.max.x - (x0 + 30.0) - 2.0,
-                if selected { Color32::from_gray(0x16) } else { t.text },
-            );
-        }
-        // Label swatch: click for the label colour menu.
-        let sw = Rect::from_center_size(pos2(label_x, r.center().y), vec2(10.0, 10.0));
-        lp.rect_filled(sw, 2.0, t.label(it.label));
-        let sresp = ui.interact(sw.expand(3.0).intersect(list), egui::Id::unique(("plabel", id.0)), Sense::click());
-        app.auto.add(&format!("project.item.{}.label", id.0), sw, it.label.name());
-        sresp.widget_info(|| {
-            let name = crate::i18n::tr_args("Label of {}: {}", &[&it.name, &app.session.prefs.label_name(it.label)]);
-            egui::WidgetInfo::labeled(egui::Role::Button, true, name)
-        });
-        let pop = egui::Id::unique(("plabel-pop", id.0));
-        if sresp.clicked() || sresp.secondary_clicked() {
-            widgets::open_popup(ui, pop);
-        }
-        if let Some(l) = widgets::label_popup(ui, pop, sw.left_bottom(), it.label, |l| app.session.prefs.label_name(l), &t) {
-            actions.push(("project.setLabel".into(), json!({"items": targets(app, *id, selected), "label": label_index(l)})));
-        }
-        // Optional columns.
-        for (key, _, cx, w) in cols.iter().skip(2) {
-            let cell = opt_clip(Rect::from_min_size(pos2(*cx, r.min.y), vec2(*w, r.height())).intersect(list));
-            if cell.width() <= 0.0 {
-                continue;
-            }
-            let cp = lp.with_clip_rect(cell);
-            let txt = match *key {
-                "type" => it.type_name().to_string(),
-                "size" => file_size(it).map(fmt_size).unwrap_or_default(),
-                "duration" => it.duration().map(|d| fmt_dur(d.seconds(), it.frame_rate().map(|r| r.as_f64()).unwrap_or(30.0))).unwrap_or_default(),
-                "fps" => it.frame_rate().map(|f| format!("{:.2}", f.as_f64())).unwrap_or_default(),
-                "path" => file_path(it).to_string(),
-                "comment" => {
-                    let cr = Rect::from_min_size(pos2(*cx - 2.0, r.min.y + 2.0), vec2(*w, r.height() - 4.0));
-                    if cell_edit(app, ui, &mut actions, "comment", cr) {
-                        continue;
-                    }
-                    let cresp = ui.interact(cell, egui::Id::unique(("pcomment", id.0)), Sense::CLICK);
-                    app.auto.add(&format!("project.item.{}.comment", id.0), cell, &it.comment);
-                    cresp.widget_info(|| {
-                        egui::WidgetInfo::labeled(egui::Role::Unknown, true, crate::i18n::tr_args("Comment of {}: {}", &[&it.name, &it.comment]))
-                    });
-                    if cresp.double_clicked() {
-                        ctx.data_mut(|d| d.insert_temp::(edit_id(), (id.0, "comment".into(), it.comment.clone())));
-                    } else {
-                        // Other clicks select the row and open its context menu.
-                        resp = resp.union(cresp);
-                    }
-                    it.comment.clone()
-                }
-                _ => String::new(),
+                ("project.setComment", json!({"items": [eid], "comment": buf}))
             };
-            let align = if *key == "fps" { Align2::RIGHT_CENTER } else { Align2::LEFT_CENTER };
-            let tx = if *key == "fps" { cx + w - 6.0 } else { *cx };
-            cp.text(pos2(tx, r.center().y), align, txt, Tokens::ui(11.5), t.text_dim);
+            actions.push((cmd.into(), params));
         }
-        if dragging.is_some_and(|d| d != id.0) && drop_row == Some(*id) && it.is_folder() {
-            lp.rect_stroke(r.shrink(1.0), 2.0, Stroke::new(1.5, t.accent), egui::StrokeKind::Inside);
-        }
-        // The row with keyboard focus (Tab, or the arrow keys after it) shows a focus ring.
-        if ctx.memory(|m| m.has_focus(row_id(*id))) {
-            lp.rect_stroke(r.shrink(0.5), 0.0, Stroke::new(1.0, t.accent), egui::StrokeKind::Inside);
-        }
-        // Part of the row: not a second Tab stop.
-        let nresp = ui.interact(name_clip, egui::Id::unique(("pname", id.0)), Sense::CLICK | Sense::DRAG);
-        app.auto.add(&format!("project.item.{}.name", id.0), name_clip, &it.name);
-        let resp = resp.union(nresp);
-        if resp.clicked() {
-            let m = ui.input(|i| i.modifiers);
-            let anchor_key = anchor_id();
-            ctx.data_mut(|d| d.insert_temp(cursor_id(), *id));
-            let anchor = ctx.data(|d| d.get_temp::(anchor_key)).or_else(|| app.session.state.project_selection.first().copied());
-            if m.shift
-                && let Some(a) = anchor.and_then(|a| rows.iter().position(|(i, _)| *i == a))
-            {
-                let b = rows.iter().position(|(i, _)| i == id).unwrap_or(a);
-                app.session.state.project_selection = rows.get(a.min(b)..=a.max(b)).unwrap_or_default().iter().map(|(i, _)| *i).collect();
-            } else if m.command {
-                if app.session.state.project_selection.contains(id) {
-                    app.session.state.project_selection.retain(|i| i != id);
-                } else {
-                    app.session.state.project_selection.push(*id);
-                }
-                ctx.data_mut(|d| d.insert_temp(anchor_key, *id));
-            } else {
-                app.session.state.project_selection = vec![*id];
-                ctx.data_mut(|d| d.insert_temp(anchor_key, *id));
-            }
-        }
-        // Double-click opens the item, as in After Effects (a comp gets its own Timeline tab and
-        // the viewer); Enter or the context menu renames.
-        if resp.double_clicked() {
-            match &it.kind {
-                ItemKind::Comp(_) => actions.push(("comp.open".into(), json!({"comp": id.0}))),
-                ItemKind::Folder if !app.ui.project_open_folders.remove(&id.0) => {
-                    app.ui.project_open_folders.insert(id.0);
-                }
-                // Footage (not data) and solids open in the Footage panel.
-                ItemKind::Footage(f) if f.kind != effectcraft_engine::project::FootageKind::Data => {
-                    actions.push(("footage.open".into(), json!({"item": id.0})))
-                }
-                ItemKind::Solid(_) => actions.push(("footage.open".into(), json!({"item": id.0}))),
-                _ => {}
-            }
-        }
-        if resp.drag_started() {
-            egui::DragAndDrop::set_payload(&ctx, DragPayload::Item(id.0));
-        }
-        // Right-clicking an item selects it (After Effects): the menu's File commands act on the
-        // selection.
-        if resp.secondary_clicked() && !selected {
-            app.session.state.project_selection = vec![*id];
-        }
-        let footage = matches!(it.kind, ItemKind::Footage(_));
-        crate::widgets::context_menu(&resp, |ui| {
-            // First on a comp, as in After Effects; for this comp, not the viewer's (#1058).
-            if matches!(it.kind, ItemKind::Comp(_)) && ui.button(crate::i18n::tr("Composition Settings…")).clicked() {
-                actions.push(("comp.settings".into(), json!({"comp": id.0})));
-                ui.close();
-            }
-            if ui.button(crate::i18n::tr("Delete")).clicked() {
-                actions.push(("project.delete".into(), json!({"items": targets(app, *id, selected)})));
-                ui.close();
-            }
-            if matches!(it.kind, ItemKind::Comp(_)) && ui.button(crate::i18n::tr("Open Composition")).clicked() {
-                actions.push(("comp.open".into(), json!({"comp": id.0})));
-                ui.close();
-            }
-            if ui.button(crate::i18n::tr("Rename")).clicked() {
-                ctx.data_mut(|d| d.insert_temp::(edit_id(), (id.0, "name".into(), it.name.clone())));
-                ui.close();
-            }
-            let duplicate_items = targets(app, *id, selected);
-            // Like Edit > Duplicate: folders disable the whole operation, including mixed selections.
-            let can_duplicate =
-                !duplicate_items.is_empty() && duplicate_items.iter().all(|id| app.session.project.item(ItemId(*id)).is_some_and(|i| !i.is_folder()));
-            let mut duplicate = egui::Button::new(crate::i18n::tr("Duplicate"));
-            if let Some(shortcut) = app.session.shortcuts().shortcut_of("edit.duplicate", &serde_json::Value::Null) {
-                duplicate = duplicate.shortcut_text(crate::menus::shortcut_text(shortcut));
-            }
-            let response = ui.add_enabled(can_duplicate, duplicate);
-            app.auto.add("project.context.duplicate", response.rect, "Duplicate");
-            if response.clicked() {
-                actions.push(("project.duplicate".into(), json!({"items": duplicate_items})));
-                ui.close();
-            }
-            if it.parent.is_some() && ui.button(crate::i18n::tr("Move to Project Root")).clicked() {
-                actions.push(("project.move".into(), json!({"items": targets(app, *id, selected), "folder": null})));
-                ui.close();
-            }
-            if !it.is_folder() && ui.button(crate::i18n::tr("Add to Composition")).clicked() {
-                actions.push(("layer.addItem".into(), json!({"item": id.0})));
-                ui.close();
-            }
-            // File ▸ New Comp from Selection on the selection (the right-clicked row joins it
-            // above): several items ask how in its dialog, as in After Effects (#360).
-            if ui.button(crate::i18n::tr("New Comp from Selection")).clicked() {
-                actions.push(("file.newCompFromSelection".into(), json!({})));
-                ui.close();
-            }
-            // File ▸ Replace Footage ▸ With Precomposition: every layer using the item uses a
-            // new composition holding it instead. The label says how many layers that changes.
-            let uses: usize = app.session.project.item_users(*id).iter().map(|(_, n)| n).sum();
-            let label = match uses {
-                0 => crate::i18n::tr("Replace with Precomposition").to_owned(),
-                1 => format!("{} ({})", crate::i18n::tr("Replace with Precomposition"), crate::i18n::tr("used once")),
-                n => format!("{} ({})", crate::i18n::tr("Replace with Precomposition"), crate::i18n::tr_args("used {} times", &[&n])),
-            };
-            if (footage || matches!(it.kind, ItemKind::Solid(_))) && ui.button(label).clicked() {
-                actions.push(("file.replaceWithPrecomp".into(), json!({"item": id.0})));
-                ui.close();
-            }
-            ui.separator();
-            ui.menu_button(crate::i18n::tr("Label"), |ui| {
-                for l in Label::ALL {
-                    if widgets::label_entry(ui, l, &app.session.prefs.label_name(l), l == it.label, &t).clicked() {
-                        actions.push(("project.setLabel".into(), json!({"items": targets(app, *id, selected), "label": label_index(l)})));
-                        ui.close();
-                    }
-                }
-            });
-            // Footage: the File menu's footage commands (Interpret Footage, Replace Footage…).
-            if footage {
-                let mut chosen = None;
-                crate::menus::submenu_at(app, ui, &["File", "Interpret Footage"], &mut chosen);
-                crate::menus::submenu_at(app, ui, &["File", "Replace Footage"], &mut chosen);
-                crate::menus::entry_for(app, ui, "file.reloadFootage", &mut chosen);
-                crate::menus::entry_for(app, ui, "file.revealInFinder", &mut chosen);
-                actions.extend(chosen);
-            }
-        });
+    } else {
+        widgets::keep_focus(&ctx, &resp, &buf);
+        ctx.data_mut(|d| d.insert_temp::(edit_id(), (eid, ef, buf)));
     }
-    // The scroll bars, over the rows (which would take their presses otherwise).
-    if overflow > 0.0 {
-        let track = Rect::from_min_max(pos2(opt_x0, list.max.y - 5.0), pos2(rect.max.x - 4.0, list.max.y - 1.0));
-        app.ui.project_hscroll = widgets::scroll_bar(ui, &mut app.auto, "project.hscroll", track, hscroll, overflow, &t);
+    true
+}
+
+/// A click on item `id`'s row: plain selects it, Shift extends from the anchor and Cmd toggles.
+fn select_click(app: &mut EffectcraftApp, ui: &egui::Ui, ctx: &egui::Context, id: ItemId, rows: &[(ItemId, usize)]) {
+    let m = ui.input(|i| i.modifiers);
+    let anchor_key = anchor_id();
+    ctx.data_mut(|d| d.insert_temp(cursor_id(), id));
+    let anchor = ctx.data(|d| d.get_temp::(anchor_key)).or_else(|| app.session.state.project_selection.first().copied());
+    if m.shift
+        && let Some(a) = anchor.and_then(|a| rows.iter().position(|(i, _)| *i == a))
+    {
+        let b = rows.iter().position(|(i, _)| *i == id).unwrap_or(a);
+        app.session.state.project_selection = rows.get(a.min(b)..=a.max(b)).unwrap_or_default().iter().map(|(i, _)| *i).collect();
+    } else if m.command {
+        if app.session.state.project_selection.contains(&id) {
+            app.session.state.project_selection.retain(|i| *i != id);
+        } else {
+            app.session.state.project_selection.push(id);
+        }
+        ctx.data_mut(|d| d.insert_temp(anchor_key, id));
+    } else {
+        app.session.state.project_selection = vec![id];
+        ctx.data_mut(|d| d.insert_temp(anchor_key, id));
     }
-    if max_scroll > 0.0 {
-        let track = Rect::from_min_max(pos2(rect.max.x - 6.0, list.min.y + 1.0), pos2(rect.max.x - 2.0, list.max.y - 7.0));
-        app.ui.project_scroll = widgets::scroll_bar(ui, &mut app.auto, "project.vscroll", track, scroll, max_scroll, &t);
+}
+
+/// Double-click opens the item, as in After Effects: a comp in its Timeline and the viewer, a
+/// folder toggles, footage (not data) and solids open in the Footage panel.
+fn open_item(app: &mut EffectcraftApp, it: &effectcraft_engine::project::Item, id: ItemId, actions: &mut Vec<(String, serde_json::Value)>) {
+    match &it.kind {
+        ItemKind::Comp(_) => actions.push(("comp.open".into(), json!({"comp": id.0}))),
+        ItemKind::Folder if !app.ui.project_open_folders.remove(&id.0) => {
+            app.ui.project_open_folders.insert(id.0);
+        }
+        // Footage (not data) and solids open in the Footage panel.
+        ItemKind::Footage(f) if f.kind != effectcraft_engine::project::FootageKind::Data => actions.push(("footage.open".into(), json!({"item": id.0}))),
+        ItemKind::Solid(_) => actions.push(("footage.open".into(), json!({"item": id.0}))),
+        _ => {}
     }
+}
+
+/// What the pointer is doing over the list: the item being dragged, the row under it, whether it
+/// is over the list at all.
+struct DropState {
+    dragging: Option,
+    row: Option,
+    hovering: bool,
+}
+
+/// Dragging an item or files over the list: drop outlines, moving items into folders, importing
+/// files and the drag ghost.
+fn drop_targets(app: &mut EffectcraftApp, ctx: &egui::Context, lp: &egui::Painter, list: Rect, ds: DropState, actions: &mut Vec<(String, serde_json::Value)>) {
+    let t = app.tokens;
+    let DropState { dragging, row, hovering } = ds;
     // Dropping a dragged item on the list: into the folder under the pointer (or the folder of
     // the item under it), or the project root below the rows.
     if let Some(iid) = dragging
-        && hover_y.is_some()
+        && hovering
     {
-        if drop_row.is_none() {
+        if row.is_none() {
             lp.rect_stroke(list.shrink(1.0), 0.0, Stroke::new(1.0, t.accent.gamma_multiply(0.6)), egui::StrokeKind::Inside);
         }
-        if ctx.input(|i| i.pointer.any_released()) && drop_row != Some(ItemId(iid)) {
-            let folder = drop_folder(&app.session.project, drop_row);
+        if ctx.input(|i| i.pointer.any_released()) && row != Some(ItemId(iid)) {
+            let folder = drop_folder(&app.session.project, row);
             let cur = app.session.project.item(ItemId(iid)).and_then(|i| i.parent);
             if folder != cur {
                 let moving: Vec = if app.session.state.project_selection.contains(&ItemId(iid)) {
@@ -1005,21 +774,21 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
                     app.ui.project_open_folders.insert(f.0);
                 }
             }
-            egui::DragAndDrop::clear_payload(&ctx);
+            egui::DragAndDrop::clear_payload(ctx);
         }
     }
     // Files dragged from the Media Browser: import.
-    if let Some(DragPayload::Files(paths)) = egui::DragAndDrop::payload::(&ctx).as_deref()
-        && hover_y.is_some()
+    if let Some(DragPayload::Files(paths)) = egui::DragAndDrop::payload::(ctx).as_deref()
+        && hovering
     {
         lp.rect_stroke(list.shrink(1.0), 0.0, Stroke::new(1.0, t.accent.gamma_multiply(0.6)), egui::StrokeKind::Inside);
         if ctx.input(|i| i.pointer.any_released()) {
             actions.push(("mediaBrowser.import".into(), json!({"paths": paths})));
-            egui::DragAndDrop::clear_payload(&ctx);
+            egui::DragAndDrop::clear_payload(ctx);
         }
     }
     // Drag ghost.
-    if let Some(DragPayload::Item(iid)) = egui::DragAndDrop::payload::(&ctx).as_deref()
+    if let Some(DragPayload::Item(iid)) = egui::DragAndDrop::payload::(ctx).as_deref()
         && let Some(pos) = ctx.pointer_hover_pos()
         && let Some(it) = app.session.project.item(ItemId(*iid))
     {
@@ -1029,8 +798,12 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         painter.rect_filled(r, 4.0, Color32::from_rgba_premultiplied(40, 40, 40, 230));
         painter.galley(r.min + vec2(8.0, 4.0), g, t.text);
     }
-    // Footer.
-    let foot = Rect::from_min_max(pos2(rect.min.x, rect.max.y - footer_h), rect.max);
+}
+
+/// The footer: Interpret Footage, New Folder, New Composition, the colour depth and the trash.
+fn footer(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, foot: Rect, actions: &mut Vec<(String, serde_json::Value)>) {
+    let t = app.tokens;
+    let ctx = ui.ctx().clone();
     p.line_segment([foot.left_top(), foot.right_top()], Stroke::new(1.0, t.separator));
     let mut x = foot.min.x + 8.0;
     for (icon, id, tip) in [
@@ -1092,23 +865,102 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         actions.push(("project.delete".into(), json!({})));
     }
     app.auto.add("project.delete", tr, "Delete");
-    if empty.double_clicked() {
-        actions.push(("file.import".into(), json!({})));
-    }
-    crate::widgets::context_menu(&empty, |ui| {
-        for (key, label, command) in
-            [("newComp", "New Composition…", "comp.new"), ("newFolder", "New Folder", "project.newFolder"), ("import", "Import File…", "file.import")]
-        {
-            let response = ui.button(label);
-            app.auto.add(&format!("project.context.{key}"), response.rect, label);
-            if response.clicked() {
-                actions.push((command.into(), json!({})));
-                ui.close();
-            }
+}
+
+/// The optional columns of a row (type, size, duration, fps, path, comment). Returns the comment
+/// cell's response so its clicks select the row and open its context menu.
+fn optional_cells(
+    app: &mut EffectcraftApp,
+    ui: &mut egui::Ui,
+    g: &RowGeom,
+    it: &effectcraft_engine::project::Item,
+    cl: &Columns,
+    editing: &Option,
+    actions: &mut Vec<(String, serde_json::Value)>,
+) -> Option {
+    let t = app.tokens;
+    let ctx = ui.ctx().clone();
+    let RowGeom { lp, list, r, .. } = *g;
+    let mut extra = None;
+    for (key, _, cx, w) in cl.cols.iter().skip(2) {
+        let cell = cl.opt_clip(Rect::from_min_size(pos2(*cx, r.min.y), vec2(*w, r.height())).intersect(list));
+        if cell.width() <= 0.0 {
+            continue;
         }
-    });
-    // Selection box over the rows it touches (#203); Shift or Cmd/Ctrl adds to the selection.
-    let adding = ui.input(|i| i.modifiers.shift || i.modifiers.command);
+        let cp = lp.with_clip_rect(cell);
+        let txt = match *key {
+            "type" => it.type_name().to_string(),
+            "size" => file_size(it).map(fmt_size).unwrap_or_default(),
+            "duration" => it.duration().map(|d| fmt_dur(d.seconds(), it.frame_rate().map(|r| r.as_f64()).unwrap_or(30.0))).unwrap_or_default(),
+            "fps" => it.frame_rate().map(|f| format!("{:.2}", f.as_f64())).unwrap_or_default(),
+            "path" => file_path(it).to_string(),
+            "comment" => {
+                let cr = Rect::from_min_size(pos2(*cx - 2.0, r.min.y + 2.0), vec2(*w, r.height() - 4.0));
+                if cell_edit(app, ui, actions, editing, it.id.0, "comment", cr) {
+                    continue;
+                }
+                let cresp = ui.interact(cell, egui::Id::unique(("pcomment", it.id.0)), Sense::CLICK);
+                app.auto.add(&format!("project.item.{}.comment", it.id.0), cell, &it.comment);
+                cresp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Unknown, true, crate::i18n::tr_args("Comment of {}: {}", &[&it.name, &it.comment])));
+                if cresp.double_clicked() {
+                    ctx.data_mut(|d| d.insert_temp::(edit_id(), (it.id.0, "comment".into(), it.comment.clone())));
+                } else {
+                    // Other clicks select the row and open its context menu.
+                    extra = Some(cresp);
+                }
+                it.comment.clone()
+            }
+            _ => String::new(),
+        };
+        let align = if *key == "fps" { Align2::RIGHT_CENTER } else { Align2::LEFT_CENTER };
+        let tx = if *key == "fps" { cx + w - 6.0 } else { *cx };
+        cp.text(pos2(tx, r.center().y), align, txt, Tokens::ui(11.5), t.text_dim);
+    }
+    extra
+}
+
+/// Shift+wheel / trackpad over the list scrolls the optional columns sideways.
+fn hscroll_wheel(app: &mut EffectcraftApp, ui: &egui::Ui, list: Rect, overflow: f32) {
+    if overflow > 0.0 && ui.rect_contains_pointer(list) {
+        let (dx, dy, shift) = ui.input(|i| (i.smooth_scroll_delta.x, i.smooth_scroll_delta.y, i.modifiers.shift));
+        let d = if dx.abs() > 0.0 {
+            dx
+        } else if shift {
+            dy
+        } else {
+            0.0
+        };
+        app.ui.project_hscroll = (app.ui.project_hscroll - d).clamp(0.0, overflow);
+    }
+}
+
+/// Enter renames the selected item (Project panel focused, not typing).
+fn enter_renames(app: &mut EffectcraftApp, ctx: &egui::Context) {
+    if app.ui.focused == crate::dock::PanelKind::Project
+        && app.dialog.is_none()
+        && keys_reach_rows(app, ctx)
+        && ctx.data(|d| d.get_temp::(edit_id())).is_none()
+        && ctx.input(|i| i.key_pressed(egui::Key::Enter))
+    {
+        begin_rename(app, ctx);
+    }
+}
+
+/// The warning marker at the end of the name cell for footage that can't be decoded.
+fn problem_marker(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &RowGeom, name_w: f32, id: ItemId, why: &str) {
+    let t = app.tokens;
+    let RowGeom { lp, list, r, .. } = *g;
+    let m = Rect::from_center_size(pos2(r.min.x + name_w - 8.0, r.center().y), vec2(13.0, 12.0));
+    lp.add(egui::Shape::convex_polygon(vec![m.center_top(), m.right_bottom(), m.left_bottom()], t.warning, Stroke::NONE));
+    lp.text(m.center() + vec2(0.0, 1.5), Align2::CENTER_CENTER, "!", Tokens::semibold(9.0), Color32::BLACK);
+    ui.interact(m.expand(2.0).intersect(list), egui::Id::unique(("pproblem", id.0)), Sense::hover()).on_hover_text(why);
+    app.auto.add(&format!("project.item.{}.problem", id.0), m, why);
+}
+
+/// Selection box over the rows it touches (#203); Shift or Cmd/Ctrl adds to the selection.
+fn marquee_select(app: &mut EffectcraftApp, ctx: &egui::Context, lp: &egui::Painter, list: Rect, empty: &egui::Response, rows: &[(ItemId, usize)]) {
+    let t = app.tokens;
+    let adding = ctx.input(|i| i.modifiers.shift || i.modifiers.command);
     if empty.clicked() && !adding {
         app.session.state.project_selection.clear();
     }
@@ -1142,6 +994,333 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
             }
         }
     }
+}
+
+/// Where a row is drawn: its clipped painter, the list, the row rectangle and the indented x.
+#[derive(Clone, Copy)]
+struct RowGeom<'a> {
+    lp: &'a egui::Painter,
+    list: Rect,
+    r: Rect,
+    x0: f32,
+}
+
+/// Vertical scrolling: only the rows in view are drawn (and registered), so a project with
+/// thousands of items costs what one screenful does. Returns the scroll offset and its maximum.
+fn update_scroll(app: &mut EffectcraftApp, ui: &egui::Ui, ctx: &egui::Context, list: Rect, rows: &[(ItemId, usize)], moved: Option) -> (f32, f32) {
+    let content_h = rows.len() as f32 * ROW_H;
+    let max_scroll = (content_h - list.height() + ROW_H).max(0.0);
+    if ui.rect_contains_pointer(list) {
+        let (dy, shift) = ui.input(|i| (i.smooth_scroll_delta.y, i.modifiers.shift));
+        if !shift && dy != 0.0 {
+            app.ui.project_scroll -= dy;
+        }
+    }
+    // A selection made elsewhere (a command, an agent, the viewer) scrolls into view.
+    let sel_key = selection_seen_id();
+    let sel_now = app.session.state.project_selection.last().map(|i| i.0);
+    if ctx.data(|d| d.get_temp::>(sel_key)) != Some(sel_now) {
+        ctx.data_mut(|d| d.insert_temp(sel_key, sel_now));
+        if let Some(row) = sel_now.and_then(|s| rows.iter().position(|(id, _)| id.0 == s)) {
+            let top = row as f32 * ROW_H;
+            if top < app.ui.project_scroll {
+                app.ui.project_scroll = top;
+            } else if top + ROW_H > app.ui.project_scroll + list.height() {
+                app.ui.project_scroll = top + ROW_H - list.height();
+            }
+        }
+    }
+    // The arrow keys' item scrolls into view (Shift+Up extends upward, past the selection's end).
+    if let Some(row) = moved.and_then(|m| rows.iter().position(|(id, _)| *id == m)) {
+        let top = row as f32 * ROW_H;
+        // Not `clamp`: a panel shorter than a row would make its bounds cross. The row's top wins.
+        app.ui.project_scroll = app.ui.project_scroll.max(top + ROW_H - list.height()).min(top);
+    }
+    app.ui.project_scroll = app.ui.project_scroll.clamp(0.0, max_scroll);
+    (app.ui.project_scroll, max_scroll)
+}
+
+/// The folder's open / close twirl (Alt / Cmd-click twirls every folder inside it too).
+fn folder_twirl(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &RowGeom, it: &effectcraft_engine::project::Item) {
+    let t = app.tokens;
+    let RowGeom { r, x0, .. } = *g;
+    let open = app.ui.project_open_folders.contains(&it.id.0);
+    let tw = Rect::from_center_size(pos2(x0 + 4.0, r.center().y), vec2(12.0, 12.0));
+    let tresp = widgets::twirl(ui, tw, open, egui::Id::unique(("pf", it.id.0)), &t);
+    tresp.widget_info(|| {
+        let name = crate::i18n::tr_args(if open { "Close folder {}" } else { "Open folder {}" }, &[&it.name]);
+        egui::WidgetInfo::labeled(egui::Role::Button, true, name)
+    });
+    if tresp.clicked() {
+        // Alt/Cmd-click twirls every folder inside it too.
+        let mut ids = vec![it.id];
+        if ui.input(|i| i.modifiers.alt || i.modifiers.command) {
+            let mut at = 0;
+            while let Some(f) = ids.get(at).copied() {
+                ids.extend(app.session.project.children(Some(f)).into_iter().filter(|c| c.is_folder()).map(|c| c.id));
+                at += 1;
+            }
+        }
+        for f in ids {
+            if open {
+                app.ui.project_open_folders.remove(&f.0);
+            } else {
+                app.ui.project_open_folders.insert(f.0);
+            }
+        }
+    }
+    app.auto.add(&format!("project.item.{}.twirl", it.id.0), tw, &it.name);
+}
+
+/// Proxy indicator: filled = the proxy is used, hollow = set but off. Click to switch.
+fn proxy_indicator(
+    app: &mut EffectcraftApp,
+    ui: &mut egui::Ui,
+    g: &RowGeom,
+    it: &effectcraft_engine::project::Item,
+    actions: &mut Vec<(String, serde_json::Value)>,
+) {
+    let t = app.tokens;
+    let RowGeom { lp, list, r, x0 } = *g;
+    if let Some(px) = it.proxy.as_ref() {
+        let pr = Rect::from_center_size(pos2(x0 + 4.0, r.center().y), vec2(9.0, 9.0));
+        if px.enabled {
+            lp.rect_filled(pr, 1.0, t.text);
+        } else {
+            lp.rect_stroke(pr, 1.0, Stroke::new(1.0, t.text), egui::StrokeKind::Inside);
+        }
+        let presp = ui.interact(pr.expand(2.0).intersect(list), egui::Id::unique(("pproxy", it.id.0)), Sense::click()).on_hover_text(format!(
+            "Proxy: {} ({})",
+            px.footage.path,
+            if px.enabled { "in use" } else { "off" }
+        ));
+        app.auto.add(&format!("project.item.{}.proxy", it.id.0), pr, if px.enabled { "Proxy in use" } else { "Proxy off" });
+        presp.widget_info(|| {
+            let name = crate::i18n::tr_args(if px.enabled { "Proxy of {}: in use" } else { "Proxy of {}: off" }, &[&it.name]);
+            egui::WidgetInfo::labeled(egui::Role::Button, true, name)
+        });
+        if presp.clicked() {
+            actions.push(("file.useProxy".into(), json!({"item": it.id.0})));
+        }
+    }
+}
+
+/// The label swatch: click for the label colour menu.
+fn label_swatch(
+    app: &mut EffectcraftApp,
+    ui: &mut egui::Ui,
+    g: &RowGeom,
+    it: &effectcraft_engine::project::Item,
+    label_x: f32,
+    selected: bool,
+    actions: &mut Vec<(String, serde_json::Value)>,
+) {
+    let t = app.tokens;
+    let RowGeom { lp, list, r, .. } = *g;
+    let sw = Rect::from_center_size(pos2(label_x, r.center().y), vec2(10.0, 10.0));
+    lp.rect_filled(sw, 2.0, t.label(it.label));
+    let sresp = ui.interact(sw.expand(3.0).intersect(list), egui::Id::unique(("plabel", it.id.0)), Sense::click());
+    app.auto.add(&format!("project.item.{}.label", it.id.0), sw, it.label.name());
+    sresp.widget_info(|| {
+        let name = crate::i18n::tr_args("Label of {}: {}", &[&it.name, &app.session.prefs.label_name(it.label)]);
+        egui::WidgetInfo::labeled(egui::Role::Button, true, name)
+    });
+    let pop = egui::Id::unique(("plabel-pop", it.id.0));
+    if sresp.clicked() || sresp.secondary_clicked() {
+        widgets::open_popup(ui, pop);
+    }
+    if let Some(l) = widgets::label_popup(ui, pop, sw.left_bottom(), it.label, |l| app.session.prefs.label_name(l), &t) {
+        actions.push(("project.setLabel".into(), json!({"items": targets(app, it.id, selected), "label": label_index(l)})));
+    }
+}
+
+/// The Project panel: the selected item's thumbnail and details (with the comps that use it), the
+/// search field, sortable column headers, and the item rows (folders, comps, footage and solids)
+/// with selection, Shift/Cmd ranges, keyboard navigation, inline rename, drag-and-drop move,
+/// marquee selection and context menus. Only the rows in view are drawn. File commands are queued
+/// and run through [`crate::menus::invoke`] at the end, with failures shown in `app.ui.status`.
+pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
+    let t = app.tokens;
+    let p = ui.painter().clone();
+    let ctx = ui.ctx().clone();
+    // Preview header.
+    let head = Rect::from_min_size(rect.min, vec2(rect.width(), 74.0));
+    details_header(app, ui, &p, &ctx, head);
+    // Search.
+    let sr = Rect::from_min_size(pos2(rect.min.x + 8.0, head.max.y + 2.0), vec2(rect.width() - 16.0, 22.0));
+    let mut q = app.ui.project_search.clone();
+    widgets::search_field(ui, sr, &mut q, crate::i18n::tr("Search project"), &t);
+    app.ui.project_search = q;
+    app.auto.add("project.search", sr, "Search");
+    // Column header: Name, Label, then the visible optional columns.
+    let hdr = Rect::from_min_size(pos2(rect.min.x, sr.max.y + 6.0), vec2(rect.width(), 20.0));
+    p.line_segment([hdr.left_bottom(), hdr.right_bottom()], Stroke::new(1.0, t.separator));
+    let cl = layout_columns(app, rect);
+    let (name_w, label_x, opt_x0, overflow, hscroll) = (cl.name_w, cl.label_x, cl.opt_x0, cl.overflow, cl.hscroll);
+    draw_header(app, ui, &p, hdr, &cl);
+    // Rows.
+    let footer_h = 28.0;
+    let list = Rect::from_min_max(pos2(rect.min.x, hdr.max.y), pos2(rect.max.x, rect.max.y - footer_h));
+    let lp = p.with_clip_rect(list);
+    // The list's empty area (under the rows and the scroll bar, which take their own clicks):
+    // a click deselects, a drag draws a selection box, a double-click imports, as in After
+    // Effects (File ▸ Import ▸ File...).
+    // Not a Tab stop: the keyboard reaches the rows, and nothing else here takes a key.
+    let empty = ui.interact(list, egui::Id::unique("proj-empty"), Sense::CLICK | Sense::DRAG);
+    app.auto.add("project.empty", list, "Double-click to import files; drag to select items; right-click for project actions");
+    hscroll_wheel(app, ui, list, overflow);
+    let moved = keyboard_nav(app, &ctx);
+    let rows = visible_rows(app);
+    enter_renames(app, &ctx);
+    let editing: Option = ctx.data(|d| d.get_temp(edit_id()));
+    let dragging = egui::DragAndDrop::payload::(&ctx).and_then(|p| match *p {
+        DragPayload::Item(i) => Some(i),
+        _ => None,
+    });
+    let hover_y = ctx.pointer_hover_pos().filter(|p| list.contains(*p)).map(|p| p.y);
+    let mut drop_row: Option = None;
+    let mut actions: Vec<(String, serde_json::Value)> = vec![];
+    let (scroll, max_scroll) = update_scroll(app, ui, &ctx, list, &rows, moved);
+    let first = ((scroll / ROW_H).floor() as usize).min(rows.len());
+    let mut y = list.min.y - (scroll - first as f32 * ROW_H);
+    let project = app.session.project.clone();
+    for (i, (id, depth)) in rows.iter().enumerate().skip(first) {
+        let Some(it) = project.item(*id) else { continue };
+        let r = Rect::from_min_size(pos2(list.min.x, y), vec2(list.width(), ROW_H));
+        y += ROW_H;
+        if r.min.y > list.max.y {
+            break;
+        }
+        if hover_y.is_some_and(|hy| hy >= r.min.y && hy < r.max.y) {
+            drop_row = Some(*id);
+        }
+        let selected = app.session.state.project_selection.contains(id);
+        lp.rect_filled(
+            r,
+            0.0,
+            // Flat rows like After Effects' Project panel (no stripes).
+            if selected { t.row_selected } else { t.row },
+        );
+        // The row's click / drag area goes first, so the twirl, proxy, label and comment widgets
+        // on top of it take their own clicks.
+        let mut resp = ui.interact(r.intersect(list), row_id(*id), Sense::click_and_drag());
+        app.auto.add(&format!("project.item.{}", id.0), r, &it.name);
+        // For screen readers: a tree item named by the item and its type, with its selection,
+        // depth and (a folder) open state. One Tab stop per row; the panel's keys then apply.
+        let open = it.is_folder().then(|| app.ui.project_open_folders.contains(&id.0) || !app.ui.project_search.is_empty());
+        resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::TreeItem, true, format!("{}, {}", it.name, crate::i18n::tr(it.type_name()))));
+        ctx.accesskit_node_builder(resp.id, |n| {
+            n.set_selected(selected);
+            n.set_level(depth.saturating_add(1));
+            if let Some(open) = open {
+                n.set_expanded(open);
+            }
+        });
+        if resp.has_focus() {
+            ctx.data_mut(|d| d.insert_temp(focused_row_id(), *id));
+        }
+        if resp.gained_focus() {
+            app.ui.focused = crate::dock::PanelKind::Project;
+        }
+        let _ = i;
+        let x0 = r.min.x + 8.0 + 14.0 * *depth as f32;
+        let g = RowGeom { lp: &lp, list, r, x0 };
+        if it.is_folder() {
+            folder_twirl(app, ui, &g, it);
+        }
+        icons::paint(&lp, Rect::from_center_size(pos2(x0 + 18.0, r.center().y), vec2(14.0, 14.0)), item_icon(it), t.text_dim);
+        proxy_indicator(app, ui, &g, it, &mut actions);
+        // Footage that can't be decoded (no decoder for its codec / profile): a warning marker
+        // at the end of the name cell, the reason on hover.
+        let problem = match &it.kind {
+            ItemKind::Footage(f) => app.session.footage.problem(f),
+            _ => None,
+        };
+        let name_clip =
+            Rect::from_min_max(pos2(x0 + 30.0, r.min.y), pos2(r.min.x + name_w + 4.0 - if problem.is_some() { 16.0 } else { 0.0 }, r.max.y)).intersect(list);
+        if let Some(why) = &problem {
+            problem_marker(app, ui, &g, name_w, it.id, why);
+        }
+        let name_rect = Rect::from_min_max(pos2(x0 + 28.0, r.min.y + 2.0), pos2(r.min.x + name_w + 2.0, r.max.y - 2.0));
+        if !cell_edit(app, ui, &mut actions, &editing, id.0, "name", name_rect) {
+            // The selected item's name sits in a light cell with dark text.
+            if selected {
+                let g = lp.layout_no_wrap(it.name.clone(), Tokens::ui(12.0), Color32::BLACK);
+                let cell = Rect::from_min_max(pos2(x0 + 27.0, r.min.y + 1.0), pos2((x0 + 33.0 + g.size().x).min(name_clip.max.x), r.max.y - 1.0));
+                lp.with_clip_rect(name_clip.expand2(vec2(3.0, 0.0))).rect_filled(cell, 0.0, Color32::from_rgb(0xa6, 0xa6, 0xa6));
+            }
+            // Long names end in "…" before the label column instead of being cut mid-glyph.
+            widgets::text_fit(
+                &lp.with_clip_rect(name_clip),
+                pos2(x0 + 30.0, r.center().y),
+                Align2::LEFT_CENTER,
+                &it.name,
+                Tokens::ui(12.0),
+                name_clip.max.x - (x0 + 30.0) - 2.0,
+                if selected { Color32::from_gray(0x16) } else { t.text },
+            );
+        }
+        label_swatch(app, ui, &g, it, label_x, selected, &mut actions);
+        if let Some(extra) = optional_cells(app, ui, &g, it, &cl, &editing, &mut actions) {
+            resp = resp.union(extra);
+        }
+        if dragging.is_some_and(|d| d != id.0) && drop_row == Some(*id) && it.is_folder() {
+            lp.rect_stroke(r.shrink(1.0), 2.0, Stroke::new(1.5, t.accent), egui::StrokeKind::Inside);
+        }
+        // The row with keyboard focus (Tab, or the arrow keys after it) shows a focus ring.
+        if ctx.memory(|m| m.has_focus(row_id(*id))) {
+            lp.rect_stroke(r.shrink(0.5), 0.0, Stroke::new(1.0, t.accent), egui::StrokeKind::Inside);
+        }
+        // Part of the row: not a second Tab stop.
+        let nresp = ui.interact(name_clip, egui::Id::unique(("pname", id.0)), Sense::CLICK | Sense::DRAG);
+        app.auto.add(&format!("project.item.{}.name", id.0), name_clip, &it.name);
+        let resp = resp.union(nresp);
+        if resp.clicked() {
+            select_click(app, ui, &ctx, *id, &rows);
+        }
+        // Double-click opens the item, as in After Effects (a comp gets its own Timeline tab and
+        // the viewer); Enter or the context menu renames.
+        if resp.double_clicked() {
+            open_item(app, it, *id, &mut actions);
+        }
+        if resp.drag_started() {
+            egui::DragAndDrop::set_payload(&ctx, DragPayload::Item(id.0));
+        }
+        // Right-clicking an item selects it (After Effects): the menu's File commands act on the
+        // selection.
+        if resp.secondary_clicked() && !selected {
+            app.session.state.project_selection = vec![*id];
+        }
+        crate::widgets::context_menu(&resp, |ui| item_menu(app, ui, &ctx, it, *id, selected, &mut actions));
+    }
+    // The scroll bars, over the rows (which would take their presses otherwise).
+    if overflow > 0.0 {
+        let track = Rect::from_min_max(pos2(opt_x0, list.max.y - 5.0), pos2(rect.max.x - 4.0, list.max.y - 1.0));
+        app.ui.project_hscroll = widgets::scroll_bar(ui, &mut app.auto, "project.hscroll", track, hscroll, overflow, &t);
+    }
+    if max_scroll > 0.0 {
+        let track = Rect::from_min_max(pos2(rect.max.x - 6.0, list.min.y + 1.0), pos2(rect.max.x - 2.0, list.max.y - 7.0));
+        app.ui.project_scroll = widgets::scroll_bar(ui, &mut app.auto, "project.vscroll", track, scroll, max_scroll, &t);
+    }
+    drop_targets(app, &ctx, &lp, list, DropState { dragging, row: drop_row, hovering: hover_y.is_some() }, &mut actions);
+    let foot = Rect::from_min_max(pos2(rect.min.x, rect.max.y - footer_h), rect.max);
+    footer(app, ui, &p, foot, &mut actions);
+    if empty.double_clicked() {
+        actions.push(("file.import".into(), json!({})));
+    }
+    crate::widgets::context_menu(&empty, |ui| {
+        for (key, label, command) in
+            [("newComp", "New Composition…", "comp.new"), ("newFolder", "New Folder", "project.newFolder"), ("import", "Import File…", "file.import")]
+        {
+            let response = ui.button(label);
+            app.auto.add(&format!("project.context.{key}"), response.rect, label);
+            if response.clicked() {
+                actions.push((command.into(), json!({})));
+                ui.close();
+            }
+        }
+    });
+    marquee_select(app, &ctx, &lp, list, &empty, &rows);
     for (id, params) in actions {
         if let Err(e) = crate::menus::invoke(app, &ctx, &id, params) {
             app.ui.status = e;
diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs
index c26bb5030..c35108937 100644
--- a/crates/ui-egui/src/panels/properties.rs
+++ b/crates/ui-egui/src/panels/properties.rs
@@ -91,7 +91,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
 
     // ---------------------------------------------------------------- Layer Transform
     if let Some(tr) = layer.transform().cloned() {
-        section_header(app, ui, &p, x0, w, y, "Layer Transform", "properties.transform");
+        section_header(app, &p, x0, w, y, "Layer Transform", "properties.transform");
         let rr = Rect::from_min_size(pos2(x0 + w - 40.0, y), vec2(40.0, ROW_H));
         let resp = ui.interact(rr, egui::Id::unique("props-reset"), Sense::click());
         p.text(rr.right_center(), Align2::RIGHT_CENTER, crate::i18n::tr("Reset"), Tokens::ui(12.0), if resp.hovered() { t.accent_hover } else { t.accent });
@@ -124,7 +124,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         divider(&p, x0, w, y - 4.0, &t);
         y = paragraph_section(app, ui, &p, &layer, &doc, x0, w, y + 4.0, &mut actions);
         divider(&p, x0, w, y - 4.0, &t);
-        section_header(app, ui, &p, x0, w, y + 4.0, "Text Animation", "properties.textAnimation");
+        section_header(app, &p, x0, w, y + 4.0, "Text Animation", "properties.textAnimation");
         y += ROW_H + 10.0;
         // Both buttons grow with their (translated) labels; English keeps its 116 / 84 px.
         let text_w = |s: &str| p.layout_no_wrap(s.to_string(), Tokens::ui(12.0), t.text).size().x;
@@ -180,11 +180,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
     }
 }
 
-fn section_header(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, x0: f32, w: f32, y: f32, title: &str, auto: &str) {
+fn section_header(app: &mut EffectcraftApp, p: &egui::Painter, x0: f32, w: f32, y: f32, title: &str, auto: &str) {
     let t = app.tokens;
     p.text(pos2(x0, y + ROW_H / 2.0), Align2::LEFT_CENTER, title, Tokens::semibold(12.0), t.text);
     let r = Rect::from_min_size(pos2(x0, y), vec2(w, ROW_H));
-    let _ = ui;
     app.auto.add(auto, r, title);
 }
 
@@ -286,7 +285,14 @@ fn transform_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter,
         }
         Value::Scalar(v) => {
             let range = if pr.name == "Opacity" { (0.0, 100.0) } else { (-1e9, 1e9) };
-            let (vr, nv, _) = widgets::hot_number_at(ui, pos2(vx, cy - 9.0), egui::Id::unique(("props-v", uid)), *v, 0.5, range, decimals(*v), suffix, &t);
+            let (vr, nv, _) = widgets::hot_number_at(
+                ui,
+                pos2(vx, cy - 9.0),
+                egui::Id::unique(("props-v", uid)),
+                *v,
+                &widgets::HotSpec { speed: 0.5, range, decimals: decimals(*v), suffix },
+                &t,
+            );
             app.auto.add(&format!("{base}.value"), vr, &pr.name);
             if let Some(nv) = nv {
                 set(actions, json!(nv));
@@ -311,8 +317,14 @@ fn transform_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter,
                 }
             }
             for d in 0..n.min(c.len()) {
-                let (vr, nv, _) =
-                    widgets::hot_number_at(ui, pos2(x, cy - 9.0), egui::Id::unique(("props-v", uid, d)), c[d], 1.0, (-1e9, 1e9), decimals(c[d]), suffix, &t);
+                let (vr, nv, _) = widgets::hot_number_at(
+                    ui,
+                    pos2(x, cy - 9.0),
+                    egui::Id::unique(("props-v", uid, d)),
+                    c[d],
+                    &widgets::HotSpec { speed: 1.0, range: (-1e9, 1e9), decimals: decimals(c[d]), suffix },
+                    &t,
+                );
                 app.auto.add(&format!("{base}.value.{d}"), vr, &pr.name);
                 if let Some(nv) = nv {
                     let mut nc = c.clone();
@@ -358,7 +370,7 @@ fn text_section(
         v["layer"] = json!(lid);
         actions.push(("layer.setText".into(), v));
     };
-    section_header(app, ui, p, x0, w, y, "Text", "properties.text");
+    section_header(app, p, x0, w, y, "Text", "properties.text");
     y += ROW_H + 4.0;
     // Font family and style.
     let fr = Rect::from_min_size(pos2(x0, y), vec2(w, 22.0));
@@ -383,7 +395,14 @@ fn text_section(
     let col2 = x0 + w / 2.0;
     let field = |app: &mut EffectcraftApp, ui: &mut egui::Ui, x: f32, y: f32, glyph: &str, key: &str, v: f64, range: (f64, f64), suffix: &str| {
         p.text(pos2(x, y + 9.0), Align2::LEFT_CENTER, glyph, Tokens::semibold(11.0), t.text_dim);
-        let (r, nv, _) = widgets::hot_number_at(ui, pos2(x + 26.0, y), egui::Id::unique(("props-text", key)), v, 0.5, range, decimals(v), suffix, &t);
+        let (r, nv, _) = widgets::hot_number_at(
+            ui,
+            pos2(x + 26.0, y),
+            egui::Id::unique(("props-text", key)),
+            v,
+            &widgets::HotSpec { speed: 0.5, range, decimals: decimals(v), suffix },
+            &t,
+        );
         app.auto.add(&format!("properties.text.{key}"), r, key);
         nv
     };
@@ -462,7 +481,7 @@ fn paragraph_section(
     actions: &mut Actions,
 ) -> f32 {
     let t = app.tokens;
-    section_header(app, ui, p, x0, w, y, "Paragraph", "properties.paragraph");
+    section_header(app, p, x0, w, y, "Paragraph", "properties.paragraph");
     y += ROW_H + 4.0;
     for (i, (j, key)) in ALIGNS.into_iter().enumerate() {
         let r = Rect::from_min_size(pos2(x0 + i as f32 * 26.0, y), vec2(22.0, 22.0));
diff --git a/crates/ui-egui/src/panels/render_queue.rs b/crates/ui-egui/src/panels/render_queue.rs
index c002558ab..5e39a30c2 100644
--- a/crates/ui-egui/src/panels/render_queue.rs
+++ b/crates/ui-egui/src/panels/render_queue.rs
@@ -415,20 +415,21 @@ fn pick_output(app: &EffectcraftApp, it: &RenderQueueItem) -> Option {
+    queue: &'a [RenderQueueItem],
+    progress: Option<&'a effectcraft_engine::render_queue::JobState>,
+    rendering: bool,
+    available: &'a [OutputFormat],
+}
+
+/// Comps dragged from the Project over the panel: an outline while hovering, queued on release.
+fn queue_drop(app: &EffectcraftApp, ui: &egui::Ui, rect: Rect, p: &egui::Painter, rendering: bool, actions: &mut Vec) {
     let t = app.tokens;
-    let p = ui.painter().with_clip_rect(rect);
-    let queue: Vec = app.session.project.render_queue.clone();
-    let progress = app.session.render_progress();
-    let rendering = app.session.is_rendering();
-    let available = app.session.exporter.as_ref().map(|e| e.formats()).unwrap_or_default();
-    let mut actions: Vec<(&'static str, Value)> = Vec::new();
-    app.auto.add("renderQueue.drop", rect, "Drop compositions to queue");
     if !rendering
         && let Some(crate::panels::DragPayload::Item(id)) = egui::DragAndDrop::payload::(ui.ctx()).as_deref()
         && app.session.project.comp(effectcraft_engine::project::ItemId(*id)).is_some()
@@ -448,14 +449,14 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
             p.rect_stroke(rect.shrink(2.0), 0.0, Stroke::new(2.0, t.accent), StrokeKind::Inside);
         }
     }
-    let mut forms: Vec<(RenderQueueItem, String)> = Vec::new();
-    let state_id = egui::Id::unique("rq-ui");
-    let (mut open, mut selected): (Vec, Option) = ui.data(|d| d.get_temp(state_id).unwrap_or_default());
+}
 
-    // ---------------------------------------------------------- Current Render strip
-    let top = Rect::from_min_size(rect.min, vec2(rect.width(), TOP_H));
-    p.rect_filled(top, 0.0, t.tl_ruler_bg);
-    let x0 = rect.min.x + 12.0;
+/// The Current Render strip inside `top`: title, elapsed / remaining time, Render, Stop, Notify
+/// and the progress bar.
+fn current_render_strip(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, top: Rect, rq: &RqView, actions: &mut Vec) {
+    let t = app.tokens;
+    let RqView { queue, progress, rendering, available } = *rq;
+    let x0 = top.min.x + 12.0;
     p.text(pos2(x0, top.min.y + 16.0), Align2::LEFT_CENTER, tr("Current Render"), Tokens::semibold(12.5), t.text);
     let cur_item = progress.as_ref().and_then(|p| p.current).and_then(|id| queue.iter().find(|i| i.id == id));
     let cur_name = cur_item.and_then(|i| app.session.project.item(i.comp)).map(|i| i.name.clone());
@@ -515,6 +516,194 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
     };
     p.text(pos2(bar.min.x + 8.0, bar.center().y), Align2::LEFT_CENTER, bar_text, Tokens::ui(11.5), t.text);
     app.auto.add("renderQueue.progress", bar, &format!("{:.0}%", frac * 100.0));
+}
+
+/// The footer: the message line and the totals.
+fn footer(p: &egui::Painter, rect: Rect, rq: &RqView, t: &Tokens) {
+    let RqView { queue, progress, rendering, .. } = *rq;
+    let foot = Rect::from_min_max(pos2(rect.min.x, rect.max.y - FOOT_H), rect.max);
+    p.rect_filled(foot, 0.0, t.tl_ruler_bg);
+    p.line_segment([foot.left_top(), foot.right_top()], Stroke::new(1.0, t.separator));
+    let msg = match &progress {
+        Some(pr) if rendering => format!("Message: Rendering {} of {}", (pr.items_done + 1).min(pr.items_total), pr.items_total),
+        _ => {
+            let done = queue.iter().filter(|i| i.status == RenderStatus::Done).count();
+            format!("Message: {done} of {} done", queue.len())
+        }
+    };
+    p.text(pos2(foot.min.x + 12.0, foot.center().y), Align2::LEFT_CENTER, msg, Tokens::ui(11.0), t.text_dim);
+    let started = queue.iter().filter(|i| i.started.is_some()).count();
+    let total: f64 = queue.iter().filter_map(|i| i.render_time).sum();
+    p.text(
+        pos2(foot.max.x - 12.0, foot.center().y),
+        Align2::RIGHT_CENTER,
+        format!("Renders Started: {started}     Total Time Elapsed: {}", hms(progress.as_ref().filter(|_| rendering).map(|p| p.elapsed).unwrap_or(total))),
+        Tokens::ui(11.0),
+        t.text_dim,
+    );
+}
+
+/// An item row's context menu: Duplicate, Remove and Move Up / Down (row `k` of `len`).
+fn row_menu(ui: &mut egui::Ui, actions: &mut Vec, item_id: u64, k: usize, len: usize) {
+    if ui.button(tr("Duplicate")).clicked() {
+        actions.push(("renderQueue.duplicate", json!({"item": item_id})));
+        ui.close();
+    }
+    if ui.button(tr("Remove")).clicked() {
+        actions.push(("renderQueue.remove", json!({"item": item_id})));
+        ui.close();
+    }
+    if k > 0 && ui.button(tr("Move Up")).clicked() {
+        actions.push(("renderQueue.move", json!({"item": item_id, "to": k})));
+        ui.close();
+    }
+    if k + 1 < len && ui.button(tr("Move Down")).clicked() {
+        actions.push(("renderQueue.move", json!({"item": item_id, "to": k + 2})));
+        ui.close();
+    }
+}
+
+/// Log: Errors Only / Plus Settings / Plus Per Frame Info.
+fn log_dropdown(app: &mut EffectcraftApp, ui: &mut egui::Ui, row: &ItemRow, it: &RenderQueueItem, actions: &mut Vec) {
+    let ItemRow { lp, r, n, rendering, .. } = *row;
+    let t = app.tokens;
+    let aid = |s: &str| format!("renderQueue.item.{n}.{s}");
+    let ld = Rect::from_min_size(pos2(r.max.x - 190.0, r.min.y + 3.0), vec2(150.0, ROW_H - 6.0));
+    lp.text(pos2(ld.min.x - 6.0, r.center().y), Align2::RIGHT_CENTER, tr("Log:"), Tokens::ui(11.5), t.text_dim);
+    let lid = egui::Id::unique(("rq-log", it.id));
+    if widgets::dropdown(ui, ld, it.log.label(), &t, lid).clicked() && !rendering {
+        widgets::open_popup(ui, lid);
+    }
+    let llabels: Vec = RenderLog::ALL.iter().map(|l| mark(it.log == *l, l.label())).collect();
+    if let Some(i) = widgets::popup_menu(ui, lid, ld.left_bottom(), &llabels, None) {
+        actions.push(("renderQueue.setLog", json!({"item": it.id, "log": RenderLog::ALL[i].label()})));
+    }
+    app.auto.add(&aid("log"), ld, it.log.label());
+}
+
+/// Where an item row's expanded line is drawn.
+#[derive(Clone, Copy)]
+struct ItemRow<'a> {
+    lp: &'a egui::Painter,
+    list: Rect,
+    r: Rect,
+    /// The item's 1-based position in the queue.
+    n: usize,
+    rendering: bool,
+}
+
+/// The expanded "Output To" line: the template menu, and the file name (click to choose it or
+/// edit the template in place).
+fn output_to(
+    app: &mut EffectcraftApp,
+    ui: &mut egui::Ui,
+    row: &ItemRow,
+    it: &RenderQueueItem,
+    dd_right: f32,
+    editing: &mut Option<(u64, String)>,
+    actions: &mut Vec,
+) {
+    let ItemRow { lp, list, r, n, rendering } = *row;
+    let t = app.tokens;
+    let aid = |s: &str| format!("renderQueue.item.{n}.{s}");
+    let ox = dd_right + 14.0;
+    lp.text(pos2(ox, r.center().y), Align2::LEFT_CENTER, tr("Output To:"), Tokens::ui(11.5), t.text_dim);
+    let odd = Rect::from_min_size(pos2(ox + 62.0, r.min.y + 3.0), vec2(22.0, ROW_H - 6.0));
+    let oid = egui::Id::unique(("rq-out", it.id));
+    if widgets::dropdown(ui, odd, "", &t, oid).clicked() && !rendering {
+        widgets::open_popup(ui, oid);
+    }
+    app.auto.add(&aid("outputTo"), odd, "Output To");
+    let omenu = output_to_menu(it);
+    let olabels: Vec = omenu.iter().map(|(l, _)| l.clone()).collect();
+    if let Some(i) = widgets::popup_menu(ui, oid, odd.left_bottom(), &olabels, None) {
+        if omenu[i].1.get("choose").is_some() {
+            match pick_output(app, it) {
+                Some(Some(path)) => actions.push(("renderQueue.setOutput", json!({"item": it.id, "path": path}))),
+                Some(None) => {}
+                None => *editing = Some((it.id, it.output.output.clone())),
+            }
+        } else {
+            let mut params = omenu[i].1.clone();
+            params["item"] = json!(it.id);
+            actions.push(("renderQueue.setOutput", params));
+        }
+    }
+    let path_rect = Rect::from_min_max(pos2(odd.max.x + 6.0, r.min.y + 2.0), pos2(r.max.x - 8.0, r.max.y - 2.0));
+    if editing.as_ref().is_some_and(|(id, _)| *id == it.id) {
+        let mut buf = editing.as_ref().map(|(_, b)| b.clone()).unwrap_or_default();
+        let mut child = ui.new_child(egui::UiBuilder::new().max_rect(path_rect));
+        let resp = child.add(egui::TextEdit::singleline(&mut buf).desired_width(path_rect.width()).font(Tokens::ui(11.5)));
+        if resp.lost_focus() {
+            if ui.input(|i| i.key_pressed(egui::Key::Enter)) && !buf.trim().is_empty() {
+                actions.push(("renderQueue.setOutput", json!({"item": it.id, "path": buf.trim()})));
+            }
+            *editing = None;
+        } else {
+            widgets::keep_focus(ui.ctx(), &resp, &buf);
+            *editing = Some((it.id, buf));
+        }
+    } else {
+        let shown = app.session.resolve_output(it).unwrap_or_else(|| "Not yet specified".into());
+        let resp = ui.interact(path_rect, egui::Id::unique(("rq-path", it.id)), Sense::click());
+        let col = if resp.hovered() { t.accent_hover } else { t.hot_text };
+        lp.with_clip_rect(path_rect.intersect(list)).text(pos2(path_rect.min.x, r.center().y), Align2::LEFT_CENTER, &shown, Tokens::ui(11.5), col);
+        // Clicking the file name opens the save dialog (Output Movie To); Alt-click, or a
+        // host without one, edits the name template in place.
+        let dialog = app.hooks.pick_save_file.is_some();
+        if resp.clicked() && !rendering {
+            let picked = if ui.input(|i| i.modifiers.alt) { None } else { pick_output(app, it) };
+            match picked {
+                Some(Some(path)) => actions.push(("renderQueue.setOutput", json!({"item": it.id, "path": path}))),
+                Some(None) => {}
+                None => *editing = Some((it.id, it.output.output.clone())),
+            }
+        }
+        let how = if dialog { "Click to choose where to save; Alt-click to edit the template" } else { "Click to edit" };
+        resp.on_hover_text(tr_args("Template: {}\n{}", &[&it.output.output, &how]));
+        app.auto.add(&aid("outputPath"), path_rect, &shown);
+    }
+}
+
+/// Delete removes the selected item when the panel has focus (`allowed`: not editing a path and
+/// not rendering).
+fn delete_selected(app: &EffectcraftApp, ui: &egui::Ui, allowed: bool, selected: &mut Option, actions: &mut Vec) {
+    if app.ui.focused == crate::dock::PanelKind::RenderQueue
+        && allowed
+        && let Some(id) = *selected
+        && app.dialog.is_none()
+        && ui.input(|i| i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace))
+        && !ui.ctx().egui_wants_keyboard_input()
+    {
+        actions.push(("renderQueue.remove", json!({"item": id})));
+        *selected = None;
+    }
+}
+
+/// The Render Queue panel: the Current Render strip (elapsed and remaining time, Render/Stop and
+/// Notify, plus the progress bar), the column header, the queued items with their output template,
+/// status and timings, and the footer totals. Comps dragged from the Project are queued on
+/// release. The open items and the selected item are kept in egui temp data; output-template forms
+/// open through a small form, and every change runs a `renderQueue.*` command.
+pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
+    let t = app.tokens;
+    let p = ui.painter().with_clip_rect(rect);
+    let queue: Vec = app.session.project.render_queue.clone();
+    let progress = app.session.render_progress();
+    let rendering = app.session.is_rendering();
+    let available = app.session.exporter.as_ref().map(|e| e.formats()).unwrap_or_default();
+    let mut actions: Vec = Vec::new();
+    let rq = RqView { queue: &queue, progress: progress.as_ref(), rendering, available: &available };
+    app.auto.add("renderQueue.drop", rect, "Drop compositions to queue");
+    queue_drop(app, ui, rect, &p, rendering, &mut actions);
+    let mut forms: Vec<(RenderQueueItem, String)> = Vec::new();
+    let state_id = egui::Id::unique("rq-ui");
+    let (mut open, mut selected): (Vec, Option) = ui.data(|d| d.get_temp(state_id).unwrap_or_default());
+
+    // ---------------------------------------------------------- Current Render strip
+    let top = Rect::from_min_size(rect.min, vec2(rect.width(), TOP_H));
+    p.rect_filled(top, 0.0, t.tl_ruler_bg);
+    current_render_strip(app, ui, &p, top, &rq, &mut actions);
 
     // ---------------------------------------------------------- column header
     let head = Rect::from_min_size(pos2(rect.min.x, top.max.y), vec2(rect.width(), HEAD_H));
@@ -575,25 +764,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
             selected = Some(it.id);
             app.ui.focused = crate::dock::PanelKind::RenderQueue;
         }
-        let item_id = it.id;
-        crate::widgets::context_menu(&row_resp, |ui| {
-            if ui.button(tr("Duplicate")).clicked() {
-                actions.push(("renderQueue.duplicate", json!({"item": item_id})));
-                ui.close();
-            }
-            if ui.button(tr("Remove")).clicked() {
-                actions.push(("renderQueue.remove", json!({"item": item_id})));
-                ui.close();
-            }
-            if k > 0 && ui.button(tr("Move Up")).clicked() {
-                actions.push(("renderQueue.move", json!({"item": item_id, "to": n - 1})));
-                ui.close();
-            }
-            if n < queue.len() && ui.button(tr("Move Down")).clicked() {
-                actions.push(("renderQueue.move", json!({"item": item_id, "to": n + 1})));
-                ui.close();
-            }
-        });
+        crate::widgets::context_menu(&row_resp, |ui| row_menu(ui, &mut actions, it.id, k, queue.len()));
         app.auto.add(&aid("row"), row, &format!("#{n}"));
         let is_open = open.contains(&it.id);
         let tw = Rect::from_center_size(pos2(cx_twirl + 8.0, row.center().y), vec2(14.0, 14.0));
@@ -686,78 +857,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
                     );
                     lp.text(pos2(dd.max.x + 14.0, r.center().y), Align2::LEFT_CENTER, info, Tokens::ui(11.0), t.text_faint);
                 }
-                // Log: Errors Only / Plus Settings / Plus Per Frame Info.
-                let ld = Rect::from_min_size(pos2(r.max.x - 190.0, r.min.y + 3.0), vec2(150.0, ROW_H - 6.0));
-                lp.text(pos2(ld.min.x - 6.0, r.center().y), Align2::RIGHT_CENTER, tr("Log:"), Tokens::ui(11.5), t.text_dim);
-                let lid = egui::Id::unique(("rq-log", it.id));
-                if widgets::dropdown(ui, ld, it.log.label(), &t, lid).clicked() && !rendering {
-                    widgets::open_popup(ui, lid);
-                }
-                let llabels: Vec = RenderLog::ALL.iter().map(|l| mark(it.log == *l, l.label())).collect();
-                if let Some(i) = widgets::popup_menu(ui, lid, ld.left_bottom(), &llabels, None) {
-                    actions.push(("renderQueue.setLog", json!({"item": it.id, "log": RenderLog::ALL[i].label()})));
-                }
-                app.auto.add(&aid("log"), ld, it.log.label());
+                log_dropdown(app, ui, &ItemRow { lp: &lp, list, r, n, rendering }, it, &mut actions);
                 continue;
             }
-            // Output To.
-            let ox = dd.max.x + 14.0;
-            lp.text(pos2(ox, r.center().y), Align2::LEFT_CENTER, tr("Output To:"), Tokens::ui(11.5), t.text_dim);
-            let odd = Rect::from_min_size(pos2(ox + 62.0, r.min.y + 3.0), vec2(22.0, ROW_H - 6.0));
-            let oid = egui::Id::unique(("rq-out", it.id));
-            if widgets::dropdown(ui, odd, "", &t, oid).clicked() && !rendering {
-                widgets::open_popup(ui, oid);
-            }
-            app.auto.add(&aid("outputTo"), odd, "Output To");
-            let omenu = output_to_menu(it);
-            let olabels: Vec = omenu.iter().map(|(l, _)| l.clone()).collect();
-            if let Some(i) = widgets::popup_menu(ui, oid, odd.left_bottom(), &olabels, None) {
-                if omenu[i].1.get("choose").is_some() {
-                    match pick_output(app, it) {
-                        Some(Some(path)) => actions.push(("renderQueue.setOutput", json!({"item": it.id, "path": path}))),
-                        Some(None) => {}
-                        None => editing = Some((it.id, it.output.output.clone())),
-                    }
-                } else {
-                    let mut params = omenu[i].1.clone();
-                    params["item"] = json!(it.id);
-                    actions.push(("renderQueue.setOutput", params));
-                }
-            }
-            let path_rect = Rect::from_min_max(pos2(odd.max.x + 6.0, r.min.y + 2.0), pos2(r.max.x - 8.0, r.max.y - 2.0));
-            if editing.as_ref().is_some_and(|(id, _)| *id == it.id) {
-                let mut buf = editing.as_ref().map(|(_, b)| b.clone()).unwrap_or_default();
-                let mut child = ui.new_child(egui::UiBuilder::new().max_rect(path_rect));
-                let resp = child.add(egui::TextEdit::singleline(&mut buf).desired_width(path_rect.width()).font(Tokens::ui(11.5)));
-                if resp.lost_focus() {
-                    if ui.input(|i| i.key_pressed(egui::Key::Enter)) && !buf.trim().is_empty() {
-                        actions.push(("renderQueue.setOutput", json!({"item": it.id, "path": buf.trim()})));
-                    }
-                    editing = None;
-                } else {
-                    widgets::keep_focus(ui.ctx(), &resp, &buf);
-                    editing = Some((it.id, buf));
-                }
-            } else {
-                let shown = app.session.resolve_output(it).unwrap_or_else(|| "Not yet specified".into());
-                let resp = ui.interact(path_rect, egui::Id::unique(("rq-path", it.id)), Sense::click());
-                let col = if resp.hovered() { t.accent_hover } else { t.hot_text };
-                lp.with_clip_rect(path_rect.intersect(list)).text(pos2(path_rect.min.x, r.center().y), Align2::LEFT_CENTER, &shown, Tokens::ui(11.5), col);
-                // Clicking the file name opens the save dialog (Output Movie To); Alt-click, or a
-                // host without one, edits the name template in place.
-                let dialog = app.hooks.pick_save_file.is_some();
-                if resp.clicked() && !rendering {
-                    let picked = if ui.input(|i| i.modifiers.alt) { None } else { pick_output(app, it) };
-                    match picked {
-                        Some(Some(path)) => actions.push(("renderQueue.setOutput", json!({"item": it.id, "path": path}))),
-                        Some(None) => {}
-                        None => editing = Some((it.id, it.output.output.clone())),
-                    }
-                }
-                let how = if dialog { "Click to choose where to save; Alt-click to edit the template" } else { "Click to edit" };
-                resp.on_hover_text(tr_args("Template: {}\n{}", &[&it.output.output, &how]));
-                app.auto.add(&aid("outputPath"), path_rect, &shown);
-            }
+            output_to(app, ui, &ItemRow { lp: &lp, list, r, n, rendering }, it, dd.max.x, &mut editing, &mut actions);
         }
     }
     ui.set_clip_rect(panel_clip);
@@ -770,40 +873,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         None => d.remove::<(u64, String)>(editing_id),
     });
 
-    // Delete removes the selected item when the panel has focus.
-    if app.ui.focused == crate::dock::PanelKind::RenderQueue
-        && editing.is_none()
-        && !rendering
-        && let Some(id) = selected
-        && app.dialog.is_none()
-        && ui.input(|i| i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace))
-        && !ui.ctx().egui_wants_keyboard_input()
-    {
-        actions.push(("renderQueue.remove", json!({"item": id})));
-        selected = None;
-    }
+    delete_selected(app, ui, editing.is_none() && !rendering, &mut selected, &mut actions);
 
     // ---------------------------------------------------------- footer
-    let foot = Rect::from_min_max(pos2(rect.min.x, rect.max.y - FOOT_H), rect.max);
-    p.rect_filled(foot, 0.0, t.tl_ruler_bg);
-    p.line_segment([foot.left_top(), foot.right_top()], Stroke::new(1.0, t.separator));
-    let msg = match &progress {
-        Some(pr) if rendering => format!("Message: Rendering {} of {}", (pr.items_done + 1).min(pr.items_total), pr.items_total),
-        _ => {
-            let done = queue.iter().filter(|i| i.status == RenderStatus::Done).count();
-            format!("Message: {done} of {} done", queue.len())
-        }
-    };
-    p.text(pos2(foot.min.x + 12.0, foot.center().y), Align2::LEFT_CENTER, msg, Tokens::ui(11.0), t.text_dim);
-    let started = queue.iter().filter(|i| i.started.is_some()).count();
-    let total: f64 = queue.iter().filter_map(|i| i.render_time).sum();
-    p.text(
-        pos2(foot.max.x - 12.0, foot.center().y),
-        Align2::RIGHT_CENTER,
-        format!("Renders Started: {started}     Total Time Elapsed: {}", hms(progress.as_ref().filter(|_| rendering).map(|p| p.elapsed).unwrap_or(total))),
-        Tokens::ui(11.0),
-        t.text_dim,
-    );
+    footer(&p, rect, &rq, &t);
 
     open.retain(|id| queue.iter().any(|i| i.id == *id));
     ui.data_mut(|d| d.insert_temp(state_id, (open, selected)));
diff --git a/crates/ui-egui/src/panels/roto_tool.rs b/crates/ui-egui/src/panels/roto_tool.rs
index 52175899c..0d9eefe45 100644
--- a/crates/ui-egui/src/panels/roto_tool.rs
+++ b/crates/ui-egui/src/panels/roto_tool.rs
@@ -135,17 +135,15 @@ pub fn draw_overlay(
     }
 }
 
+/// The layer <-> screen mappings [`interact`] works with.
+pub struct LayerXform<'a> {
+    pub to_screen: &'a dyn Fn([f64; 2]) -> Pos2,
+    pub to_layer: &'a dyn Fn(Pos2) -> [f64; 2],
+}
+
 /// 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 {
+pub fn interact(app: &mut EffectcraftApp, ui: &mut egui::Ui, painter: &egui::Painter, area: Rect, layer: &Layer, zoom: f64, xf: LayerXform) -> bool {
+    let LayerXform { to_screen, to_layer } = xf;
     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 };
diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs
index c285011fe..692f48236 100644
--- a/crates/ui-egui/src/panels/settings.rs
+++ b/crates/ui-egui/src/panels/settings.rs
@@ -302,19 +302,20 @@ fn theme_preview(ui: &mut egui::Ui, kind: ThemeKind, width: f32) {
     painter.line_segment([pos2(cti, graph.top()), pos2(cti, graph.bottom())], egui::Stroke::new(1.0, p.cti));
 }
 
+/// What [`theme_card`] shows: the setting `key`, the translated `title`, whether the family is
+/// `active`, the `cur`rent theme and the `options` (value, label) to pick from.
+struct ThemeFamily<'a> {
+    key: &'a str,
+    title: &'a str,
+    active: bool,
+    cur: &'a str,
+    options: &'a [(&'a str, &'a str)],
+}
+
 /// One theme family's card: its (translated) name, Active when the appearance shows it now, a preview of the
 /// chosen theme and a radio button per theme.
-fn theme_card(
-    app: &mut EffectcraftApp,
-    ui: &mut egui::Ui,
-    t: &Tokens,
-    key: &str,
-    title: &str,
-    active: bool,
-    cur: &str,
-    options: &[(&str, &str)],
-    acts: &mut Vec,
-) {
+fn theme_card(app: &mut EffectcraftApp, ui: &mut egui::Ui, t: &Tokens, fam: ThemeFamily, acts: &mut Vec) {
+    let ThemeFamily { key, title, active, cur, options } = fam;
     egui::Frame::new()
         .fill(t.field_bg)
         .stroke(egui::Stroke::new(1.0, if active { t.accent } else { t.field_border }))
@@ -396,8 +397,20 @@ fn appearance_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, cur: &Value, t: &T
     let light_active = mode == "light" || (mode == "auto" && system_light == Some(true));
     ui.columns(2, |cols| {
         let [l, d] = cols else { return };
-        theme_card(app, l, t, "appearance.lightTheme", tr("Light Theme"), light_active, &light, LIGHT_THEMES, acts);
-        theme_card(app, d, t, "appearance.darkTheme", tr("Dark Theme"), !light_active, &dark, DARK_THEMES, acts);
+        theme_card(
+            app,
+            l,
+            t,
+            ThemeFamily { key: "appearance.lightTheme", title: tr("Light Theme"), active: light_active, cur: &light, options: LIGHT_THEMES },
+            acts,
+        );
+        theme_card(
+            app,
+            d,
+            t,
+            ThemeFamily { key: "appearance.darkTheme", title: tr("Dark Theme"), active: !light_active, cur: &dark, options: DARK_THEMES },
+            acts,
+        );
     });
     ui.add_space(6.0);
 }
diff --git a/crates/ui-egui/src/panels/text_panels.rs b/crates/ui-egui/src/panels/text_panels.rs
index 2a3fb5063..8a8e604e2 100644
--- a/crates/ui-egui/src/panels/text_panels.rs
+++ b/crates/ui-egui/src/panels/text_panels.rs
@@ -64,7 +64,8 @@ pub fn text_target(app: &EffectcraftApp) -> Option {
 /// A small toggle button at `r` showing `label` (semibold 13 when `bold`, else 12), filled with
 /// the accent while `on`; returns whether it was clicked. It only reports the click: the caller
 /// decides the new state.
-fn toggle(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, r: Rect, id: &str, label: &str, on: bool, bold: bool) -> bool {
+fn toggle(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, r: Rect, id: &str, look: (&str, bool, bool)) -> bool {
+    let (label, on, bold) = look;
     let t = app.tokens;
     let resp = ui.interact(r, egui::Id::unique(("text-toggle", id)), Sense::click());
     p.rect_filled(
@@ -84,6 +85,103 @@ fn toggle(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, r: Rec
     resp.clicked()
 }
 
+/// Position of the numeric grid (`x0`, top `y`, width `w`) and whether edits are collected.
+#[derive(Clone, Copy)]
+struct Grid {
+    x0: f32,
+    y: f32,
+    w: f32,
+    enabled: bool,
+}
+
+/// Numeric fields in AE's two-column grid (Kerning is a popup, at row 1 left).
+fn numeric_fields(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, g: Grid, doc: &TextDoc, actions: &mut Vec) {
+    let t = app.tokens;
+    let Grid { x0, y, w, enabled } = g;
+    let fields: [(&str, &str, f64, (f64, f64), &str); 10] = [
+        ("size", "T", doc.size, (1.0, 2000.0), " px"),
+        ("leading", "A", doc.leading.unwrap_or(doc.size * 1.2), (0.0, 5000.0), " px"),
+        ("", "", 0.0, (0.0, 0.0), ""),
+        ("tracking", "VA", doc.tracking, (-1000.0, 10000.0), ""),
+        ("strokeWidth", "W", doc.stroke_width, (0.0, 500.0), " px"),
+        ("", "", 0.0, (0.0, 0.0), ""),
+        ("vScale", "↕T", doc.v_scale, (1.0, 1000.0), " %"),
+        ("hScale", "↔T", doc.h_scale, (1.0, 1000.0), " %"),
+        ("baselineShift", "A↑", doc.baseline_shift, (-1000.0, 1000.0), " px"),
+        ("tsume", "Ts", doc.tsume, (0.0, 100.0), " %"),
+    ];
+    for (i, (key, glyph, v, range, suffix)) in fields.into_iter().enumerate() {
+        if key.is_empty() {
+            continue;
+        }
+        let col = i % 2;
+        let row = i / 2;
+        let fx = x0 + col as f32 * (w / 2.0);
+        let fy = y + row as f32 * 28.0;
+        p.text(pos2(fx, fy + 9.0), Align2::LEFT_CENTER, glyph, Tokens::semibold(11.0), t.text_dim);
+        let (r, nv, _) = widgets::hot_number_at(
+            ui,
+            pos2(fx + 26.0, fy),
+            egui::Id::unique(("char", key)),
+            v,
+            &widgets::HotSpec { speed: 0.5, range, decimals: 0, suffix },
+            &t,
+        );
+        app.auto.add(&format!("character.{key}"), r, key);
+        if let Some(nv) = nv
+            && enabled
+        {
+            actions.push(json!({key: nv, "merge": format!("char-{key}")}));
+        }
+    }
+}
+
+/// Kerning: Metrics / Optical / a manual value (grid row 1, left).
+fn kerning_field(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, g: Grid, doc: &TextDoc, actions: &mut Vec) {
+    let t = app.tokens;
+    let Grid { x0, y, w, enabled } = g;
+    let fy = y + 28.0;
+    p.text(pos2(x0, fy + 9.0), Align2::LEFT_CENTER, "V/A", Tokens::semibold(11.0), t.text_dim);
+    let label = match doc.kerning {
+        Kerning::Metrics => "Metrics".to_string(),
+        Kerning::Optical => "Optical".to_string(),
+        Kerning::Manual(v) => format!("{v:.0}"),
+    };
+    let kr = Rect::from_min_size(pos2(x0 + 26.0, fy), vec2((w / 2.0 - 32.0).max(50.0), 20.0));
+    let kpop = egui::Id::unique("char-kerning-pop");
+    if widgets::dropdown(ui, kr, &label, &t, egui::Id::unique("char-kerning")).clicked() && enabled {
+        widgets::open_popup(ui, kpop);
+    }
+    app.auto.add("character.kerning", kr, "Kerning");
+    let opts: Vec = ["Metrics", "Optical", "0", "-50", "-25", "-10", "10", "25", "50", "100"].iter().map(|s| s.to_string()).collect();
+    if let Some(i) = widgets::popup_menu(ui, kpop, kr.left_bottom(), &opts, None) {
+        let v = match i {
+            0 => json!("metrics"),
+            1 => json!("optical"),
+            _ => json!(opts[i].parse::().unwrap_or(0.0)),
+        };
+        actions.push(json!({"kerning": v}));
+    }
+}
+
+/// Stroke Over Fill / Fill Over Stroke (grid row 2, right).
+fn stroke_order_field(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: Grid, doc: &TextDoc, actions: &mut Vec) {
+    let t = app.tokens;
+    let Grid { x0, y, w, enabled } = g;
+    let fx = x0 + w / 2.0;
+    let fy = y + 2.0 * 28.0;
+    let dr = Rect::from_min_size(pos2(fx, fy), vec2(w / 2.0, 20.0));
+    let labels = vec!["Fill Over Stroke".to_string(), "Stroke Over Fill".to_string()];
+    let pop = egui::Id::unique("char-stroke-order-pop");
+    if widgets::dropdown(ui, dr, &labels[doc.stroke_over_fill as usize], &t, egui::Id::unique("char-stroke-order")).clicked() && enabled {
+        widgets::open_popup(ui, pop);
+    }
+    app.auto.add("character.strokeOverFill", dr, "Stroke order");
+    if let Some(i) = widgets::popup_menu(ui, pop, dr.left_bottom(), &labels, Some(doc.stroke_over_fill as usize)) {
+        actions.push(json!({"strokeOverFill": i == 1}));
+    }
+}
+
 /// The Character panel for the text being edited (or the selected text layer): font family and
 /// style, size, leading, tracking, stroke, scale, baseline shift and other numeric fields, kerning,
 /// fill/stroke colours with eyedroppers, style toggles, OpenType features and variable-font axes.
@@ -122,76 +220,10 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         actions.push(json!({"style": st}));
     }
     y += 34.0;
-    // Numeric fields in AE's two-column grid (Kerning is a popup, at row 1 right).
-    let fields: [(&str, &str, f64, (f64, f64), &str); 10] = [
-        ("size", "T", doc.size, (1.0, 2000.0), " px"),
-        ("leading", "A", doc.leading.unwrap_or(doc.size * 1.2), (0.0, 5000.0), " px"),
-        ("", "", 0.0, (0.0, 0.0), ""),
-        ("tracking", "VA", doc.tracking, (-1000.0, 10000.0), ""),
-        ("strokeWidth", "W", doc.stroke_width, (0.0, 500.0), " px"),
-        ("", "", 0.0, (0.0, 0.0), ""),
-        ("vScale", "↕T", doc.v_scale, (1.0, 1000.0), " %"),
-        ("hScale", "↔T", doc.h_scale, (1.0, 1000.0), " %"),
-        ("baselineShift", "A↑", doc.baseline_shift, (-1000.0, 1000.0), " px"),
-        ("tsume", "Ts", doc.tsume, (0.0, 100.0), " %"),
-    ];
-    for (i, (key, glyph, v, range, suffix)) in fields.into_iter().enumerate() {
-        if key.is_empty() {
-            continue;
-        }
-        let col = i % 2;
-        let row = i / 2;
-        let fx = x0 + col as f32 * (w / 2.0);
-        let fy = y + row as f32 * 28.0;
-        p.text(pos2(fx, fy + 9.0), Align2::LEFT_CENTER, glyph, Tokens::semibold(11.0), t.text_dim);
-        let (r, nv, _) = widgets::hot_number_at(ui, pos2(fx + 26.0, fy), egui::Id::unique(("char", key)), v, 0.5, range, 0, suffix, &t);
-        app.auto.add(&format!("character.{key}"), r, key);
-        if let Some(nv) = nv
-            && enabled
-        {
-            actions.push(json!({key: nv, "merge": format!("char-{key}")}));
-        }
-    }
-    // Kerning: Metrics / Optical / a manual value (row 1, left).
-    {
-        let fy = y + 28.0;
-        p.text(pos2(x0, fy + 9.0), Align2::LEFT_CENTER, "V/A", Tokens::semibold(11.0), t.text_dim);
-        let label = match doc.kerning {
-            Kerning::Metrics => "Metrics".to_string(),
-            Kerning::Optical => "Optical".to_string(),
-            Kerning::Manual(v) => format!("{v:.0}"),
-        };
-        let kr = Rect::from_min_size(pos2(x0 + 26.0, fy), vec2((w / 2.0 - 32.0).max(50.0), 20.0));
-        let kpop = egui::Id::unique("char-kerning-pop");
-        if widgets::dropdown(ui, kr, &label, &t, egui::Id::unique("char-kerning")).clicked() && enabled {
-            widgets::open_popup(ui, kpop);
-        }
-        app.auto.add("character.kerning", kr, "Kerning");
-        let opts: Vec = ["Metrics", "Optical", "0", "-50", "-25", "-10", "10", "25", "50", "100"].iter().map(|s| s.to_string()).collect();
-        if let Some(i) = widgets::popup_menu(ui, kpop, kr.left_bottom(), &opts, None) {
-            let v = match i {
-                0 => json!("metrics"),
-                1 => json!("optical"),
-                _ => json!(opts[i].parse::().unwrap_or(0.0)),
-            };
-            actions.push(json!({"kerning": v}));
-        }
-    }
-    // Stroke Over Fill / Fill Over Stroke (row 2, right).
-    {
-        let fx = x0 + w / 2.0;
-        let fy = y + 2.0 * 28.0;
-        let dr = Rect::from_min_size(pos2(fx, fy), vec2(w / 2.0, 20.0));
-        let labels = vec!["Fill Over Stroke".to_string(), "Stroke Over Fill".to_string()];
-        let pop = egui::Id::unique("char-stroke-order-pop");
-        if widgets::dropdown(ui, dr, &labels[doc.stroke_over_fill as usize], &t, egui::Id::unique("char-stroke-order")).clicked() && enabled {
-            widgets::open_popup(ui, pop);
-        }
-        app.auto.add("character.strokeOverFill", dr, "Stroke order");
-        if let Some(i) = widgets::popup_menu(ui, pop, dr.left_bottom(), &labels, Some(doc.stroke_over_fill as usize)) {
-            actions.push(json!({"strokeOverFill": i == 1}));
-        }
-    }
+    let grid = Grid { x0, y, w, enabled };
+    numeric_fields(app, ui, &p, grid, &doc, &mut actions);
+    kerning_field(app, ui, &p, grid, &doc, &mut actions);
+    stroke_order_field(app, ui, grid, &doc, &mut actions);
     y += 5.0 * 28.0 + 8.0;
     // Fill / stroke swatches with their enable boxes.
     for (i, (key, label, on, c)) in [("fill", "Fill", doc.apply_fill, doc.fill), ("stroke", "Stroke", doc.apply_stroke, doc.stroke)].into_iter().enumerate() {
@@ -242,7 +274,7 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
     let bw = ((w - 5.0 * 4.0) / 6.0).clamp(22.0, 30.0);
     for (i, (key, label, on, bold)) in toggles.into_iter().enumerate() {
         let r = Rect::from_min_size(pos2(x0 + i as f32 * (bw + 4.0), y), vec2(bw, 24.0));
-        if toggle(app, ui, &p, r, &format!("character.{key}"), label, on, bold) && enabled {
+        if toggle(app, ui, &p, r, &format!("character.{key}"), (label, on, bold)) && enabled {
             actions.push(json!({key: !on}));
         }
     }
@@ -296,8 +328,14 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
             let tag = a.tag.trim_end().to_string();
             let v = doc.variations.iter().chain(&instance).find(|(t2, _)| t2.trim_end() == tag).map(|(_, v)| *v).unwrap_or(a.default);
             p.text(pos2(fx, fy + 9.0), Align2::LEFT_CENTER, &tag, Tokens::semibold(10.5), t.text_dim);
-            let (r, nv, _) =
-                widgets::hot_number_at(ui, pos2(fx + 34.0, fy), egui::Id::unique(("char-axis", &tag)), v as f64, 1.0, (a.min as f64, a.max as f64), 0, "", &t);
+            let (r, nv, _) = widgets::hot_number_at(
+                ui,
+                pos2(fx + 34.0, fy),
+                egui::Id::unique(("char-axis", &tag)),
+                v as f64,
+                &widgets::HotSpec { speed: 1.0, range: (a.min as f64, a.max as f64), decimals: 0, suffix: "" },
+                &t,
+            );
             app.auto.add(&format!("character.axis.{tag}"), r, &a.name);
             if let Some(nv) = nv
                 && enabled
@@ -517,7 +555,14 @@ pub fn paragraph(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         let fx = x0 + (i % 2) as f32 * (w / 2.0);
         let fy = y + (i / 2) as f32 * 28.0;
         p.text(pos2(fx, fy + 9.0), Align2::LEFT_CENTER, glyph, Tokens::semibold(11.0), t.text_dim);
-        let (r, nv, _) = widgets::hot_number_at(ui, pos2(fx + 26.0, fy), egui::Id::unique(("para-f", key)), v, 0.5, (-5000.0, 5000.0), 0, " px", &t);
+        let (r, nv, _) = widgets::hot_number_at(
+            ui,
+            pos2(fx + 26.0, fy),
+            egui::Id::unique(("para-f", key)),
+            v,
+            &widgets::HotSpec { speed: 0.5, range: (-5000.0, 5000.0), decimals: 0, suffix: " px" },
+            &t,
+        );
         app.auto.add(&format!("paragraph.{key}"), r, key);
         if let Some(nv) = nv
             && enabled
diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs
index ea18c7f1e..a45d8e708 100644
--- a/crates/ui-egui/src/panels/timeline.rs
+++ b/crates/ui-egui/src/panels/timeline.rs
@@ -15,6 +15,7 @@ use effectcraft_engine::render::EvalCtx;
 use effectcraft_engine::time::Tick;
 use egui::{Align2, Color32, Pos2, Rect, Sense, Stroke, StrokeKind, pos2, vec2};
 use serde_json::json;
+use std::sync::Arc;
 
 use crate::i18n::tr;
 use crate::icons::{self, Icon};
@@ -169,19 +170,24 @@ fn pick_whip_id() -> egui::Id {
     egui::Id::unique("tl-pickwhip")
 }
 
+/// The automation id, label and hover hint of a pick whip.
+struct WhipText<'a> {
+    auto: &'a str,
+    label: &'a str,
+    hint: &'a str,
+}
+
 /// A pick whip at `rect`: dragging it starts a pick whip of `kind` from `layer`'s `prop` (0 for
 /// the layer's own), drawn and dropped at the end of [`show`].
-#[allow(clippy::too_many_arguments)]
 fn pick_whip(
     app: &mut EffectcraftApp,
     ui: &egui::Ui,
     p: &egui::Painter,
     rect: Rect,
     (kind, layer, prop): (u8, u64, u64),
-    auto: &str,
-    label: &str,
-    hint: &str,
+    text: WhipText<'_>,
 ) -> egui::Response {
+    let WhipText { auto, label, hint } = text;
     let resp = ui.interact(rect, egui::Id::unique(("pick-whip", kind, layer, prop)), Sense::drag()).on_hover_text(hint);
     icons::paint(p, rect.shrink(1.0), Icon::PickWhip, if resp.hovered() || resp.dragged() { app.tokens.text } else { app.tokens.text_dim });
     app.auto.add(auto, rect, label);
@@ -1415,12 +1421,9 @@ fn mode_targets(comp: &Comp, layer: LayerId, selected: &[LayerId]) -> Vec {
     edited_layers(layer, selected).into_iter().filter(|l| comp.layer(LayerId(*l)).is_some_and(|l| l.source.is_av())).collect()
 }
 
-/// A layer's switch (A/V Features and Switches columns) in `row`, with the
-/// [`switch_gesture`].
-#[allow(clippy::too_many_arguments)]
-fn layer_switch(
-    ui: &mut egui::Ui,
-    t: &Tokens,
+/// Where and what a layer switch is: its cell, icon, state and the switch it sets.
+#[derive(Clone, Copy)]
+struct SwitchSpec {
     row: Rect,
     br: Rect,
     icon: Icon,
@@ -1429,11 +1432,20 @@ fn layer_switch(
     layer: u64,
     name: &'static str,
     others: bool,
+}
+
+/// A layer's switch (A/V Features and Switches columns) in `row`, with the
+/// [`switch_gesture`].
+fn layer_switch(
+    ui: &mut egui::Ui,
+    t: &Tokens,
+    spec: &SwitchSpec,
     comp: &Comp,
     project: &Project,
     selected: &[LayerId],
     actions: &mut Vec<(String, serde_json::Value)>,
 ) {
+    let SwitchSpec { row, br, icon, on, id, layer, name, others } = *spec;
     let resp = widgets::icon_toggle(ui, br, icon, on, t, id, Sense::click_and_drag());
     // Alt-click on an A/V feature sets the layer apart: the other layers go off (or locked),
     // and come back on the next Alt-click. With several layers selected, the whole selection is
@@ -1516,54 +1528,108 @@ fn replace_source_target(app: &EffectcraftApp, cid: ItemId, item: ItemId, lid: L
             .is_some_and(|l| !l.switches.locked && matches!(l.source, LayerSource::Footage { .. } | LayerSource::Comp { .. } | LayerSource::Solid { .. }))
 }
 
-/// The Timeline panel for the active composition inside `rect`: draws the header, ruler, work
-/// area and cache bars, the layer rows with their switch and value cells, the time graph and its
-/// keys, and the footer; and turns this frame's clicks, drags and drops into commands. Without an
-/// active comp it shows a hint, and dropping footage on it creates a composition. Most edits
-/// use [`crate::menus::invoke`]; time, switch and work-area gestures call `Session::execute`
-/// directly and ignore errors. Reported failures go to `app.ui.status`. UI state (scroll, zoom,
-/// open rows and the drags in progress) lives in
-/// `app.ui.timeline` and egui's temp data.
-pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
+/// The frame's layout and state shared by the Timeline's drawing steps.
+struct Tl {
+    t: Tokens,
+    cid: ItemId,
+    time: Tick,
+    fr: effectcraft_engine::time::FrameRate,
+    rect: Rect,
+    header_h: f32,
+    colhdr_h: f32,
+    footer_h: f32,
+    vis: Vis,
+    left_w: f32,
+    overflow: f32,
+    oscroll: f32,
+    cw: Cols,
+    graph_x0: f32,
+    graph_x1: f32,
+    fit_pps: f64,
+    pps: f64,
+    scroll_to: Option,
+    tm: TMap,
+    top: f32,
+    rows_rect: Rect,
+    ruler: Rect,
+    bin: Rect,
+    tc_rect: Rect,
+    wa_row: Rect,
+    fd: f64,
+    rh: f32,
+    max_scroll: f32,
+    ctx: egui::Context,
+    comp: Arc,
+    p: egui::Painter,
+    rows: Arc>,
+    actions: Vec<(String, serde_json::Value)>,
+}
+
+/// [`Tl`] plus what the layer rows and the interactions after them share.
+struct Rw {
+    tl: Tl,
+    lp: egui::Painter,
+    gp: egui::Painter,
+    graph_on: bool,
+    full_clip: Rect,
+    left_clip: Rect,
+    right_clip: Rect,
+    empty_rect: Rect,
+    outline_rect: Rect,
+    empty: egui::Response,
+    outline_empty: egui::Response,
+    selected: Vec,
+    sel_keys: Vec,
+    ui_actions: Vec,
+    hit_rows: Vec<(Rect, Row)>,
+    dim_hits: Vec<(u64, Vec)>,
+}
+
+/// One visible row: what the per-kind drawing steps need to know about it.
+struct RowCtx<'a> {
+    row: &'a Row,
+    layer: &'a Layer,
+    r: Rect,
+    left: Rect,
+    cy: f32,
+    is_sel: bool,
+    ri: usize,
+    indent: f32,
+}
+
+/// The responses of a layer bar's body and its two trim edges.
+struct BarResps {
+    body: egui::Response,
+    lin: egui::Response,
+    lout: egui::Response,
+}
+
+/// An expression row's property, expression and editor ids.
+#[derive(Clone, Copy)]
+struct ExprRow<'a> {
+    prop: &'a Property,
+    ex: &'a effectcraft_engine::project::Expression,
+    buf_id: egui::Id,
+    edit_id: egui::Id,
+}
+
+/// A keyframe marker's hit result.
+struct KeyHit<'a> {
+    k: &'a effectcraft_engine::keyframe::Keyframe,
+    ct: Tick,
+    ks: bool,
+    kresp: egui::Response,
+    mods: egui::Modifiers,
+    this_key: serde_json::Value,
+}
+
+/// Computes this frame's layout: zoom, scroll and the panel's regions.
+fn layout(app: &mut EffectcraftApp, ui: &egui::Ui, rect: Rect, cid: ItemId, comp: &Arc) -> Tl {
     let t = app.tokens;
     let ctx = ui.ctx().clone();
-    let Some(cid) = app.session.active_comp_id() else {
-        ui.painter().text(rect.center(), Align2::CENTER_CENTER, "(no composition)", Tokens::ui(12.0), t.text_faint);
-        // Dropping footage into an empty Timeline should create its first composition,
-        // just like dropping onto the Project panel's New Composition button (#649).
-        if let Some(crate::panels::DragPayload::Item(dragged)) = egui::DragAndDrop::payload::(&ctx).as_deref()
-            && ui.rect_contains_pointer(rect)
-        {
-            let items: Vec = crate::panels::project_drop_items(app, *dragged)
-                .into_iter()
-                .map(ItemId)
-                .filter(|id| {
-                    app.session.project.item(*id).is_some_and(|item| match &item.kind {
-                        effectcraft_engine::project::ItemKind::Footage(footage) => footage.kind != effectcraft_engine::project::FootageKind::Data,
-                        effectcraft_engine::project::ItemKind::Solid(_) | effectcraft_engine::project::ItemKind::Comp(_) => true,
-                        _ => false,
-                    })
-                })
-                .collect();
-            if !items.is_empty() {
-                ui.painter().rect_stroke(rect.shrink(2.0), 0.0, Stroke::new(2.0, t.accent), StrokeKind::Inside);
-                app.auto.add("timeline.newCompDrop", rect, "Drop footage to create a composition");
-                if ctx.input(|i| i.pointer.any_released()) {
-                    app.session.state.project_selection = items;
-                    if let Err(e) = crate::menus::invoke(app, &ctx, "file.newCompFromSelection", json!({})) {
-                        app.ui.status = e;
-                    }
-                    egui::DragAndDrop::clear_payload(&ctx);
-                }
-            }
-        }
-        return;
-    };
-    let Some(comp) = app.session.project.comp_arc(cid) else { return };
     let p = ui.painter().clone();
     let time = app.session.time();
     let fr = comp.frame_rate;
-
     // Geometry.
     let header_h = 48.0;
     let ruler_h = 34.0;
@@ -1581,7 +1647,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
     let graph_x0 = rect.min.x + left_w + 1.0;
     let graph_x1 = rect.max.x - GUTTER_W;
     ctx.data_mut(|d| d.insert_temp(egui::Id::unique("timeline-graph-area"), (graph_x0, graph_x1 - graph_x0)));
-    let fit_pps = fit_pps(graph_x1 - graph_x0, &comp);
+    let fit_pps = fit_pps(graph_x1 - graph_x0, comp);
     let pps = app.ui.timeline.pps.unwrap_or(fit_pps);
     if app.ui.timeline.pps.is_none() {
         app.ui.timeline.start = 0.0;
@@ -1605,17 +1671,434 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
     let top = rect.min.y;
     let rows_top = top + header_h + colhdr_h;
     let rows_rect = Rect::from_min_max(pos2(rect.min.x, rows_top), pos2(rect.max.x, rect.max.y - footer_h));
-    let graph_rect = Rect::from_min_max(pos2(graph_x0, top + header_h - ruler_h + colhdr_h), pos2(rect.max.x, rect.max.y - footer_h));
     p.rect_filled(Rect::from_min_max(pos2(graph_x0, top), rect.max), 0.0, t.tl_bg);
     p.line_segment([pos2(graph_x0 - 1.0, top), pos2(graph_x0 - 1.0, rect.max.y)], Stroke::new(1.0, t.app_bg));
 
-    let mut actions: Vec<(String, serde_json::Value)> = Vec::new();
-    if !ui.input(|i| i.pointer.primary_down()) {
-        ctx.data_mut(|d| d.remove::(switch_drag_id()));
-    }
-    // ---- header (left): time display, search, switches.
-    let tc = crate::panels::timecode(&app.session, &comp, time);
+    let ruler = Rect::from_min_max(pos2(graph_x0, top + header_h - ruler_h), pos2(rect.max.x, top + header_h));
+    // The composition marker bin, at the right end of the ruler above the layers' scroll bar.
+    let bin = Rect::from_min_max(pos2(graph_x1 + 2.0, ruler.max.y - 16.0), pos2(rect.max.x - 2.0, ruler.max.y - 2.0));
     let tc_rect = Rect::from_min_size(pos2(rect.min.x + 12.0, top + 6.0), vec2(150.0, 24.0));
+    let wa_row = Rect::from_min_max(pos2(graph_x0, top + header_h), pos2(rect.max.x, top + header_h + colhdr_h));
+    let fd = comp.frame_duration().seconds();
+    Tl {
+        t,
+        cid,
+        time,
+        fr,
+        rect,
+        header_h,
+        colhdr_h,
+        footer_h,
+        vis,
+        left_w,
+        overflow,
+        oscroll,
+        cw,
+        graph_x0,
+        graph_x1,
+        fit_pps,
+        pps,
+        scroll_to,
+        tm,
+        top,
+        rows_rect,
+        ruler,
+        bin,
+        tc_rect,
+        wa_row,
+        fd,
+        rh: t.row_h,
+        max_scroll: 0.0,
+        ctx,
+        comp: comp.clone(),
+        p,
+        rows: Arc::new(Vec::new()),
+        actions: Vec::new(),
+    }
+}
+
+/// Without an active composition: a hint, and dropping footage creates the first composition.
+fn empty_timeline(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
+    let t = app.tokens;
+    let ctx = ui.ctx().clone();
+    ui.painter().text(rect.center(), Align2::CENTER_CENTER, "(no composition)", Tokens::ui(12.0), t.text_faint);
+    // Dropping footage into an empty Timeline should create its first composition,
+    // just like dropping onto the Project panel's New Composition button (#649).
+    if let Some(crate::panels::DragPayload::Item(dragged)) = egui::DragAndDrop::payload::(&ctx).as_deref()
+        && ui.rect_contains_pointer(rect)
+    {
+        let items: Vec = crate::panels::project_drop_items(app, *dragged)
+            .into_iter()
+            .map(ItemId)
+            .filter(|id| {
+                app.session.project.item(*id).is_some_and(|item| match &item.kind {
+                    effectcraft_engine::project::ItemKind::Footage(footage) => footage.kind != effectcraft_engine::project::FootageKind::Data,
+                    effectcraft_engine::project::ItemKind::Solid(_) | effectcraft_engine::project::ItemKind::Comp(_) => true,
+                    _ => false,
+                })
+            })
+            .collect();
+        if !items.is_empty() {
+            ui.painter().rect_stroke(rect.shrink(2.0), 0.0, Stroke::new(2.0, t.accent), StrokeKind::Inside);
+            app.auto.add("timeline.newCompDrop", rect, "Drop footage to create a composition");
+            if ctx.input(|i| i.pointer.any_released()) {
+                app.session.state.project_selection = items;
+                if let Err(e) = crate::menus::invoke(app, &ctx, "file.newCompFromSelection", json!({})) {
+                    app.ui.status = e;
+                }
+                egui::DragAndDrop::clear_payload(&ctx);
+            }
+        }
+    }
+}
+
+/// Starts the rows pass: the clipped painters and the background responses.
+fn begin_rows(app: &mut EffectcraftApp, ui: &mut egui::Ui, tl: Tl) -> Rw {
+    let rows_rect = tl.rows_rect;
+    let graph_x0 = tl.graph_x0;
+    let rect = tl.rect;
+    let lp = tl.p.with_clip_rect(rows_rect.intersect(Rect::from_min_max(rows_rect.min, pos2(graph_x0 - 1.0, rows_rect.max.y))));
+    let gp = tl.p.with_clip_rect(rows_rect.intersect(Rect::from_min_max(pos2(graph_x0, rows_rect.min.y), rows_rect.max)));
+    let graph_on = app.ui.timeline.graph_editor;
+    super::graph::track_open(ui.ctx(), graph_on);
+    let full_clip = ui.clip_rect();
+    let left_clip = full_clip.intersect(Rect::from_min_max(pos2(rect.min.x, rows_rect.min.y), pos2(graph_x0 - 1.0, rows_rect.max.y)));
+    let right_clip = full_clip.intersect(Rect::from_min_max(pos2(graph_x0, rows_rect.min.y), rows_rect.max));
+    // Registered before the rows so keys, bars and editors on top of it get the pointer first.
+    let empty_rect = if graph_on { Rect::NOTHING } else { Rect::from_min_max(pos2(graph_x0, rows_rect.min.y), rows_rect.max) };
+    let empty = ui.interact(empty_rect, egui::Id::unique("tl-graph-bg"), Sense::click_and_drag());
+    let outline_rect = Rect::from_min_max(rows_rect.min, pos2(graph_x0 - 1.0, rows_rect.max.y));
+    let outline_empty = ui.interact(outline_rect, egui::Id::unique("tl-layer-bg"), Sense::click_and_drag());
+    app.auto.add("timeline.layerMarquee", outline_rect, "Drag empty outline to select layers");
+    Rw {
+        lp,
+        gp,
+        graph_on,
+        full_clip,
+        left_clip,
+        right_clip,
+        empty_rect,
+        outline_rect,
+        empty,
+        outline_empty,
+        selected: app.session.state.selected_layers.clone(),
+        sel_keys: app.session.state.selected_keys.clone(),
+        ui_actions: Vec::new(),
+        hit_rows: Vec::new(),
+        dim_hits: Vec::new(),
+        tl,
+    }
+}
+
+/// The Timeline panel for the active composition inside `rect`: draws the header, ruler, work
+/// area and cache bars, the layer rows with their switch and value cells, the time graph and its
+/// keys, and the footer; and turns this frame's clicks, drags and drops into commands. Without an
+/// active comp it shows a hint, and dropping footage on it creates a composition. Most edits
+/// use [`crate::menus::invoke`]; time, switch and work-area gestures call `Session::execute`
+/// directly and ignore errors. Reported failures go to `app.ui.status`. UI state (scroll, zoom,
+/// open rows and the drags in progress) lives in
+/// `app.ui.timeline` and egui's temp data.
+pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
+    let Some(cid) = app.session.active_comp_id() else {
+        empty_timeline(app, ui, rect);
+        return;
+    };
+    let Some(comp) = app.session.project.comp_arc(cid) else { return };
+    let mut tl = layout(app, ui, rect, cid, &comp);
+    let g = &mut tl;
+    if !ui.input(|i| i.pointer.primary_down()) {
+        g.ctx.data_mut(|d| d.remove::(switch_drag_id()));
+    }
+    header_timecode(app, ui, g);
+    header_search_and_switches(app, ui, g);
+    time_navigator(app, ui, g);
+    ruler_background(app, g);
+    work_area_bar(app, ui, g);
+    cache_bars(app, g);
+    ruler_ticks(app, g);
+    ruler_scrub(app, ui, g);
+    column_header_icons(app, g);
+    column_header_interact(app, ui, g);
+    let rows = Arc::new(with_expr_rows(build_rows(app, &comp), &comp, &app.ui.timeline, &g.ctx));
+    let total_h: f32 = rows.iter().map(|r| row_height(r, g.rh)).sum();
+    g.max_scroll = (total_h - g.rows_rect.height() + g.rh).max(0.0);
+    g.rows = rows;
+    reveal_scroll(app, g);
+    wheel_zoom_scroll(app, ui, g);
+    pick_whip_edge_scroll(app, g);
+    let snap_project = app.session.project.clone();
+    let snap_expr = app.session.expr.clone();
+    let ectx = EvalCtx { project: &snap_project, comp_id: cid, comp: &comp, time: g.time, expr: snap_expr.as_deref(), footage: None };
+    let mut rw = begin_rows(app, ui, tl);
+    let g = &mut rw;
+    draw_rows(app, ui, g, &ectx);
+    ui.set_clip_rect(g.full_clip);
+    background_menus(app, ui, g);
+    outline_marquee(ui, g);
+    pick_whip_drag(g);
+    alt_scale_and_graph(app, ui, g, &ectx);
+    graph_empty_area(ui, g);
+    layer_reorder_drag(ui, g);
+    quick_offset_follow(g);
+    key_drag_follow(app, g);
+    key_box_select(app, ui, g);
+    current_time_indicator(app, g);
+    let footer = footer_bar(app, ui, g);
+    footer_zoom(app, ui, g, footer);
+    comp_markers_ui(app, ui, g);
+    hand_tool_pan(app, ui, g);
+    drop_targets(app, ui, g);
+    let Rw { tl, ui_actions, .. } = rw;
+    apply_ui_acts(app, &tl.comp, ui_actions);
+    apply_actions(app, ui, &tl.ctx, tl.actions);
+}
+
+/// Applies the UI-only changes the frame collected (twirls, time, ending an undo merge).
+fn apply_ui_acts(app: &mut EffectcraftApp, comp: &Comp, ui_actions: Vec) {
+    for a in ui_actions {
+        match a {
+            UiAct::ToggleLayer(id, all) => {
+                let tl = &mut app.ui.timeline;
+                // Twirled by hand, the layer shows its whole tree again.
+                tl.reveal.clear();
+                tl.layer_reveal.remove(&id);
+                tl.full_groups.remove(&id);
+                let open = !tl.open_layers.contains(&id);
+                if open {
+                    tl.open_layers.insert(id);
+                } else {
+                    tl.open_layers.remove(&id);
+                }
+                if all && let Some(l) = comp.layer(LayerId(id)) {
+                    let mut uids = vec![];
+                    walk_groups(&l.props, &mut uids);
+                    for u in uids {
+                        if open {
+                            tl.open_groups.insert(u);
+                        } else {
+                            tl.open_groups.remove(&u);
+                        }
+                    }
+                }
+            }
+            UiAct::ToggleGroup(layer, uid, all) => {
+                let tl = &mut app.ui.timeline;
+                tl.toggle_group(layer, uid);
+                let open = tl.open_groups.contains(&uid);
+                if all && let Some(g) = comp.layer(LayerId(layer)).and_then(|l| find_group(&l.props, uid)) {
+                    let mut uids = vec![];
+                    walk_groups(g, &mut uids);
+                    for u in uids {
+                        if open {
+                            tl.open_groups.insert(u);
+                        } else {
+                            tl.open_groups.remove(&u);
+                        }
+                    }
+                }
+            }
+            UiAct::SetTime(tt) => app.session.set_time(tt),
+            UiAct::EndMerge => app.session.history.merge_key = None,
+            UiAct::ChangeRenderer => {
+                if super::dialogs::open_comp_settings(app).is_ok() {
+                    app.dialog_state.comp.tab = super::comp_settings::Tab::Renderer;
+                }
+            }
+        }
+    }
+}
+
+/// Runs the commands the frame collected through [`crate::menus::invoke`].
+fn apply_actions(app: &mut EffectcraftApp, ui: &mut egui::Ui, ctx: &egui::Context, actions: Vec<(String, serde_json::Value)>) {
+    if ui.input(|i| i.pointer.primary_down()) && actions.iter().any(|(id, _)| id == "prop.set") {
+        app.ui.viewer.property_interacting = true;
+        ctx.request_repaint();
+    }
+    for (id, params) in actions {
+        if id == "__endMerge" {
+            app.session.history.merge_key = None;
+            continue;
+        }
+        if let Err(e) = crate::menus::invoke(app, ctx, &id, params) {
+            app.ui.status = e;
+        }
+    }
+}
+
+/// Draws the visible rows, top to bottom, each by its kind.
+fn draw_rows(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, ectx: &EvalCtx<'_>) {
+    let comp = g.tl.comp.clone();
+    let rows = g.tl.rows.clone();
+    let (rect, rows_rect, rh, cw_name) = (g.tl.rect, g.tl.rows_rect, g.tl.rh, g.tl.cw.name);
+    let mut y = rows_rect.min.y - app.ui.timeline.scroll_y;
+    // Rows stripe by their index among the rows that are not expression editors, so adding or
+    // removing an expression leaves every other row's stripe alone; an editor takes the index
+    // (and so the stripe) of its property's row, the one just above.
+    let mut striped = 0usize;
+    for row in rows.iter() {
+        let ri = if matches!(row.kind, RowKind::Expr { .. }) {
+            striped.saturating_sub(1)
+        } else {
+            striped += 1;
+            striped - 1
+        };
+        // Outline widgets never spill into the time graph (narrow timelines).
+        ui.set_clip_rect(g.left_clip);
+        let hh = row_height(row, rh);
+        let r = Rect::from_min_size(pos2(rect.min.x, y), vec2(rect.width(), hh));
+        y += hh;
+        if r.max.y < rows_rect.min.y || r.min.y > rows_rect.max.y {
+            continue;
+        }
+        g.hit_rows.push((r, row.clone()));
+        let Some(layer) = comp.layer(row.layer) else { continue };
+        let rc = RowCtx {
+            row,
+            layer,
+            r,
+            left: Rect::from_min_max(r.min, pos2(g.tl.graph_x0 - 1.0, r.max.y)),
+            cy: r.min.y + rh / 2.0,
+            is_sel: g.selected.contains(&layer.id),
+            ri,
+            indent: cw_name + 6.0 + 14.0 * row.depth as f32,
+        };
+        match &row.kind {
+            RowKind::Layer => layer_row(app, ui, g, &rc),
+            RowKind::Group { .. } => group_row(app, ui, g, &rc),
+            RowKind::Waveform { .. } => waveform_row(app, g, &rc),
+            RowKind::Expr { .. } => expr_row(app, ui, g, &rc),
+            RowKind::Prop { .. } => prop_row(app, ui, g, &rc, ectx),
+        }
+    }
+}
+
+/// A layer row: switches, label, name, cells and the layer bar.
+fn layer_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>) {
+    layer_row_background(g, rc);
+    layer_av_switches(app, ui, g, rc);
+    layer_label_swatch(app, ui, g, rc);
+    layer_number_and_cells(app, ui, g, rc);
+    let (row_resp, name_x) = layer_twirl_and_icon(app, ui, g, rc);
+    layer_name(app, ui, g, rc, name_x);
+    layer_row_actions(app, ui, g, rc, &row_resp);
+    layer_switches(app, ui, g, rc);
+    if app.ui.timeline.show_modes && rc.layer.source.is_av() {
+        layer_mode_cell(app, ui, g, rc);
+        layer_matte_cell(app, ui, g, rc);
+    }
+    if g.tl.vis.parent {
+        layer_parent_cell(app, ui, g, rc);
+    }
+    ui.set_clip_rect(g.right_clip);
+    if !g.graph_on {
+        layer_bar(app, ui, g, rc);
+    }
+}
+
+/// The layer's duration bar: source extent, move and trim, markers.
+fn layer_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>) {
+    let (bar, lc) = layer_bar_paint(g, rc);
+    layer_source_extent(app, ui, g, rc, bar, lc);
+    let bars = layer_bar_hits(app, ui, g, rc, bar);
+    let quick = layer_quick_offset_begin(ui, g, rc, &bars.body);
+    layer_bar_drag(g, rc, &bars, quick);
+    layer_bar_markers(app, ui, g, rc);
+}
+
+/// A group row (effect, mask, shape group, layer style).
+fn group_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>) {
+    let shown = group_background(app, g, rc);
+    group_switches(app, ui, g, rc);
+    group_name(app, ui, g, rc, &shown);
+    group_popups(app, ui, g, rc);
+    group_mask_mode(ui, g, rc);
+    group_row_click(app, ui, g, rc);
+}
+
+/// An expression row: its controls and the editor.
+fn expr_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>) {
+    let RowKind::Expr { uid, .. } = &rc.row.kind else { return };
+    let Some(prop) = rc.layer.props.find(*uid) else { return };
+    let Some(ex) = prop.expr.as_ref() else { return };
+    let st = ExprRow { prop, ex, buf_id: expr_buf_id(*uid), edit_id: egui::Id::unique(("expr-edit", uid)) };
+    expr_controls(app, ui, g, rc, &st);
+    expr_language_menu(app, ui, g, rc, &st);
+    expr_editor_cell(app, ui, g, rc, &st);
+}
+
+/// A property row: keyframe navigator, stopwatch, name, value editors and the keyframes.
+fn prop_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>, ectx: &EvalCtx<'_>) {
+    let RowKind::Prop { uid } = &rc.row.kind else { return };
+    let Some(prop) = rc.layer.props.find(*uid) else { return };
+    prop_background(app, g, rc, prop);
+    prop_key_navigator(app, ui, g, rc, prop);
+    prop_stopwatch(app, ui, g, rc, prop);
+    let name_x = prop_name(app, ui, g, rc, prop);
+    prop_value_cell(app, ui, g, rc, ectx, prop, name_x);
+    prop_expression_hint(app, ui, g, rc, prop);
+    if !g.graph_on {
+        for k in &prop.keys {
+            key_marker(app, ui, g, rc, prop, k);
+        }
+    }
+}
+
+/// One keyframe marker: its look, selection, menu and drag.
+fn key_marker(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>, prop: &Property, k: &effectcraft_engine::keyframe::Keyframe) {
+    let RowKind::Prop { uid } = &rc.row.kind else { return };
+    let ct = rc.layer.comp_time(k.time);
+    let x = g.tl.tm.x(ct.seconds());
+    if x < g.tl.graph_x0 - 8.0 || x > g.tl.rect.max.x + 8.0 {
+        return;
+    }
+    let kref = effectcraft_engine::KeyRef { layer: rc.layer.id, prop: *uid, time: k.time };
+    let ks = g.sel_keys.contains(&kref);
+    let kr = key_paint(g, rc, k, x, ks);
+    let kresp = key_hit(app, ui, g, rc, prop, k, kr);
+    let mods = ui.input(|i| i.modifiers);
+    let this_key = json!({"keys": [{"layer": rc.layer.id.0, "prop": uid, "time": k.time.seconds()}], "selectProperties": true});
+    let kh = KeyHit { k, ct, ks, kresp, mods, this_key };
+    key_select(g, &kh);
+    key_double_click(g, rc, prop, &kh);
+    key_context_menu(app, g, rc, prop, &kh);
+    key_drag_begin(app, g, &kh);
+}
+
+/// Drop targets: effects and presets on a layer, footage and files into the stack.
+fn drop_targets(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw) {
+    let ctx = g.tl.ctx.clone();
+    let rows_rect = g.tl.rows_rect;
+    if let Some(payload) = egui::DragAndDrop::payload::(&ctx)
+        && !matches!(*payload, crate::panels::DragPayload::Property { .. })
+        && let Some(ptr) = ctx.input(|i| i.pointer.hover_pos()).filter(|_| ui.rect_contains_pointer(rows_rect))
+    {
+        let released = ctx.input(|i| i.pointer.any_released());
+        // Alt/Option-drag from the Project panel replaces the source of the layer under the
+        // pointer (After Effects). The shortcut replaces every selected layer instead.
+        let alt_item = ctx.input(|i| i.modifiers.alt) && matches!(payload.as_ref(), crate::panels::DragPayload::Item(_));
+        if alt_item {
+            drop_replace_source(app, g, &payload, ptr, released);
+        } else if matches!(payload.as_ref(), crate::panels::DragPayload::Effect(_) | crate::panels::DragPayload::Preset { .. }) {
+            drop_on_layer(app, g, &payload, ptr, released);
+        } else {
+            drop_insert_layers(app, g, &payload, ptr, released);
+        }
+        if released {
+            egui::DragAndDrop::clear_payload(&ctx);
+        }
+    }
+}
+
+/// The header's time display: click to switch style, drag to scrub, double-click to type a time.
+fn header_timecode(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &Tl) {
+    let t = g.t;
+    let time = g.time;
+    let fr = g.fr;
+    let tc_rect = g.tc_rect;
+    let ctx = g.ctx.clone();
+    let comp = g.comp.clone();
+    let p = g.p.clone();
+    let tc = crate::panels::timecode(&app.session, &comp, time);
     let tc_resp = ui.interact(tc_rect, egui::Id::unique("tl-timecode"), Sense::click_and_drag());
     p.text(tc_rect.left_center(), Align2::LEFT_CENTER, &tc, Tokens::semibold(16.0), t.timecode);
     // Under it the other style (frames under timecode, timecode under frames) and the rate.
@@ -1657,6 +2140,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
             ctx.data_mut(|d| d.insert_temp(egui::Id::unique("tl-tc-edit"), buf));
         }
     }
+}
+
+/// The header's search field and the composition switches.
+fn header_search_and_switches(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &Tl) {
+    let t = g.t;
+    let rect = g.rect;
+    let left_w = g.left_w;
+    let top = g.top;
+    let tc_rect = g.tc_rect;
+    let comp = g.comp.clone();
     let sr = Rect::from_min_size(pos2(tc_rect.max.x + 14.0, top + 12.0), vec2((left_w - 420.0).clamp(120.0, 220.0), 22.0));
     let mut search = app.ui.timeline.search.clone();
     widgets::search_field(ui, sr, &mut search, "Search", &t);
@@ -1685,11 +2178,20 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         }
         bx -= 28.0;
     }
+}
 
-    // ---- ruler (right).
-    let ruler = Rect::from_min_max(pos2(graph_x0, top + header_h - ruler_h), pos2(rect.max.x, top + header_h));
-    // The composition marker bin, at the right end of the ruler above the layers' scroll bar.
-    let bin = Rect::from_min_max(pos2(graph_x1 + 2.0, ruler.max.y - 16.0), pos2(rect.max.x - 2.0, ruler.max.y - 2.0));
+/// The time navigator: the visible span as a bar with end handles.
+fn time_navigator(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &Tl) {
+    let t = g.t;
+    let graph_x0 = g.graph_x0;
+    let graph_x1 = g.graph_x1;
+    let fit_pps = g.fit_pps;
+    let pps = g.pps;
+    let tm = g.tm;
+    let top = g.top;
+    let ctx = g.ctx.clone();
+    let comp = g.comp.clone();
+    let p = g.p.clone();
     let nav = Rect::from_min_max(pos2(graph_x0 + 6.0, top + 6.0), pos2(graph_x1, top + 14.0));
     // Time navigator: the visible span as a grey bar with blue end handles (as in After
     // Effects), over a dark track.
@@ -1730,12 +2232,31 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
             set_zoom(&mut app.ui.timeline, &comp, span_px / span.max(min_span), fit_pps, keep_t, keep_px);
         }
     }
+}
+
+fn ruler_background(app: &mut EffectcraftApp, g: &Tl) {
+    let t = g.t;
+    let tm = g.tm;
+    let ruler = g.ruler;
+    let p = g.p.clone();
     p.rect_filled(ruler, 0.0, t.tl_ruler_bg);
     app.auto.add("timeline.ruler", ruler, &format!("{},{}", tm.start, tm.pps));
+}
+
+/// The work area bar beside the column headers, with its begin / end handles.
+fn work_area_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Tl) {
+    let t = g.t;
+    let fr = g.fr;
+    let pps = g.pps;
+    let tm = g.tm;
+    let wa_row = g.wa_row;
+    let ctx = g.ctx.clone();
+    let comp = g.comp.clone();
+    let p = g.p.clone();
+    let actions = &mut g.actions;
     // Work area bar: below the ruler, beside the column headers (After Effects' layout), with
     // blue begin / end handles; the cache bar runs under it. Dragging the bar moves the work
     // area, the handles move its ends; Shift snaps (#252).
-    let wa_row = Rect::from_min_max(pos2(graph_x0, top + header_h), pos2(rect.max.x, top + header_h + colhdr_h));
     let (wa0, wa1) = (comp.work_area.0.seconds(), comp.work_area.1.seconds());
     let wa = Rect::from_min_max(pos2(tm.x(wa0), wa_row.min.y + 3.0), pos2(tm.x(wa1), wa_row.max.y - 6.0));
     p.with_clip_rect(wa_row).rect_filled(wa, 0.0, t.tl_work_area_bar);
@@ -1791,10 +2312,20 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
             let _ = app.session.execute("comp.workArea", json!({key: secs, "merge": "wa-drag"}));
         }
     }
+}
+
+/// The RAM (green) and disk (blue) cache bars under the work area.
+fn cache_bars(app: &mut EffectcraftApp, g: &Tl) {
+    let t = g.t;
+    let cid = g.cid;
+    let tm = g.tm;
+    let wa_row = g.wa_row;
+    let fd = g.fd;
+    let comp = g.comp.clone();
+    let p = g.p.clone();
     // Cache bar (green: cached frames of what the viewer shows: its resolution, view and options).
     let series = app.shown_series(cid);
     let cached = app.frames.cached_frames(&series);
-    let fd = comp.frame_duration().seconds();
     let cy0 = wa_row.max.y - 4.0;
     let mut run: Option<(i64, i64)> = None;
     let draw_run = |a: i64, b: i64| {
@@ -1843,6 +2374,20 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
             draw_blue(a, b);
         }
     }
+}
+
+/// The ruler's tick marks and labels.
+fn ruler_ticks(app: &mut EffectcraftApp, g: &Tl) {
+    let t = g.t;
+    let fr = g.fr;
+    let graph_x0 = g.graph_x0;
+    let graph_x1 = g.graph_x1;
+    let pps = g.pps;
+    let tm = g.tm;
+    let ruler = g.ruler;
+    let bin = g.bin;
+    let fd = g.fd;
+    let p = g.p.clone();
     // 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;
@@ -1871,6 +2416,17 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         }
         s += secs_per_label;
     }
+}
+
+/// Scrubbing in the ruler: the current time follows the pointer.
+fn ruler_scrub(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &Tl) {
+    let graph_x0 = g.graph_x0;
+    let graph_x1 = g.graph_x1;
+    let pps = g.pps;
+    let tm = g.tm;
+    let ruler = g.ruler;
+    let bin = g.bin;
+    let comp = g.comp.clone();
     // Scrub in the ruler: the current time jumps to the pointer as soon as the button goes down
     // (After Effects), then follows it.
     let rresp = ui.interact(
@@ -1929,8 +2485,19 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
             app.scrub_audio(cid, t, now);
         }
     }
+}
 
-    // ---- column headers (right-click: show/hide columns; click the name header: Source/Layer Name).
+/// The column headers' background, icons and titles.
+fn column_header_icons(app: &mut EffectcraftApp, g: &Tl) {
+    let t = g.t;
+    let rect = g.rect;
+    let header_h = g.header_h;
+    let colhdr_h = g.colhdr_h;
+    let vis = g.vis;
+    let graph_x0 = g.graph_x0;
+    let top = g.top;
+    let p = g.p.clone();
+    let cw = &g.cw;
     let ch = Rect::from_min_max(pos2(rect.min.x, top + header_h), pos2(rect.max.x, top + header_h + colhdr_h));
     p.rect_filled(Rect::from_min_max(ch.min, pos2(graph_x0 - 1.0, ch.max.y)), 0.0, t.panel_bg);
     p.line_segment([pos2(rect.min.x, ch.max.y), pos2(rect.max.x, ch.max.y)], Stroke::new(1.0, t.separator));
@@ -1980,6 +2547,20 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
             htext(x + 6.0, s);
         }
     }
+}
+
+/// Column header clicks, automation ids and the right-click column menu.
+fn column_header_interact(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Tl) {
+    let rect = g.rect;
+    let header_h = g.header_h;
+    let colhdr_h = g.colhdr_h;
+    let vis = g.vis;
+    let graph_x0 = g.graph_x0;
+    let top = g.top;
+    let ctx = g.ctx.clone();
+    let cw = &g.cw;
+    let actions = &mut g.actions;
+    let ch = Rect::from_min_max(pos2(rect.min.x, top + header_h), pos2(rect.max.x, top + header_h + colhdr_h));
     let hdr_rect = Rect::from_min_max(ch.min, pos2(graph_x0 - 1.0, ch.max.y));
     let hresp = ui.interact(hdr_rect, egui::Id::unique("tl-colhdr"), Sense::click());
     app.auto.add("timeline.header", hdr_rect, "Columns (right-click)");
@@ -2004,12 +2585,12 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
     }
     if hresp.clicked()
         && let Some(pt) = hresp.interact_pointer_pos()
-        && column_at(&cw, pt.x) == Some("name")
+        && column_at(cw, pt.x) == Some("name")
     {
         app.ui.timeline.source_name = !app.ui.timeline.source_name;
     }
     if hresp.secondary_clicked() {
-        let under = hresp.interact_pointer_pos().and_then(|pt| column_at(&cw, pt.x));
+        let under = hresp.interact_pointer_pos().and_then(|pt| column_at(cw, pt.x));
         ctx.data_mut(|d| d.insert_temp(egui::Id::unique("tl-colhdr-under"), under.unwrap_or("")));
     }
     crate::widgets::context_menu(&hresp, |ui| {
@@ -2034,12 +2615,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         }
     });
     let _ = cw.end;
+}
 
-    // ---- rows.
-    let rows = with_expr_rows(build_rows(app, &comp), &comp, &app.ui.timeline, &ctx);
-    let rh = t.row_h;
-    let total_h: f32 = rows.iter().map(|r| row_height(r, rh)).sum();
-    let max_scroll = (total_h - rows_rect.height() + rh).max(0.0);
+/// Scrolls an expression editor or the requested layer / property into view.
+fn reveal_scroll(app: &mut EffectcraftApp, g: &Tl) {
+    let scroll_to = g.scroll_to;
+    let rows_rect = g.rows_rect;
+    let rh = g.rh;
+    let ctx = g.ctx.clone();
+    let comp = g.comp.clone();
+    let rows = &g.rows;
     // An expression editor asked to take the keyboard comes into view; a request whose
     // expression didn't appear, or that wasn't taken within a few passes, is dropped (it never
     // takes the keyboard later).
@@ -2065,6 +2650,18 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
     {
         app.ui.timeline.scroll_y = rows.iter().take(i).map(|r| row_height(r, rh)).sum();
     }
+}
+
+/// Mouse wheel and pinch over the rows: zoom time or scroll.
+fn wheel_zoom_scroll(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &Tl) {
+    let overflow = g.overflow;
+    let graph_x0 = g.graph_x0;
+    let fit_pps = g.fit_pps;
+    let pps = g.pps;
+    let tm = g.tm;
+    let rows_rect = g.rows_rect;
+    let max_scroll = g.max_scroll;
+    let comp = g.comp.clone();
     if ui.rect_contains_pointer(rows_rect) {
         let (dy, dx, zoom) = ui.input(|i| (i.smooth_scroll_delta.y, i.smooth_scroll_delta.x, i.modifiers.alt));
         // Ctrl/Cmd+wheel and a trackpad pinch reach egui as a zoom factor rather than a scroll
@@ -2090,6 +2687,15 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
             }
         }
     }
+}
+
+/// A held pick whip near the top or bottom scrolls the rows.
+fn pick_whip_edge_scroll(app: &mut EffectcraftApp, g: &Tl) {
+    let rect = g.rect;
+    let rows_rect = g.rows_rect;
+    let rh = g.rh;
+    let max_scroll = g.max_scroll;
+    let ctx = g.ctx.clone();
     // A pick whip held near the top or bottom of the layers (or past it) scrolls them, as in
     // After Effects.
     if ctx.data(|d| d.get_temp::(pick_whip_id())).is_some()
@@ -2102,1095 +2708,1453 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         }
     }
     app.ui.timeline.scroll_y = app.ui.timeline.scroll_y.clamp(0.0, max_scroll);
-    let snap_project = app.session.project.clone();
-    let snap_expr = app.session.expr.clone();
-    let ectx = EvalCtx { project: &snap_project, comp_id: cid, comp: &comp, time, expr: snap_expr.as_deref(), footage: None };
-    let lp = p.with_clip_rect(rows_rect.intersect(Rect::from_min_max(rows_rect.min, pos2(graph_x0 - 1.0, rows_rect.max.y))));
-    let gp = p.with_clip_rect(rows_rect.intersect(Rect::from_min_max(pos2(graph_x0, rows_rect.min.y), rows_rect.max)));
-    let mut y = rows_rect.min.y - app.ui.timeline.scroll_y;
-    let selected = app.session.state.selected_layers.clone();
-    let sel_keys = app.session.state.selected_keys.clone();
-    let mut ui_actions: Vec = Vec::new();
-    let idx_of = |id: LayerId| comp.index_of(id).unwrap_or(0);
-    let graph_on = app.ui.timeline.graph_editor;
-    super::graph::track_open(ui.ctx(), graph_on);
-    let full_clip = ui.clip_rect();
-    let left_clip = full_clip.intersect(Rect::from_min_max(pos2(rect.min.x, rows_rect.min.y), pos2(graph_x0 - 1.0, rows_rect.max.y)));
-    let right_clip = full_clip.intersect(Rect::from_min_max(pos2(graph_x0, rows_rect.min.y), rows_rect.max));
-    // Registered before the rows so keys, bars and editors on top of it get the pointer first.
-    let empty_rect = if graph_on { Rect::NOTHING } else { Rect::from_min_max(pos2(graph_x0, rows_rect.min.y), rows_rect.max) };
-    let empty = ui.interact(empty_rect, egui::Id::unique("tl-graph-bg"), Sense::click_and_drag());
-    let outline_rect = Rect::from_min_max(rows_rect.min, pos2(graph_x0 - 1.0, rows_rect.max.y));
-    let outline_empty = ui.interact(outline_rect, egui::Id::unique("tl-layer-bg"), Sense::click_and_drag());
-    app.auto.add("timeline.layerMarquee", outline_rect, "Drag empty outline to select layers");
-    let mut hit_rows: Vec<(Rect, Row)> = vec![];
-    // The value fields of multi-dimension properties (per dimension), for the pick whip.
-    let mut dim_hits: Vec<(u64, Vec)> = vec![];
-    // Rows stripe by their index among the rows that are not expression editors, so adding or
-    // removing an expression leaves every other row's stripe alone; an editor takes the index
-    // (and so the stripe) of its property's row, the one just above.
-    let mut striped = 0usize;
-    for row in rows.iter() {
-        let ri = if matches!(row.kind, RowKind::Expr { .. }) {
-            striped.saturating_sub(1)
-        } else {
-            striped += 1;
-            striped - 1
-        };
-        // Outline widgets never spill into the time graph (narrow timelines).
-        ui.set_clip_rect(left_clip);
-        let hh = row_height(row, rh);
-        let r = Rect::from_min_size(pos2(rect.min.x, y), vec2(rect.width(), hh));
-        y += hh;
-        if r.max.y < rows_rect.min.y || r.min.y > rows_rect.max.y {
+}
+
+fn layer_row_background(g: &Rw, rc: &RowCtx<'_>) {
+    let t = g.tl.t;
+    let rect = g.tl.rect;
+    let graph_x0 = g.tl.graph_x0;
+    let lp = g.lp.clone();
+    let gp = g.gp.clone();
+    let r = rc.r;
+    let left = rc.left;
+    let is_sel = rc.is_sel;
+    lp.rect_filled(left, 0.0, if is_sel { t.row_selected } else { t.row_alt });
+    gp.rect_filled(Rect::from_min_max(pos2(graph_x0, r.min.y), r.max), 0.0, if is_sel { t.tl_graph_selected } else { t.tl_bg });
+    lp.line_segment([pos2(rect.min.x, r.max.y - 0.5), pos2(graph_x0, r.max.y - 0.5)], Stroke::new(1.0, t.app_bg));
+    gp.line_segment([pos2(graph_x0, r.max.y - 0.5), pos2(r.max.x, r.max.y - 0.5)], Stroke::new(1.0, t.app_bg));
+}
+
+/// The A/V feature switches (video, audio, solo, lock).
+fn layer_av_switches(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>) {
+    let t = g.tl.t;
+    let vis = g.tl.vis;
+    let comp = g.tl.comp.clone();
+    let cw = &g.tl.cw;
+    let actions = &mut g.tl.actions;
+    let lp = g.lp.clone();
+    let selected = &g.selected;
+    let layer = rc.layer;
+    let r = rc.r;
+    let cy = rc.cy;
+    let sw = &layer.switches;
+    // Every layer has the Video switch (the eye), nulls, cameras and lights included,
+    // as in After Effects: it hides a null's outline in the viewer and turns a camera or
+    // light off.
+    let av_items: [(Icon, bool, &str, bool); 4] = [
+        (Icon::Eye, sw.video, "video", switch_applies(layer, "video", &app.session.project)),
+        (Icon::Speaker, sw.audio, "audio", switch_applies(layer, "audio", &app.session.project)),
+        (Icon::Solo, sw.solo, "solo", switch_applies(layer, "solo", &app.session.project)),
+        (Icon::Lock, sw.locked, "lock", switch_applies(layer, "lock", &app.session.project)),
+    ];
+    for (i, (icon, on, name, applicable)) in av_items.into_iter().enumerate() {
+        let br = Rect::from_center_size(pos2(cw.av + 10.0 + 18.0 * i as f32, cy), vec2(15.0, 15.0));
+        if !applicable || !vis.av {
             continue;
         }
-        hit_rows.push((r, row.clone()));
-        let Some(layer) = comp.layer(row.layer) else { continue };
-        let is_sel = selected.contains(&layer.id);
-        let left = Rect::from_min_max(r.min, pos2(graph_x0 - 1.0, r.max.y));
-        let cy = r.min.y + rh / 2.0;
-        match &row.kind {
-            RowKind::Layer => {
-                // After Effects draws layer rows as one flat tone separated by dark hairlines
-                // (outline and time graph alike), not as alternating stripes.
-                let _ = ri;
-                lp.rect_filled(left, 0.0, if is_sel { t.row_selected } else { t.row_alt });
-                gp.rect_filled(Rect::from_min_max(pos2(graph_x0, r.min.y), r.max), 0.0, if is_sel { t.tl_graph_selected } else { t.tl_bg });
-                lp.line_segment([pos2(rect.min.x, r.max.y - 0.5), pos2(graph_x0, r.max.y - 0.5)], Stroke::new(1.0, t.app_bg));
-                gp.line_segment([pos2(graph_x0, r.max.y - 0.5), pos2(r.max.x, r.max.y - 0.5)], Stroke::new(1.0, t.app_bg));
-                // A/V features.
-                let sw = &layer.switches;
-                // Every layer has the Video switch (the eye), nulls, cameras and lights included,
-                // as in After Effects: it hides a null's outline in the viewer and turns a camera or
-                // light off.
-                let av_items: [(Icon, bool, &str, bool); 4] = [
-                    (Icon::Eye, sw.video, "video", switch_applies(layer, "video", &app.session.project)),
-                    (Icon::Speaker, sw.audio, "audio", switch_applies(layer, "audio", &app.session.project)),
-                    (Icon::Solo, sw.solo, "solo", switch_applies(layer, "solo", &app.session.project)),
-                    (Icon::Lock, sw.locked, "lock", switch_applies(layer, "lock", &app.session.project)),
-                ];
-                for (i, (icon, on, name, applicable)) in av_items.into_iter().enumerate() {
-                    let br = Rect::from_center_size(pos2(cw.av + 10.0 + 18.0 * i as f32, cy), vec2(15.0, 15.0));
-                    if !applicable || !vis.av {
-                        continue;
-                    }
-                    lp.rect_filled(br.shrink(0.5), 1.0, t.switch_well);
-                    layer_switch(
-                        ui,
-                        &t,
-                        r,
-                        br,
-                        icon,
-                        on,
-                        egui::Id::unique(("av", layer.id.0, i)),
-                        layer.id.0,
-                        name,
-                        true,
-                        &comp,
-                        &app.session.project,
-                        &selected,
-                        &mut actions,
-                    );
-                    app.auto.add(&format!("timeline.layer.{}.{name}", layer.id.0), br, name);
-                }
-                // Label swatch.
-                if vis.label {
-                    let lr = Rect::from_center_size(pos2(cw.label + 10.0, cy), vec2(12.0, 12.0));
-                    lp.rect_filled(lr, 2.0, t.label(layer.label));
-                    let lresp = ui.interact(lr, egui::Id::unique(("label", layer.id.0)), Sense::click());
-                    app.auto.add(&format!("timeline.layer.{}.label", layer.id.0), lr, layer.label.name());
-                    // Click (or right-click) the swatch for the label menu (After Effects).
-                    let pop = egui::Id::unique(("label-pop", layer.id.0));
-                    if lresp.clicked() || lresp.secondary_clicked() {
-                        widgets::open_popup(ui, pop);
-                    }
-                    if let Some(lab) = widgets::label_popup(ui, pop, lr.left_bottom(), layer.label, |l| app.session.prefs.label_name(l), &t) {
-                        actions.push(("edit.label".into(), json!({"layers": edited_layers(layer.id, &selected), "label": lab.name()})));
-                    }
-                }
-                if vis.num {
-                    lp.text(pos2(cw.num + 13.0, cy), Align2::CENTER_CENTER, format!("{}", idx_of(layer.id)), Tokens::ui(11.5), t.text_dim);
-                }
-                layer_cells(app, ui, &lp, &cw, &comp, layer, r, cy, &mut actions);
-                // Row click → select; double-click → rename; drag → reorder. Registered before the
-                // twirl and the name field so those sit on top and get their clicks.
-                let row_x0 = if vis.num { cw.num } else { cw.name + 16.0 };
-                let row_resp = ui.interact(
-                    Rect::from_min_max(pos2(row_x0, r.min.y), pos2(cw.name_end, r.max.y)),
-                    egui::Id::unique(("row", layer.id.0)),
-                    Sense::click_and_drag(),
-                );
-                // Twirl + icon + name.
-                let tw = Rect::from_center_size(pos2(cw.name + 8.0, cy), vec2(14.0, 14.0));
-                let open = app.ui.timeline.open_layers.contains(&layer.id.0);
-                if widgets::twirl(ui, tw, open, egui::Id::unique(("twirl", layer.id.0)), &t).clicked() {
-                    ui_actions.push(UiAct::ToggleLayer(layer.id.0, ui.input(|i| i.modifiers.alt || i.modifiers.command)));
-                }
-                app.auto.add(&format!("timeline.layer.{}.twirl", layer.id.0), tw, "twirl");
-                icons::paint(&lp, Rect::from_center_size(pos2(cw.name + 24.0, cy), vec2(13.0, 13.0)), layer_icon(layer, &app.session.project), t.text_dim);
-                // A guide layer carries a guide mark before its name (After Effects).
-                let name_x = if layer.switches.guide {
-                    let mark = Rect::from_center_size(pos2(cw.name + 40.0, cy), vec2(11.0, 11.0));
-                    icons::paint(&lp, mark, Icon::Grid, t.text_dim);
-                    app.auto.add(&format!("timeline.layer.{}.guide", layer.id.0), mark, "Guide Layer");
-                    cw.name + 50.0
-                } else {
-                    cw.name + 34.0
-                };
-                let name_rect = Rect::from_min_max(pos2(name_x, r.min.y), pos2(cw.name_end - 4.0, r.max.y));
-                let rename_id = egui::Id::unique("tl-rename");
-                let renaming: Option<(u64, String)> = ctx.data(|d| d.get_temp(rename_id));
-                if let Some((rl, mut buf)) = renaming.filter(|(rl, _)| *rl == layer.id.0) {
-                    let mut child = ui.new_child(egui::UiBuilder::new().max_rect(name_rect.shrink2(vec2(0.0, 2.0))));
-                    let er = child.add(egui::TextEdit::singleline(&mut buf).font(Tokens::ui(12.0)).desired_width(name_rect.width()));
-                    app.auto.add(&format!("timeline.layer.{rl}.nameEdit"), er.rect, &buf);
-                    if ctx.data_mut(|d| d.remove_temp::(rename_focus_id())).unwrap_or(false) {
-                        // Just started: focus the field with the whole name selected.
-                        er.request_focus();
-                        widgets::select_all(&ctx, er.id, &buf);
-                    }
-                    if er.lost_focus() {
-                        if !ui.input(|i| i.key_pressed(egui::Key::Escape)) {
-                            actions.push(("layer.rename".into(), json!({"layer": rl, "name": buf})));
-                        }
-                        ctx.data_mut(|d| d.remove::<(u64, String)>(rename_id));
-                    } else {
-                        ctx.data_mut(|d| d.insert_temp(rename_id, (rl, buf)));
-                    }
-                } else {
-                    // The selected layer's name sits in a light cell with dark text.
-                    let name_col = if is_sel { Color32::from_gray(0x16) } else { t.text };
-                    if is_sel {
-                        let g = lp.layout_no_wrap(display_name(app, layer), Tokens::ui(12.0), name_col);
-                        let cell = Rect::from_min_max(
-                            pos2(name_rect.min.x - 3.0, r.min.y + 1.0),
-                            pos2((name_rect.min.x + g.size().x + 4.0).max(cw.name_end - 4.0).min(cw.name_end - 2.0), r.max.y - 1.0),
-                        );
-                        lp.rect_filled(cell, 0.0, SELECTED_NAME_BG);
-                    }
-                    let lpn = lp.with_clip_rect(name_rect.intersect(lp.clip_rect()));
-                    lpn.text(pos2(name_rect.min.x, cy), Align2::LEFT_CENTER, display_name(app, layer), Tokens::ui(12.0), name_col);
-                }
-                // Locked layers can't be selected or moved (unlock them with the lock switch).
-                let locked = layer.switches.locked;
-                if row_resp.drag_started() && !locked {
-                    // The selected layers move together; an unselected row moves alone.
-                    let moving: Vec =
-                        if is_sel { comp.layers.iter().filter(|l| selected.contains(&l.id)).map(|l| l.id.0).collect() } else { vec![layer.id.0] };
-                    ctx.data_mut(|d| d.insert_temp(layer_drag_id(), moving));
-                }
-                if row_resp.drag_stopped() {
-                    ctx.data_mut(|d| d.insert_temp(layer_drop_id(), true));
-                }
-                app.auto.add(&format!("timeline.layer.{}.row", layer.id.0), left, &layer.name);
-                if row_resp.clicked() && !locked {
-                    let m = ui.input(|i| i.modifiers);
-                    if let Some(layers) = m.shift.then(|| shift_range(&ctx, cid, &comp, &rows, &selected, layer.id)).flatten() {
-                        actions.push(("layer.select".into(), json!({"layers": layers})));
-                    } else {
-                        actions.push(("layer.select".into(), json!({"layers": [layer.id.0], "toggle": m.command})));
-                        set_anchor(&ctx, cid, layer.id);
-                    }
-                }
-                if row_resp.double_clicked() {
-                    match double_click_action(layer) {
-                        Some(a) => actions.push(a),
-                        None => start_rename(&ctx, layer.id.0, &layer.name),
-                    }
-                }
-                layer_context_menu(&row_resp, &comp, layer, &selected, LayerMenuCaps::of(&app.session.project, layer), &mut app.auto, &mut actions);
-                // Switches.
-                let sws: [(Icon, bool, &str, bool); 8] = [
-                    (Icon::Shy, sw.shy, "shy", switch_applies(layer, "shy", &app.session.project)),
-                    (Icon::Collapse, sw.collapse, "collapse", switch_applies(layer, "collapse", &app.session.project)),
-                    (
-                        Icon::Quality,
-                        sw.quality == effectcraft_engine::project::Quality::Best,
-                        "quality",
-                        switch_applies(layer, "quality", &app.session.project),
-                    ),
-                    (Icon::Fx, sw.effects, "fx", switch_applies(layer, "fx", &app.session.project)),
-                    (
-                        Icon::FrameBlend,
-                        sw.frame_blend != effectcraft_engine::project::FrameBlend::Off,
-                        "frameBlend",
-                        switch_applies(layer, "frameBlend", &app.session.project),
-                    ),
-                    (Icon::MotionBlur, sw.motion_blur, "motionBlur", switch_applies(layer, "motionBlur", &app.session.project)),
-                    (Icon::Adjustment, sw.adjustment, "adjustment", switch_applies(layer, "adjustment", &app.session.project)),
-                    (Icon::Cube, layer.is_3d(), "threeD", switch_applies(layer, "threeD", &app.session.project)),
-                ];
-                for (i, (icon, on, name, applicable)) in sws.into_iter().enumerate() {
-                    if !applicable || !vis.switches {
-                        continue;
-                    }
-                    let br = Rect::from_center_size(pos2(cw.switches + SW * i as f32 + SW / 2.0, cy), vec2(16.0, 16.0));
-                    lp.rect_filled(br.shrink(1.0), 1.0, t.switch_well);
-                    layer_switch(
-                        ui,
-                        &t,
-                        r,
-                        br,
-                        icon,
-                        on,
-                        egui::Id::unique(("sw", layer.id.0, i)),
-                        layer.id.0,
-                        name,
-                        false,
-                        &comp,
-                        &app.session.project,
-                        &selected,
-                        &mut actions,
-                    );
-                    app.auto.add(&format!("timeline.layer.{}.switch.{name}", layer.id.0), br, name);
-                }
-                // Modes.
-                if app.ui.timeline.show_modes && layer.source.is_av() {
-                    let mr = Rect::from_min_size(pos2(cw.mode + 2.0, cy - 9.0), vec2(80.0, 18.0));
-                    let mresp = widgets::dropdown(ui, mr, layer.blend_mode.label(), &t, egui::Id::unique(("mode", layer.id.0)));
-                    app.auto.add(&format!("timeline.layer.{}.mode", layer.id.0), mr, "Mode");
-                    let pop = egui::Id::unique(("mode-pop", layer.id.0));
-                    if mresp.clicked() {
-                        widgets::open_popup(ui, pop);
-                    }
-                    let opts: Vec = BlendMode::ALL
-                        .iter()
-                        .flat_map(|m| if m.ends_group() { vec![m.label().to_string(), "-".to_string()] } else { vec![m.label().to_string()] })
-                        .collect();
-                    if let Some(i) = widgets::popup_menu(ui, pop, mr.left_bottom(), &opts, opts.iter().position(|o| o == layer.blend_mode.label())) {
-                        actions.push(("layer.setBlendMode".into(), json!({"layers": mode_targets(&comp, layer.id, &selected), "mode": opts[i]})));
-                    }
-                    let tr = Rect::from_center_size(pos2(cw.mode + 76.0 + 6.0, cy), vec2(14.0, 14.0));
-                    app.auto.add(&format!("timeline.layer.{}.preserveTransparency", layer.id.0), tr, "Preserve Transparency");
-                    if widgets::checkbox(ui, tr, layer.preserve_transparency, &t, egui::Id::unique(("pt", layer.id.0))).clicked() {
-                        // The click gives the selection the state the clicked row toggles to,
-                        // whatever the first selected layer's value (as the switches do).
-                        actions.push((
-                            "layer.setSwitch".into(),
-                            json!({
-                                "layers": mode_targets(&comp, layer.id, &selected),
-                                "switch": "preserveTransparency",
-                                "value": !layer.preserve_transparency
-                            }),
-                        ));
-                    }
-                    // Track matte: layer picker + kind.
-                    let mr = Rect::from_min_size(pos2(cw.trkmat + 2.0, cy - 9.0), vec2(104.0, 18.0));
-                    let label = match layer.track_matte {
-                        Some(m) => comp.layer(m.layer).map(|l| format!("{} {}", idx_of(l.id), l.name)).unwrap_or("None".into()),
-                        None => "No Track Matte".into(),
-                    };
-                    // The cell is too narrow for the mode as well: it is in the tooltip and ticked in the menu.
-                    let tip = layer.track_matte.map(|m| format!("{label} · {}", crate::i18n::tr(m.kind.label())));
-                    let tresp = widgets::dropdown_tip(ui, mr, &label, tip.as_deref(), &t, egui::Id::unique(("trkmat", layer.id.0)));
-                    app.auto.add(&format!("timeline.layer.{}.trackMatte", layer.id.0), mr, "Track Matte");
-                    let pop = egui::Id::unique(("trk-pop", layer.id.0));
-                    if tresp.clicked() {
-                        widgets::open_popup(ui, pop);
-                    }
-                    // The menu lists every layer: built only while it is open (per visible row
-                    // it made a big comp's timeline quadratic).
-                    let mut opts = vec!["No Track Matte".to_string(), "-".to_string()];
-                    let mut candidates: Vec<&Layer> = vec![];
-                    if widgets::popup_is_open(ui, pop) {
-                        for (i, l) in comp.layers.iter().enumerate().filter(|(_, l)| l.id != layer.id && l.source.is_av()) {
-                            opts.push(format!("{}. {}", i + 1, l.name));
-                            candidates.push(l);
-                        }
-                        opts.push("-".into());
-                        for k in MatteKind::ALL {
-                            opts.push(k.label().into());
-                        }
-                    }
-                    let current = layer.track_matte.and_then(|m| opts.iter().position(|o| o == m.kind.label()));
-                    if let Some(i) = widgets::popup_menu(ui, pop, mr.left_bottom(), &opts, current) {
-                        let ids: Vec = candidates.iter().map(|l| l.id).collect();
-                        if let Some(a) = opts.get(i).and_then(|o| track_matte_pick(layer, &ids, i, o)) {
-                            actions.push(a);
-                        }
-                    }
-                    if layer.track_matte.is_some() {
-                        let kr = Rect::from_center_size(pos2(mr.max.x - 26.0, cy), vec2(10.0, 10.0));
-                        let _ = kr;
-                    }
-                }
-                // Parent.
-                if vis.parent {
-                    pick_whip(
-                        app,
-                        ui,
-                        &lp,
-                        Rect::from_center_size(pos2(cw.parent + 9.0, cy), vec2(15.0, 15.0)),
-                        (WHIP_PARENT, layer.id.0, 0),
-                        &format!("timeline.layer.{}.pickWhip", layer.id.0),
-                        "Parent pick whip",
-                        crate::i18n::tr("Parent pick whip: drag onto a layer"),
-                    );
-                    let pr_rect = Rect::from_min_size(pos2(cw.parent + 20.0, cy - 9.0), vec2(94.0, 18.0));
-                    let plabel = layer.parent.and_then(|p| comp.layer(p)).map(|l| format!("{}. {}", idx_of(l.id), l.name)).unwrap_or_else(|| "None".into());
-                    let presp = widgets::dropdown(ui, pr_rect, &plabel, &t, egui::Id::unique(("parent", layer.id.0)));
-                    app.auto.add(&format!("timeline.layer.{}.parent", layer.id.0), pr_rect, "Parent");
-                    let pop = egui::Id::unique(("par-pop", layer.id.0));
-                    if presp.clicked() {
-                        widgets::open_popup(ui, pop);
-                    }
-                    let mut cands: Vec<&Layer> = vec![];
-                    let mut popts = vec!["None".to_string(), "-".to_string()];
-                    if widgets::popup_is_open(ui, pop) {
-                        for (i, l) in comp.layers.iter().enumerate().filter(|(_, l)| l.id != layer.id) {
-                            popts.push(format!("{}. {}", i + 1, l.name));
-                            cands.push(l);
-                        }
-                    }
-                    if let Some(i) = widgets::popup_menu(ui, pop, pr_rect.left_bottom(), &popts, None) {
-                        let par = if i == 0 { serde_json::Value::Null } else { json!(cands[i - 2].id.0) };
-                        actions.push(("layer.setParent".into(), json!({"layers": [layer.id.0], "parent": par})));
-                    }
-                }
-                // Layer bar.
-                ui.set_clip_rect(right_clip);
-                if !graph_on {
-                    let x_in = tm.x(layer.in_point.seconds());
-                    let x_out = tm.x(layer.out_point.seconds());
-                    let bar = Rect::from_min_max(pos2(x_in, r.min.y + 1.0), pos2(x_out, r.max.y - 1.0));
-                    let lc = t.label(layer.label);
-                    let fill = bar_color(lc, is_sel);
-                    gp.rect_filled(bar, 0.0, fill);
-                    if is_sel {
-                        gp.rect_stroke(bar, 0.0, Stroke::new(1.0, Color32::from_white_alpha(90)), StrokeKind::Inside);
-                    }
-                    // Source extent (e.g. trimmed footage) shown faint.
-                    if let LayerSource::Footage { .. } | LayerSource::Comp { .. } = layer.source
-                        && let Some(d) = layer.source.item().and_then(|i| app.session.project.item(i)).and_then(|i| i.duration())
-                    {
-                        let xs = tm.x(layer.start_time.seconds());
-                        let xe = tm.x(layer.comp_time(d).seconds());
-                        let ghost = Rect::from_min_max(pos2(xs.min(xe), r.min.y + 9.0), pos2(xs.max(xe), r.max.y - 9.0));
-                        gp.rect_filled(ghost, 1.0, lc.gamma_multiply(0.18));
-                        // The bar past the source's end (its last frame held) or before its start
-                        // is striped, so the source's own span shows (#290). Time remapping
-                        // decides what shows there itself.
-                        if layer.props.get("timeRemap").is_none() {
-                            for (side, part) in [
-                                ("beforeSource", Rect::from_min_max(bar.min, pos2(ghost.min.x, bar.max.y))),
-                                ("afterSource", Rect::from_min_max(pos2(ghost.max.x, bar.min.y), bar.max)),
-                            ] {
-                                if part.width() >= 1.0 {
-                                    stripes(&gp, part, lc);
-                                    app.auto.add(&format!("timeline.layer.{}.bar.{side}", layer.id.0), part, "Past the source");
-                                }
-                            }
-                        }
-                        // Dragging the source bar outside the in/out span slips the source.
-                        for (side, gr) in [
-                            ("l", Rect::from_min_max(ghost.min, pos2(bar.min.x, ghost.max.y))),
-                            ("r", Rect::from_min_max(pos2(bar.max.x, ghost.min.y), ghost.max)),
-                        ] {
-                            if gr.width() < 2.0 {
-                                continue;
-                            }
-                            let gresp = ui
-                                .interact(gr, egui::Id::unique(("ghost", side, layer.id.0)), Sense::click_and_drag())
-                                .on_hover_text(crate::i18n::tr("Drag to slip the source"));
-                            // Outside the bar it is empty track area: a plain click deselects.
-                            if gresp.clicked() && !ui.input(|i| i.modifiers.shift || i.modifiers.command) {
-                                actions.push(("layer.select".into(), json!({"layers": []})));
-                            }
-                            app.auto.add(&format!("timeline.layer.{}.source{}", layer.id.0, side), gr, "Slip");
-                            if gresp.hovered() || gresp.dragged() {
-                                ctx.set_cursor_icon(egui::CursorIcon::ResizeHorizontal);
-                            }
-                            let acc_id = egui::Id::unique(("slip-acc", layer.id.0));
-                            if gresp.dragged() {
-                                let mut acc: f32 = ctx.data(|d| d.get_temp(acc_id).unwrap_or(0.0));
-                                acc += gresp.drag_delta().x;
-                                let frames = ((acc as f64 / pps) / fd).trunc() as i64;
-                                if frames != 0 {
-                                    acc -= (frames as f64 * fd * pps) as f32;
-                                    actions.push((
-                                        "layer.slip".into(),
-                                        json!({"layers": [layer.id.0], "frames": frames, "merge": format!("slip-{}", layer.id.0)}),
-                                    ));
-                                }
-                                ctx.data_mut(|d| d.insert_temp(acc_id, acc));
-                            }
-                            if gresp.drag_stopped() {
-                                ctx.data_mut(|d| d.remove::(acc_id));
-                                ui_actions.push(UiAct::EndMerge);
-                            }
-                        }
-                    }
-                    app.auto.add(&format!("timeline.layer.{}.bar", layer.id.0), bar, &layer.name);
-                    // Interactions: move body, trim edges.
-                    let (body_r, in_r, out_r) = trim_zones(bar);
-                    let body = ui.interact(body_r, egui::Id::unique(("bar", layer.id.0)), Sense::click_and_drag());
-                    let lin = ui.interact(in_r, egui::Id::unique(("bar-in", layer.id.0)), Sense::drag());
-                    let lout = ui.interact(out_r, egui::Id::unique(("bar-out", layer.id.0)), Sense::drag());
-                    app.auto.add(&format!("timeline.layer.{}.bar.in", layer.id.0), in_r, "Trim In");
-                    app.auto.add(&format!("timeline.layer.{}.bar.out", layer.id.0), out_r, "Trim Out");
-                    if lin.hovered() || lout.hovered() || lin.dragged() || lout.dragged() {
-                        trim_cursor(&ctx);
-                    }
-                    if body.double_clicked()
-                        && let Some(a) = double_click_action(layer)
-                    {
-                        actions.push(a);
-                    }
-                    let shift = ui.input(|i| i.modifiers.shift);
-                    if let Some(layers) = (body.clicked() && shift).then(|| shift_range(&ctx, cid, &comp, &rows, &selected, layer.id)).flatten() {
-                        // Shift-click: the layers from the last one clicked to this one, as in
-                        // the layer outline.
-                        actions.push(("layer.select".into(), json!({"layers": layers})));
-                    } else {
-                        if (body.clicked() || body.drag_started()) && !is_sel {
-                            actions.push(("layer.select".into(), json!({"layers": [layer.id.0], "add": shift})));
-                        }
-                        if (body.clicked() || body.drag_started()) && !shift {
-                            set_anchor(&ctx, cid, layer.id);
-                        }
-                    }
-                    layer_context_menu(&body, &comp, layer, &selected, LayerMenuCaps::of(&app.session.project, layer), &mut app.auto, &mut actions);
-                    let drag_key = format!("bar-{}", layer.id.0);
-                    // Ctrl/Cmd+Alt dragging a selected bar: Quick Offset (#1012), chosen as the
-                    // drag starts and followed below the rows until the button is released.
-                    let mods = ui.input(|i| i.modifiers);
-                    if body.drag_started()
-                        && is_sel
-                        && mods.command
-                        && mods.alt
-                        && let Some(n) = quick_offset_layers(&comp, selected.iter().copied())
-                        && let Some(p) = ctx.input(|i| i.pointer.press_origin())
-                    {
-                        ctx.data_mut(|d| d.insert_temp(quick_bar_id(), QuickBar { layer: layer.id, origin_x: p.x, layers: n, applied: 0 }));
-                    }
-                    let quick = ctx.data(|d| d.get_temp::(quick_bar_id())).is_some_and(|q| q.layer == layer.id);
-                    for (resp, kind) in [(&body, "move"), (&lin, "in"), (&lout, "out")] {
-                        if kind == "move" && quick {
-                            continue;
-                        }
-                        if resp.dragged() {
-                            let acc_id = egui::Id::unique(("bar-acc", layer.id.0, kind));
-                            let mut acc: f32 = ctx.data(|d| d.get_temp(acc_id).unwrap_or(0.0));
-                            acc += resp.drag_delta().x;
-                            let frames = ((acc as f64 / pps) / fd).trunc() as i64;
-                            if frames != 0 {
-                                acc -= (frames as f64 * fd * pps) as f32;
-                                let d = frames as f64 * fd;
-                                // Moving or trimming a selected layer acts on the whole
-                                // selection; each layer trims from its own in/out point.
-                                let targets = if is_sel { json!(selected.iter().map(|l| l.0).collect::>()) } else { json!([layer.id.0]) };
-                                let params = match kind {
-                                    "move" => json!({"layers": targets, "delta": d, "merge": drag_key}),
-                                    "in" => json!({"layers": targets, "inDelta": d, "merge": drag_key}),
-                                    _ => json!({"layers": targets, "outDelta": d, "merge": drag_key}),
-                                };
-                                actions.push(("layer.timing".into(), params));
-                            }
-                            ctx.data_mut(|d| d.insert_temp(acc_id, acc));
-                        }
-                        if resp.drag_stopped() {
-                            ctx.data_mut(|d| d.remove::(egui::Id::unique(("bar-acc", layer.id.0, kind))));
-                            ui_actions.push(UiAct::EndMerge);
-                        }
-                    }
-                    // Markers on the layer (drag, Alt-drag duration, Cmd-click delete, dialog).
-                    let gclip = rows_rect.intersect(Rect::from_min_max(pos2(graph_x0, rows_rect.min.y), rows_rect.max));
-                    super::markers_ui::layer_markers(app, ui, gclip, &comp, layer, tm, r);
-                }
+        lp.rect_filled(br.shrink(0.5), 1.0, t.switch_well);
+        layer_switch(
+            ui,
+            &t,
+            &SwitchSpec { row: r, br, icon, on, id: egui::Id::unique(("av", layer.id.0, i)), layer: layer.id.0, name, others: true },
+            &comp,
+            &app.session.project,
+            selected,
+            &mut *actions,
+        );
+        app.auto.add(&format!("timeline.layer.{}.{name}", layer.id.0), br, name);
+    }
+}
+
+/// The label swatch and its colour menu.
+fn layer_label_swatch(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>) {
+    let t = g.tl.t;
+    let vis = g.tl.vis;
+    let cw = &g.tl.cw;
+    let actions = &mut g.tl.actions;
+    let lp = g.lp.clone();
+    let selected = &g.selected;
+    let layer = rc.layer;
+    let cy = rc.cy;
+    // Label swatch.
+    if vis.label {
+        let lr = Rect::from_center_size(pos2(cw.label + 10.0, cy), vec2(12.0, 12.0));
+        lp.rect_filled(lr, 2.0, t.label(layer.label));
+        let lresp = ui.interact(lr, egui::Id::unique(("label", layer.id.0)), Sense::click());
+        app.auto.add(&format!("timeline.layer.{}.label", layer.id.0), lr, layer.label.name());
+        // Click (or right-click) the swatch for the label menu (After Effects).
+        let pop = egui::Id::unique(("label-pop", layer.id.0));
+        if lresp.clicked() || lresp.secondary_clicked() {
+            widgets::open_popup(ui, pop);
+        }
+        if let Some(lab) = widgets::label_popup(ui, pop, lr.left_bottom(), layer.label, |l| app.session.prefs.label_name(l), &t) {
+            actions.push(("edit.label".into(), json!({"layers": edited_layers(layer.id, selected), "label": lab.name()})));
+        }
+    }
+}
+
+fn layer_number_and_cells(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>) {
+    let t = g.tl.t;
+    let vis = g.tl.vis;
+    let comp = g.tl.comp.clone();
+    let cw = &g.tl.cw;
+    let actions = &mut g.tl.actions;
+    let lp = g.lp.clone();
+    let layer = rc.layer;
+    let cy = rc.cy;
+    if vis.num {
+        lp.text(pos2(cw.num + 13.0, cy), Align2::CENTER_CENTER, format!("{}", comp.index_of(layer.id).unwrap_or(0)), Tokens::ui(11.5), t.text_dim);
+    }
+    layer_cells(app, ui, &lp, cw, &comp, rc, &mut *actions);
+}
+
+/// The row's click target, twirl, icon and guide mark; returns the row response and the name's x.
+fn layer_twirl_and_icon(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>) -> (egui::Response, f32) {
+    let t = g.tl.t;
+    let vis = g.tl.vis;
+    let cw = &g.tl.cw;
+    let lp = g.lp.clone();
+    let ui_actions = &mut g.ui_actions;
+    let layer = rc.layer;
+    let r = rc.r;
+    let cy = rc.cy;
+    // Row click → select; double-click → rename; drag → reorder. Registered before the
+    // twirl and the name field so those sit on top and get their clicks.
+    let row_x0 = if vis.num { cw.num } else { cw.name + 16.0 };
+    let row_resp =
+        ui.interact(Rect::from_min_max(pos2(row_x0, r.min.y), pos2(cw.name_end, r.max.y)), egui::Id::unique(("row", layer.id.0)), Sense::click_and_drag());
+    // Twirl + icon + name.
+    let tw = Rect::from_center_size(pos2(cw.name + 8.0, cy), vec2(14.0, 14.0));
+    let open = app.ui.timeline.open_layers.contains(&layer.id.0);
+    if widgets::twirl(ui, tw, open, egui::Id::unique(("twirl", layer.id.0)), &t).clicked() {
+        ui_actions.push(UiAct::ToggleLayer(layer.id.0, ui.input(|i| i.modifiers.alt || i.modifiers.command)));
+    }
+    app.auto.add(&format!("timeline.layer.{}.twirl", layer.id.0), tw, "twirl");
+    icons::paint(&lp, Rect::from_center_size(pos2(cw.name + 24.0, cy), vec2(13.0, 13.0)), layer_icon(layer, &app.session.project), t.text_dim);
+    // A guide layer carries a guide mark before its name (After Effects).
+    let name_x = if layer.switches.guide {
+        let mark = Rect::from_center_size(pos2(cw.name + 40.0, cy), vec2(11.0, 11.0));
+        icons::paint(&lp, mark, Icon::Grid, t.text_dim);
+        app.auto.add(&format!("timeline.layer.{}.guide", layer.id.0), mark, "Guide Layer");
+        cw.name + 50.0
+    } else {
+        cw.name + 34.0
+    };
+    (row_resp, name_x)
+}
+
+/// The layer name: the rename field or the (selected) name cell.
+fn layer_name(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>, name_x: f32) {
+    let t = g.tl.t;
+    let ctx = g.tl.ctx.clone();
+    let cw = &g.tl.cw;
+    let actions = &mut g.tl.actions;
+    let lp = g.lp.clone();
+    let layer = rc.layer;
+    let r = rc.r;
+    let cy = rc.cy;
+    let is_sel = rc.is_sel;
+    let name_rect = Rect::from_min_max(pos2(name_x, r.min.y), pos2(cw.name_end - 4.0, r.max.y));
+    let rename_id = egui::Id::unique("tl-rename");
+    let renaming: Option<(u64, String)> = ctx.data(|d| d.get_temp(rename_id));
+    if let Some((rl, mut buf)) = renaming.filter(|(rl, _)| *rl == layer.id.0) {
+        let mut child = ui.new_child(egui::UiBuilder::new().max_rect(name_rect.shrink2(vec2(0.0, 2.0))));
+        let er = child.add(egui::TextEdit::singleline(&mut buf).font(Tokens::ui(12.0)).desired_width(name_rect.width()));
+        app.auto.add(&format!("timeline.layer.{rl}.nameEdit"), er.rect, &buf);
+        if ctx.data_mut(|d| d.remove_temp::(rename_focus_id())).unwrap_or(false) {
+            // Just started: focus the field with the whole name selected.
+            er.request_focus();
+            widgets::select_all(&ctx, er.id, &buf);
+        }
+        if er.lost_focus() {
+            if !ui.input(|i| i.key_pressed(egui::Key::Escape)) {
+                actions.push(("layer.rename".into(), json!({"layer": rl, "name": buf})));
             }
-            RowKind::Group { uid, name, open, has_children, fx, eye } => {
-                // The row draws an effect's name in the interface language (#821); `name` stays
-                // the property tree's, which the automation labels below keep. Any other group
-                // (a mask, a shape group, a layer style) draws its own name.
-                let group = layer.props.find_group(*uid);
-                let shown = match group.and_then(|g| match &g.kind {
-                    GroupKind::Effect { effect } => Some(effect.as_str()),
-                    _ => None,
-                }) {
-                    Some(effect) => crate::i18n::effects::group(effect, name),
-                    None => name.clone(),
-                };
-                // Selected groups (an effect, a mask, a shape group…) highlight like selected
-                // property rows.
-                let sel_group = app.session.state.selected_props.contains(&(layer.id, *uid));
-                lp.rect_filled(
-                    left,
-                    0.0,
-                    if sel_group {
-                        t.row_selected
-                    } else if ri % 2 == 0 {
-                        t.row
-                    } else {
-                        t.row_alt
-                    },
-                );
-                gp.rect_filled(Rect::from_min_max(pos2(graph_x0, r.min.y), r.max), 0.0, t.tl_bg);
-                // An effect's colour label (26.5): the same bar the Effect Controls header wears,
-                // on the row's left edge.
-                if let Some(lab) =
-                    group.filter(|g| matches!(g.kind, GroupKind::Effect { .. })).and_then(|g| g.label).filter(|l| *l != effectcraft_engine::color::Label::None)
-                {
-                    let [lr, lg, lb] = app.session.prefs.label_rgb(lab);
-                    let bar = Rect::from_min_size(left.min, vec2(4.0, left.height()));
-                    lp.rect_filled(bar, 0.0, Color32::from_rgb(lr, lg, lb));
-                    app.auto.add(&format!("timeline.group.{uid}.label"), bar, &app.session.prefs.label_name(lab));
-                }
-                let indent = cw.name + 6.0 + 14.0 * row.depth as f32;
-                if *has_children {
-                    let tw = Rect::from_center_size(pos2(indent, cy), vec2(13.0, 13.0));
-                    if widgets::twirl(ui, tw, *open, egui::Id::unique(("gtw", uid)), &t).clicked() {
-                        // Alt/Cmd-click twirls the whole tree under the group (as on a layer).
-                        ui_actions.push(UiAct::ToggleGroup(layer.id.0, *uid, ui.input(|i| i.modifiers.alt || i.modifiers.command)));
-                    }
-                    app.auto.add(&format!("timeline.group.{uid}.twirl"), tw, name);
-                }
-                if let Some(en) = fx {
-                    let fr_ = Rect::from_center_size(pos2(cw.switches + SW * 3.0 + SW / 2.0, cy), vec2(16.0, 16.0));
-                    if widgets::icon_toggle(ui, fr_, Icon::Fx, *en, &t, egui::Id::unique(("gfx", uid)), Sense::click()).clicked() {
-                        actions.push(("effect.toggle".into(), json!({"layer": layer.id.0, "effect": uid})));
-                    }
-                    app.auto.add(&format!("timeline.group.{uid}.fx"), fr_, name);
-                }
-                if let Some(en) = eye {
-                    // Layer style eye switch in the A/V column, like AE.
-                    let er = Rect::from_center_size(pos2(cw.av + 10.0, cy), vec2(15.0, 15.0));
-                    lp.rect_stroke(er.shrink(1.5), 2.0, Stroke::new(1.0, t.separator), StrokeKind::Inside);
-                    if widgets::icon_toggle(ui, er, Icon::Eye, *en, &t, egui::Id::unique(("geye", uid)), Sense::click()).clicked() {
-                        actions.push(("layer.style.toggle".into(), json!({"layer": layer.id.0, "style": uid})));
-                    }
-                    app.auto.add(&format!("timeline.group.{uid}.eye"), er, name);
-                }
-                let group_edit: Option<(u64, u64, String)> = ctx.data(|d| d.get_temp(group_rename_id()));
-                if let Some((_, _, mut buf)) = group_edit.filter(|(rl, ru, _)| *rl == layer.id.0 && ru == uid) {
-                    let field = Rect::from_min_max(pos2(indent + 8.0, r.min.y + 2.0), pos2((cw.switches - 4.0).max(indent + 60.0), r.max.y - 2.0));
-                    let mut child = ui.new_child(egui::UiBuilder::new().max_rect(field));
-                    let er = child.add(egui::TextEdit::singleline(&mut buf).font(Tokens::ui(12.0)).desired_width(field.width()));
-                    app.auto.add(&format!("timeline.group.{uid}.nameEdit"), er.rect, &buf);
-                    if ctx.data_mut(|d| d.remove_temp::(rename_focus_id())).unwrap_or(false) {
-                        er.request_focus();
-                        widgets::select_all(&ctx, er.id, &buf);
-                    }
-                    if er.lost_focus() {
-                        if !ui.input(|i| i.key_pressed(egui::Key::Escape)) && !buf.trim().is_empty() && buf != *name {
-                            actions.push(("prop.renameGroup".into(), json!({"layer": layer.id.0, "prop": uid, "name": buf})));
-                        }
-                        ctx.data_mut(|d| d.remove::<(u64, u64, String)>(group_rename_id()));
-                    } else {
-                        ctx.data_mut(|d| d.insert_temp(group_rename_id(), (layer.id.0, *uid, buf)));
-                    }
-                } else {
-                    lp.text(pos2(indent + 10.0, cy), Align2::LEFT_CENTER, &shown, Tokens::ui(12.0), t.text);
-                }
-                text_anim_popups(app, ui, &lp, layer, *uid, cw.switches, cy, &mut actions);
-                shape_add_popup(app, ui, &lp, layer, *uid, cw.switches, cy, &mut actions);
-                if *uid == GEOM_BIT | layer.id.0 {
-                    let lr = lp.text(pos2(cw.switches + 6.0, cy), Align2::LEFT_CENTER, crate::i18n::tr("Change Renderer…"), Tokens::ui(11.5), t.accent);
-                    let resp = ui
-                        .interact(lr, egui::Id::unique(("tl-change-renderer", layer.id.0)), Sense::click())
-                        .on_hover_text(crate::i18n::tr("Composition Settings ▸ 3D Renderer"));
-                    if resp.hovered() {
-                        ctx.set_cursor_icon(egui::CursorIcon::PointingHand);
-                    }
-                    app.auto.add(&format!("timeline.layer.{}.changeRenderer", layer.id.0), lr, "Change Renderer…");
-                    if resp.clicked() {
-                        ui_actions.push(UiAct::ChangeRenderer);
-                    }
-                }
-                dash_buttons(app, ui, &lp, layer, *uid, cw.switches, cy, &mut actions);
-                // Mask mode + inverted inline.
-                if let Some(g) = layer.props.find_group(*uid)
-                    && let GroupKind::Mask { mode, inverted, .. } = g.kind
-                {
-                    let mr = Rect::from_min_size(pos2(cw.switches + 4.0, cy - 9.0), vec2(80.0, 18.0));
-                    let pop = egui::Id::unique(("maskmode", uid));
-                    if widgets::dropdown(ui, mr, mode.label(), &t, egui::Id::unique(("mm", uid))).clicked() {
-                        widgets::open_popup(ui, pop);
-                    }
-                    let opts: Vec = effectcraft_engine::project::MaskMode::ALL.iter().map(|m| m.label().to_string()).collect();
-                    if let Some(i) = widgets::popup_menu(ui, pop, mr.left_bottom(), &opts, None) {
-                        actions.push(("layer.setMask".into(), json!({"layer": layer.id.0, "mask": uid, "mode": opts[i]})));
-                    }
-                    let ir = Rect::from_min_size(pos2(mr.max.x + 8.0, cy - 8.0), vec2(16.0, 16.0));
-                    if widgets::checkbox(ui, ir, inverted, &t, egui::Id::unique(("minv", uid))).clicked() {
-                        actions.push(("layer.setMask".into(), json!({"layer": layer.id.0, "mask": uid, "inverted": !inverted})));
-                    }
-                    lp.text(pos2(ir.max.x + 4.0, cy), Align2::LEFT_CENTER, crate::i18n::tr("Inverted"), Tokens::ui(11.0), t.text_dim);
-                }
-                let gr_rect = Rect::from_min_max(pos2(indent + 8.0, r.min.y), pos2(cw.switches, r.max.y));
-                let gr = ui.interact(gr_rect, egui::Id::unique(("grow", uid)), Sense::click());
-                app.auto.add(&format!("timeline.group.{uid}.name"), gr_rect, name);
-                if gr.clicked() && uid & (WAVE_BIT | GEOM_BIT) == 0 {
-                    actions.push(("prop.select".into(), json!({"layer": layer.id.0, "prop": uid, "selectKeys": false})));
-                }
-                if gr.double_clicked() {
-                    if fx.is_some() {
-                        // An effect's name: open it in Effect Controls (as in After Effects).
-                        actions.push(("layer.select".into(), json!({"layers": [layer.id.0]})));
-                        actions.push(("prop.select".into(), json!({"layer": layer.id.0, "prop": uid, "selectKeys": false})));
-                        actions.push(("window.panel".into(), json!({"panel": "EffectControls"})));
-                    } else {
-                        ui_actions.push(UiAct::ToggleGroup(layer.id.0, *uid, false));
-                    }
-                }
+            ctx.data_mut(|d| d.remove::<(u64, String)>(rename_id));
+        } else {
+            ctx.data_mut(|d| d.insert_temp(rename_id, (rl, buf)));
+        }
+    } else {
+        // The selected layer's name sits in a light cell with dark text.
+        let name_col = if is_sel { Color32::from_gray(0x16) } else { t.text };
+        if is_sel {
+            let g = lp.layout_no_wrap(display_name(app, layer), Tokens::ui(12.0), name_col);
+            let cell = Rect::from_min_max(
+                pos2(name_rect.min.x - 3.0, r.min.y + 1.0),
+                pos2((name_rect.min.x + g.size().x + 4.0).max(cw.name_end - 4.0).min(cw.name_end - 2.0), r.max.y - 1.0),
+            );
+            lp.rect_filled(cell, 0.0, SELECTED_NAME_BG);
+        }
+        let lpn = lp.with_clip_rect(name_rect.intersect(lp.clip_rect()));
+        lpn.text(pos2(name_rect.min.x, cy), Align2::LEFT_CENTER, display_name(app, layer), Tokens::ui(12.0), name_col);
+    }
+}
+
+/// Row click, drag, double-click and context menu.
+fn layer_row_actions(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>, row_resp: &egui::Response) {
+    let cid = g.tl.cid;
+    let ctx = g.tl.ctx.clone();
+    let comp = g.tl.comp.clone();
+    let rows = &g.tl.rows;
+    let actions = &mut g.tl.actions;
+    let selected = &g.selected;
+    let layer = rc.layer;
+    let left = rc.left;
+    let is_sel = rc.is_sel;
+    // Locked layers can't be selected or moved (unlock them with the lock switch).
+    let locked = layer.switches.locked;
+    if row_resp.drag_started() && !locked {
+        // The selected layers move together; an unselected row moves alone.
+        let moving: Vec = if is_sel { comp.layers.iter().filter(|l| selected.contains(&l.id)).map(|l| l.id.0).collect() } else { vec![layer.id.0] };
+        ctx.data_mut(|d| d.insert_temp(layer_drag_id(), moving));
+    }
+    if row_resp.drag_stopped() {
+        ctx.data_mut(|d| d.insert_temp(layer_drop_id(), true));
+    }
+    app.auto.add(&format!("timeline.layer.{}.row", layer.id.0), left, &layer.name);
+    if row_resp.clicked() && !locked {
+        let m = ui.input(|i| i.modifiers);
+        if let Some(layers) = m.shift.then(|| shift_range(&ctx, cid, &comp, rows, selected, layer.id)).flatten() {
+            actions.push(("layer.select".into(), json!({"layers": layers})));
+        } else {
+            actions.push(("layer.select".into(), json!({"layers": [layer.id.0], "toggle": m.command})));
+            set_anchor(&ctx, cid, layer.id);
+        }
+    }
+    if row_resp.double_clicked() {
+        match double_click_action(layer) {
+            Some(a) => actions.push(a),
+            None => start_rename(&ctx, layer.id.0, &layer.name),
+        }
+    }
+    layer_context_menu(row_resp, &comp, layer, selected, LayerMenuCaps::of(&app.session.project, layer), &mut app.auto, &mut *actions);
+}
+
+/// The eight layer switches.
+fn layer_switches(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>) {
+    let t = g.tl.t;
+    let vis = g.tl.vis;
+    let comp = g.tl.comp.clone();
+    let cw = &g.tl.cw;
+    let actions = &mut g.tl.actions;
+    let lp = g.lp.clone();
+    let selected = &g.selected;
+    let layer = rc.layer;
+    let r = rc.r;
+    let cy = rc.cy;
+    let sw = &layer.switches;
+    let sws: [(Icon, bool, &str, bool); 8] = [
+        (Icon::Shy, sw.shy, "shy", switch_applies(layer, "shy", &app.session.project)),
+        (Icon::Collapse, sw.collapse, "collapse", switch_applies(layer, "collapse", &app.session.project)),
+        (Icon::Quality, sw.quality == effectcraft_engine::project::Quality::Best, "quality", switch_applies(layer, "quality", &app.session.project)),
+        (Icon::Fx, sw.effects, "fx", switch_applies(layer, "fx", &app.session.project)),
+        (
+            Icon::FrameBlend,
+            sw.frame_blend != effectcraft_engine::project::FrameBlend::Off,
+            "frameBlend",
+            switch_applies(layer, "frameBlend", &app.session.project),
+        ),
+        (Icon::MotionBlur, sw.motion_blur, "motionBlur", switch_applies(layer, "motionBlur", &app.session.project)),
+        (Icon::Adjustment, sw.adjustment, "adjustment", switch_applies(layer, "adjustment", &app.session.project)),
+        (Icon::Cube, layer.is_3d(), "threeD", switch_applies(layer, "threeD", &app.session.project)),
+    ];
+    for (i, (icon, on, name, applicable)) in sws.into_iter().enumerate() {
+        if !applicable || !vis.switches {
+            continue;
+        }
+        let br = Rect::from_center_size(pos2(cw.switches + SW * i as f32 + SW / 2.0, cy), vec2(16.0, 16.0));
+        lp.rect_filled(br.shrink(1.0), 1.0, t.switch_well);
+        layer_switch(
+            ui,
+            &t,
+            &SwitchSpec { row: r, br, icon, on, id: egui::Id::unique(("sw", layer.id.0, i)), layer: layer.id.0, name, others: false },
+            &comp,
+            &app.session.project,
+            selected,
+            &mut *actions,
+        );
+        app.auto.add(&format!("timeline.layer.{}.switch.{name}", layer.id.0), br, name);
+    }
+}
+
+/// The blend mode dropdown and Preserve Transparency.
+fn layer_mode_cell(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>) {
+    let t = g.tl.t;
+    let comp = g.tl.comp.clone();
+    let cw = &g.tl.cw;
+    let actions = &mut g.tl.actions;
+    let selected = &g.selected;
+    let layer = rc.layer;
+    let cy = rc.cy;
+    let mr = Rect::from_min_size(pos2(cw.mode + 2.0, cy - 9.0), vec2(80.0, 18.0));
+    let mresp = widgets::dropdown(ui, mr, layer.blend_mode.label(), &t, egui::Id::unique(("mode", layer.id.0)));
+    app.auto.add(&format!("timeline.layer.{}.mode", layer.id.0), mr, "Mode");
+    let pop = egui::Id::unique(("mode-pop", layer.id.0));
+    if mresp.clicked() {
+        widgets::open_popup(ui, pop);
+    }
+    let opts: Vec =
+        BlendMode::ALL.iter().flat_map(|m| if m.ends_group() { vec![m.label().to_string(), "-".to_string()] } else { vec![m.label().to_string()] }).collect();
+    if let Some(i) = widgets::popup_menu(ui, pop, mr.left_bottom(), &opts, opts.iter().position(|o| o == layer.blend_mode.label())) {
+        actions.push(("layer.setBlendMode".into(), json!({"layers": mode_targets(&comp, layer.id, selected), "mode": opts[i]})));
+    }
+    let tr = Rect::from_center_size(pos2(cw.mode + 76.0 + 6.0, cy), vec2(14.0, 14.0));
+    app.auto.add(&format!("timeline.layer.{}.preserveTransparency", layer.id.0), tr, "Preserve Transparency");
+    if widgets::checkbox(ui, tr, layer.preserve_transparency, &t, egui::Id::unique(("pt", layer.id.0))).clicked() {
+        // The click gives the selection the state the clicked row toggles to,
+        // whatever the first selected layer's value (as the switches do).
+        actions.push((
+            "layer.setSwitch".into(),
+            json!({
+                "layers": mode_targets(&comp, layer.id, selected),
+                "switch": "preserveTransparency",
+                "value": !layer.preserve_transparency
+            }),
+        ));
+    }
+}
+
+/// The track matte picker.
+fn layer_matte_cell(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>) {
+    let t = g.tl.t;
+    let comp = g.tl.comp.clone();
+    let cw = &g.tl.cw;
+    let actions = &mut g.tl.actions;
+    let layer = rc.layer;
+    let cy = rc.cy;
+    // Track matte: layer picker + kind.
+    let mr = Rect::from_min_size(pos2(cw.trkmat + 2.0, cy - 9.0), vec2(104.0, 18.0));
+    let label = match layer.track_matte {
+        Some(m) => comp.layer(m.layer).map(|l| format!("{} {}", comp.index_of(l.id).unwrap_or(0), l.name)).unwrap_or("None".into()),
+        None => "No Track Matte".into(),
+    };
+    // The cell is too narrow for the mode as well: it is in the tooltip and ticked in the menu.
+    let tip = layer.track_matte.map(|m| format!("{label} · {}", crate::i18n::tr(m.kind.label())));
+    let tresp = widgets::dropdown_tip(ui, mr, &label, tip.as_deref(), &t, egui::Id::unique(("trkmat", layer.id.0)));
+    app.auto.add(&format!("timeline.layer.{}.trackMatte", layer.id.0), mr, "Track Matte");
+    let pop = egui::Id::unique(("trk-pop", layer.id.0));
+    if tresp.clicked() {
+        widgets::open_popup(ui, pop);
+    }
+    // The menu lists every layer: built only while it is open (per visible row
+    // it made a big comp's timeline quadratic).
+    let mut opts = vec!["No Track Matte".to_string(), "-".to_string()];
+    let mut candidates: Vec<&Layer> = vec![];
+    if widgets::popup_is_open(ui, pop) {
+        for (i, l) in comp.layers.iter().enumerate().filter(|(_, l)| l.id != layer.id && l.source.is_av()) {
+            opts.push(format!("{}. {}", i + 1, l.name));
+            candidates.push(l);
+        }
+        opts.push("-".into());
+        for k in MatteKind::ALL {
+            opts.push(k.label().into());
+        }
+    }
+    let current = layer.track_matte.and_then(|m| opts.iter().position(|o| o == m.kind.label()));
+    if let Some(i) = widgets::popup_menu(ui, pop, mr.left_bottom(), &opts, current) {
+        let ids: Vec = candidates.iter().map(|l| l.id).collect();
+        if let Some(a) = opts.get(i).and_then(|o| track_matte_pick(layer, &ids, i, o)) {
+            actions.push(a);
+        }
+    }
+    if layer.track_matte.is_some() {
+        let kr = Rect::from_center_size(pos2(mr.max.x - 26.0, cy), vec2(10.0, 10.0));
+        let _ = kr;
+    }
+}
+
+/// The parent pick whip and dropdown.
+fn layer_parent_cell(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>) {
+    let t = g.tl.t;
+    let comp = g.tl.comp.clone();
+    let cw = &g.tl.cw;
+    let actions = &mut g.tl.actions;
+    let lp = g.lp.clone();
+    let layer = rc.layer;
+    let cy = rc.cy;
+    pick_whip(
+        app,
+        ui,
+        &lp,
+        Rect::from_center_size(pos2(cw.parent + 9.0, cy), vec2(15.0, 15.0)),
+        (WHIP_PARENT, layer.id.0, 0),
+        WhipText {
+            auto: &format!("timeline.layer.{}.pickWhip", layer.id.0),
+            label: "Parent pick whip",
+            hint: crate::i18n::tr("Parent pick whip: drag onto a layer"),
+        },
+    );
+    let pr_rect = Rect::from_min_size(pos2(cw.parent + 20.0, cy - 9.0), vec2(94.0, 18.0));
+    let plabel = layer.parent.and_then(|p| comp.layer(p)).map(|l| format!("{}. {}", comp.index_of(l.id).unwrap_or(0), l.name)).unwrap_or_else(|| "None".into());
+    let presp = widgets::dropdown(ui, pr_rect, &plabel, &t, egui::Id::unique(("parent", layer.id.0)));
+    app.auto.add(&format!("timeline.layer.{}.parent", layer.id.0), pr_rect, "Parent");
+    let pop = egui::Id::unique(("par-pop", layer.id.0));
+    if presp.clicked() {
+        widgets::open_popup(ui, pop);
+    }
+    let mut cands: Vec<&Layer> = vec![];
+    let mut popts = vec!["None".to_string(), "-".to_string()];
+    if widgets::popup_is_open(ui, pop) {
+        for (i, l) in comp.layers.iter().enumerate().filter(|(_, l)| l.id != layer.id) {
+            popts.push(format!("{}. {}", i + 1, l.name));
+            cands.push(l);
+        }
+    }
+    if let Some(i) = widgets::popup_menu(ui, pop, pr_rect.left_bottom(), &popts, None) {
+        let par = if i == 0 { serde_json::Value::Null } else { json!(cands[i - 2].id.0) };
+        actions.push(("layer.setParent".into(), json!({"layers": [layer.id.0], "parent": par})));
+    }
+}
+
+/// Paints the layer bar; returns it and its label colour.
+fn layer_bar_paint(g: &Rw, rc: &RowCtx<'_>) -> (Rect, Color32) {
+    let t = g.tl.t;
+    let tm = g.tl.tm;
+    let gp = g.gp.clone();
+    let layer = rc.layer;
+    let r = rc.r;
+    let is_sel = rc.is_sel;
+    let x_in = tm.x(layer.in_point.seconds());
+    let x_out = tm.x(layer.out_point.seconds());
+    let bar = Rect::from_min_max(pos2(x_in, r.min.y + 1.0), pos2(x_out, r.max.y - 1.0));
+    let lc = t.label(layer.label);
+    let fill = bar_color(lc, is_sel);
+    gp.rect_filled(bar, 0.0, fill);
+    if is_sel {
+        gp.rect_stroke(bar, 0.0, Stroke::new(1.0, Color32::from_white_alpha(90)), StrokeKind::Inside);
+    }
+    (bar, lc)
+}
+
+/// Dragging the source bar outside the in/out span slips the source.
+fn slip_source_handles(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>, ghost: Rect, bar: Rect) {
+    let pps = g.tl.pps;
+    let fd = g.tl.fd;
+    let ctx = g.tl.ctx.clone();
+    let actions = &mut g.tl.actions;
+    let ui_actions = &mut g.ui_actions;
+    let layer = rc.layer;
+    // Dragging the source bar outside the in/out span slips the source.
+    for (side, gr) in [("l", Rect::from_min_max(ghost.min, pos2(bar.min.x, ghost.max.y))), ("r", Rect::from_min_max(pos2(bar.max.x, ghost.min.y), ghost.max))] {
+        if gr.width() < 2.0 {
+            continue;
+        }
+        let gresp =
+            ui.interact(gr, egui::Id::unique(("ghost", side, layer.id.0)), Sense::click_and_drag()).on_hover_text(crate::i18n::tr("Drag to slip the source"));
+        // Outside the bar it is empty track area: a plain click deselects.
+        if gresp.clicked() && !ui.input(|i| i.modifiers.shift || i.modifiers.command) {
+            actions.push(("layer.select".into(), json!({"layers": []})));
+        }
+        app.auto.add(&format!("timeline.layer.{}.source{}", layer.id.0, side), gr, "Slip");
+        if gresp.hovered() || gresp.dragged() {
+            ctx.set_cursor_icon(egui::CursorIcon::ResizeHorizontal);
+        }
+        let acc_id = egui::Id::unique(("slip-acc", layer.id.0));
+        if gresp.dragged() {
+            let mut acc: f32 = ctx.data(|d| d.get_temp(acc_id).unwrap_or(0.0));
+            acc += gresp.drag_delta().x;
+            let frames = ((acc as f64 / pps) / fd).trunc() as i64;
+            if frames != 0 {
+                acc -= (frames as f64 * fd * pps) as f32;
+                actions.push(("layer.slip".into(), json!({"layers": [layer.id.0], "frames": frames, "merge": format!("slip-{}", layer.id.0)})));
             }
-            RowKind::Waveform { item } => {
-                lp.rect_filled(left, 0.0, if ri % 2 == 0 { t.row } else { t.row_alt });
-                let indent = cw.name + 6.0 + 14.0 * row.depth as f32;
-                lp.text(pos2(indent + 10.0, cy), Align2::LEFT_CENTER, "Waveform", Tokens::ui(12.0), t.text);
-                let wr = Rect::from_min_max(pos2(graph_x0, r.min.y), r.max);
-                gp.rect_filled(wr, 0.0, t.tl_bg);
-                app.auto.add(&format!("timeline.layer.{}.waveform", layer.id.0), wr, "Waveform");
-                match super::waveform::summary(app, &ctx, ItemId(*item)) {
-                    Some(s) => super::waveform::draw(&gp, wr.shrink2(vec2(0.0, 2.0)), &tm, layer, &s, Color32::from_rgb(0x5f, 0xc8, 0x8a)),
-                    None => {
-                        gp.text(
-                            pos2(tm.x(layer.in_point.seconds()).max(wr.min.x) + 6.0, wr.center().y),
-                            Align2::LEFT_CENTER,
-                            "Building waveform…",
-                            Tokens::ui(11.0),
-                            t.text_faint,
-                        );
-                    }
-                }
-            }
-            RowKind::Expr { uid, lines } => {
-                let Some(prop) = layer.props.find(*uid) else { continue };
-                let Some(ex) = prop.expr.clone() else { continue };
-                let (buf_id, edit_id) = (expr_buf_id(*uid), egui::Id::unique(("expr-edit", uid)));
-                // The same background as its property's row, selection included.
-                let sel_prop = app.session.state.selected_props.contains(&(layer.id, *uid));
-                lp.rect_filled(
-                    left,
-                    0.0,
-                    if sel_prop {
-                        t.row_selected
-                    } else if ri % 2 == 0 {
-                        t.row
-                    } else {
-                        t.row_alt
-                    },
-                );
-                let indent = cw.name + 6.0 + 14.0 * row.depth as f32;
-                lp.text(pos2(indent + 12.0, cy), Align2::LEFT_CENTER, crate::i18n::tr("Expression:"), Tokens::ui(11.5), t.text_dim);
-                lp.text(pos2(indent + 82.0, cy), Align2::LEFT_CENTER, &prop.name, Tokens::ui(11.5), t.text);
-                // "=" enable switch and the property pick whip (AE's expression controls).
-                let en_r = Rect::from_center_size(pos2(cw.switches + 10.0, cy), vec2(16.0, 16.0));
-                let en = ui.interact(en_r, egui::Id::unique(("expr-en", uid)), Sense::click()).on_hover_text(crate::i18n::tr("Enable Expression"));
-                lp.rect_stroke(en_r.shrink(1.0), 2.0, Stroke::new(1.0, t.separator), StrokeKind::Inside);
-                let expr_col = t.expr_text;
-                lp.text(
-                    en_r.center(),
-                    Align2::CENTER_CENTER,
-                    if ex.enabled { "=" } else { "≠" },
-                    Tokens::semibold(13.0),
-                    if ex.enabled { expr_col } else { t.text_dim },
-                );
-                app.auto.add(&format!("timeline.prop.{uid}.exprEnable"), en_r, "Enable Expression");
-                if en.clicked() {
-                    actions.push(("prop.setExpression".into(), json!({"layer": layer.id.0, "prop": uid, "enabled": !ex.enabled})));
-                }
-                let pw = pick_whip(
-                    app,
-                    ui,
-                    &lp,
-                    Rect::from_center_size(pos2(cw.switches + 30.0, cy), vec2(15.0, 15.0)),
-                    (WHIP_EXPR, layer.id.0, *uid),
-                    &format!("timeline.prop.{uid}.pickWhip"),
-                    "Expression pick whip",
-                    crate::i18n::tr("Expression pick whip: drag onto a property"),
-                );
-                if pw.is_pointer_button_down_on() && ui.input(|i| i.pointer.primary_pressed()) {
-                    // Pressed while the expression is being edited (its text is buffered while
-                    // the editor has the focus): the reference will go in at the cursor,
-                    // replacing the selection (#284).
-                    let into = ctx.data(|d| d.get_temp::(buf_id)).map(|buf| {
-                        let n = buf.chars().count();
-                        let sel = egui::text_edit::TextEditState::load(&ctx, edit_id)
-                            .and_then(|st| st.cursor.char_range())
-                            .map_or([n, n], |r| [r.primary.index.0.min(r.secondary.index.0).min(n), r.primary.index.0.max(r.secondary.index.0).min(n)]);
-                        (buf, sel)
-                    });
-                    ctx.data_mut(|d| {
-                        if let Some(into) = into {
-                            d.insert_temp(whip_into_id(*uid), into);
-                        } else {
-                            d.remove::(whip_into_id(*uid));
-                        }
-                    });
-                }
-                // Syntax errors: warning in the outline (the expression is disabled).
-                if let Some(check) = app.session.expr_check
-                    && let Err(e) = check(&ex.text)
-                {
-                    let wr = Rect::from_center_size(pos2(cw.switches + 50.0, cy), vec2(14.0, 14.0));
-                    lp.text(wr.center(), Align2::CENTER_CENTER, "⚠", Tokens::ui(12.0), Color32::from_rgb(0xf0, 0xa0, 0x30));
-                    let _ = ui.interact(wr, egui::Id::unique(("expr-err", uid)), Sense::hover()).on_hover_text(e);
-                }
-                // ⓕ: the Expression Language menu inserts at the cursor.
-                let lang_r = Rect::from_center_size(pos2(cw.switches + 70.0, cy), vec2(16.0, 16.0));
-                let lang = ui.interact(lang_r, egui::Id::unique(("expr-lang", uid)), Sense::click()).on_hover_text(crate::i18n::tr("Expression Language Menu"));
-                lp.circle_stroke(lang_r.center(), 6.5, Stroke::new(1.0, if lang.hovered() { t.text } else { t.text_dim }));
-                lp.text(lang_r.center(), Align2::CENTER_CENTER, "f", Tokens::semibold(10.0), if lang.hovered() { t.text } else { t.text_dim });
-                app.auto.add(&format!("timeline.prop.{uid}.exprLanguage"), lang_r, "Expression Language Menu");
-                let mut picked: Option<&'static str> = None;
-                egui::Popup::menu(&lang).style(crate::widgets::menu_style).show(|ui| {
-                    for (cat, items) in effectcraft_engine::commands::expr_tools::language_menu() {
-                        ui.menu_button(cat, |ui| {
-                            widgets::menu_scroll(ui, false, |ui| {
-                                for (label, text) in items {
-                                    if ui.button(label).clicked() {
-                                        picked = Some(text);
-                                        ui.close();
-                                    }
-                                }
-                            })
-                        });
-                    }
-                });
-                if let Some(text) = picked {
-                    let cur: String = ctx.data(|d| d.get_temp(buf_id)).unwrap_or_else(|| ex.text.clone());
-                    let at = egui::text_edit::TextEditState::load(&ctx, edit_id)
-                        .and_then(|st| st.cursor.char_range())
-                        .map(|r| r.primary.index.0)
-                        .unwrap_or(cur.chars().count())
-                        .min(cur.chars().count());
-                    let byte = cur.char_indices().nth(at).map(|(b, _)| b).unwrap_or(cur.len());
-                    let mut next = cur.clone();
-                    next.insert_str(byte, text);
-                    actions.push(("prop.setExpression".into(), json!({"layer": layer.id.0, "prop": uid, "expression": next})));
-                    ctx.data_mut(|d| d.remove::(buf_id));
-                }
-                // The editor in the time-graph area.
-                ui.set_clip_rect(right_clip);
-                gp.rect_filled(Rect::from_min_max(pos2(graph_x0, r.min.y), r.max), 0.0, t.expr_bg);
-                let er = Rect::from_min_max(pos2(graph_x0 + 8.0, r.min.y + 3.0), pos2(rect.max.x - 14.0, r.max.y - 3.0));
-                let mut buf: String = ctx.data(|d| d.get_temp(buf_id)).unwrap_or_else(|| ex.text.clone());
-                if let Some(f) = ctx.data(|d| d.get_temp::(expr_focus_id())).filter(|f| f.prop == *uid) {
-                    // Asked for once (a pick whip put a reference in, or an Alt-click added the
-                    // expression): editing starts in the expression as it is now.
-                    ctx.data_mut(|d| d.remove::(expr_focus_id()));
-                    buf = ex.text.clone();
-                    let n = buf.chars().count();
-                    let [a, b] = f.select.map(|before_end| egui::text::CCursor::new(n.saturating_sub(before_end)));
-                    let mut st = egui::text_edit::TextEditState::load(&ctx, edit_id).unwrap_or_default();
-                    st.cursor.set_char_range(Some(egui::text::CCursorRange::two(a, b)));
-                    st.store(&ctx, edit_id);
-                    ctx.memory_mut(|m| m.request_focus(edit_id));
-                }
-                // Settings ▸ Scripting & Expressions ▸ Expressions Editor.
-                let sp = app.session.prefs.scripting.clone();
-                let resp = super::expr_editor::editor(ui, edit_id, &mut buf, er, &sp, if ex.enabled { expr_col } else { t.text_dim }, *lines, &t);
-                app.auto.add(&format!("timeline.prop.{uid}.expression"), er, &prop.name);
-                // Its bottom edge drags to set its height in whole lines, as in After Effects.
-                let edge = Rect::from_min_max(pos2(er.min.x, r.max.y - 4.0), pos2(er.max.x, r.max.y));
-                let eresp = ui.interact(edge, egui::Id::unique(("expr-edge", uid)), Sense::drag());
-                app.auto.add(&format!("timeline.prop.{uid}.expressionHeight"), edge, "Expression field height");
-                if eresp.hovered() || eresp.dragged() {
-                    ctx.set_cursor_icon(egui::CursorIcon::ResizeVertical);
-                }
-                if let Some(py) = eresp.interact_pointer_pos().filter(|_| eresp.dragged()).map(|p| p.y) {
-                    let n = ((py - r.min.y) / rh).round().clamp(1.0, EXPR_MAX_LINES as f32);
-                    app.ui.timeline.expr_lines.insert(*uid, n as usize);
-                }
-                let commit = resp.has_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter) && (i.modifiers.command || i.modifiers.ctrl));
-                if commit {
-                    resp.surrender_focus();
-                }
-                if resp.has_focus() && !commit {
-                    ctx.data_mut(|d| d.insert_temp(buf_id, buf));
-                } else {
-                    if (resp.lost_focus() || commit) && buf.trim_end() != ex.text {
-                        actions.push(("prop.setExpression".into(), json!({"layer": layer.id.0, "prop": uid, "expression": buf.trim_end()})));
-                    }
-                    ctx.data_mut(|d| d.remove::(buf_id));
-                }
-            }
-            RowKind::Prop { uid } => {
-                let Some(prop) = layer.props.find(*uid) else { continue };
-                app.auto.add(&format!("timeline.prop.{uid}.row"), r, &prop.name);
-                let sel_prop = app.session.state.selected_props.contains(&(layer.id, *uid));
-                lp.rect_filled(
-                    left,
-                    0.0,
-                    if sel_prop {
-                        t.row_selected
-                    } else if ri % 2 == 0 {
-                        t.row
-                    } else {
-                        t.row_alt
-                    },
-                );
-                gp.rect_filled(Rect::from_min_max(pos2(graph_x0, r.min.y), r.max), 0.0, t.tl_bg);
-                let indent = cw.name + 6.0 + 14.0 * row.depth as f32;
-                // Keyframe navigator (in the A/V column, like AE).
-                // (in the Keys column when it is shown).
-                if prop.is_animated() && (vis.keys || vis.av) {
-                    let lt = layer.layer_time(time);
-                    let at_key = effectcraft_engine::keyframe::key_at(&prop.keys, lt).is_some();
-                    let nav_x = if vis.keys { cw.keys + 12.0 } else { cw.av + 18.0 };
-                    let prev = Rect::from_center_size(pos2(nav_x, cy), vec2(12.0, 14.0));
-                    let mid = Rect::from_center_size(pos2(nav_x + 16.0, cy), vec2(14.0, 14.0));
-                    let next = Rect::from_center_size(pos2(nav_x + 32.0, cy), vec2(12.0, 14.0));
-                    icons::paint(&lp, prev.shrink(2.0), Icon::ChevronLeft, t.text_dim);
-                    icons::paint(&lp, next.shrink(2.0), Icon::ChevronRight, t.text_dim);
-                    let kc = if at_key { t.keyframe_selected } else { Color32::TRANSPARENT };
-                    lp.add(egui::Shape::convex_polygon(
-                        vec![mid.center() + vec2(0.0, -5.0), mid.center() + vec2(5.0, 0.0), mid.center() + vec2(0.0, 5.0), mid.center() + vec2(-5.0, 0.0)],
-                        kc,
-                        Stroke::new(1.0, t.text_dim),
-                    ));
-                    if ui.interact(prev, egui::Id::unique(("kprev", uid)), Sense::click()).clicked()
-                        && let Some(k) = prop.keys.iter().rev().find(|k| layer.comp_time(k.time).0 < time.0 - fr.frame_duration().0 / 2)
-                    {
-                        ui_actions.push(UiAct::SetTime(layer.comp_time(k.time)));
-                    }
-                    if ui.interact(next, egui::Id::unique(("knext", uid)), Sense::click()).clicked()
-                        && let Some(k) = prop.keys.iter().find(|k| layer.comp_time(k.time).0 > time.0 + fr.frame_duration().0 / 2)
-                    {
-                        ui_actions.push(UiAct::SetTime(layer.comp_time(k.time)));
-                    }
-                    if ui.interact(mid, egui::Id::unique(("kmid", uid)), Sense::click()).clicked() {
-                        actions.push(("prop.toggleKey".into(), json!({"layer": layer.id.0, "prop": uid})));
-                    }
-                    app.auto.add(&format!("timeline.prop.{uid}.addKey"), mid, "Add or remove keyframe");
-                }
-                // Stopwatch: Alt+click adds an expression; a press toggles the animation, and
-                // dragging on over other stopwatches gives them the same state (#284).
-                if !prop.static_only {
-                    let swr = Rect::from_center_size(pos2(indent, cy), vec2(15.0, 15.0));
-                    let resp = ui.interact(swr, egui::Id::unique(("stopwatch", uid)), Sense::click_and_drag());
-                    let col = if prop.is_animated() {
-                        t.hot_text
-                    } else if resp.hovered() {
-                        t.text
-                    } else {
-                        t.text_dim
-                    };
-                    icons::paint(&lp, swr, Icon::Stopwatch, col);
-                    app.auto.add(&format!("timeline.prop.{uid}.stopwatch"), swr, &prop.name);
-                    if ui.input(|i| i.modifiers.alt) {
-                        if resp.clicked() {
-                            actions.push(("prop.setExpression".into(), json!({"layer": layer.id.0, "prop": uid})));
-                            if prop.expr.is_none() {
-                                // Adding one: its editor opens, comes into view and takes the
-                                // keyboard with the new expression selected (#1072).
-                                app.ui.timeline.expr_closed.remove(uid);
-                                focus_expression(&ctx, *uid, [usize::MAX, 0]);
-                            } else if ctx.data(|d| d.get_temp::(expr_focus_id())).is_some_and(|f| f.prop == *uid) {
-                                // Removing it: nothing is left to take the keyboard.
-                                ctx.data_mut(|d| d.remove::(expr_focus_id()));
-                            }
-                        }
-                    } else if let Some((value, merge)) = switch_gesture(ui, &resp, r, "stopwatch", prop.is_animated()) {
-                        actions.push(("prop.toggleAnimation".into(), json!({"layer": layer.id.0, "prop": uid, "value": value, "merge": merge})));
-                    } else if resp.clicked() && !resp.clicked_by(egui::PointerButton::Primary) {
-                        // Keyboard or accessibility activation.
-                        actions.push(("prop.toggleAnimation".into(), json!({"layer": layer.id.0, "prop": uid})));
-                    }
-                }
-                let name_x = indent + 12.0;
-                // 3D layers show Rotation as "Z Rotation" next to X/Y Rotation.
-                let is_tr_rot = layer.transform().and_then(|t| t.get("rotation")).is_some_and(|r| r.uid == prop.uid);
-                let base = if is_tr_rot && prop.name == "Rotation" && layer.is_3d() { "Z Rotation".to_string() } else { prop.name.clone() };
-                let pname = if prop.has_expression() { format!("{base}  =") } else { base };
-                lp.text(pos2(name_x, cy), Align2::LEFT_CENTER, &pname, Tokens::ui(12.0), if prop.has_expression() { t.expr_text } else { t.text });
-                let name_rect = Rect::from_min_max(pos2(name_x, r.min.y), pos2(cw.switches - 2.0, r.max.y));
-                let name_resp = ui.interact(name_rect, egui::Id::unique(("pname", uid)), Sense::click_and_drag());
-                app.auto.add(&format!("timeline.prop.{uid}.name"), name_rect, &prop.name);
-                if name_resp.drag_started() {
-                    // Drop on the Essential Graphics panel to expose the property.
-                    egui::DragAndDrop::set_payload(&ctx, crate::panels::DragPayload::Property { layer: layer.id.0, prop: *uid });
-                }
-                if name_resp.dragged() {
-                    egui::Tooltip::always_open(ctx.clone(), ui.layer_id(), egui::Id::unique("prop-drag-tip"), egui::PopupAnchor::Pointer)
-                        .show(|ui| ui.label(&prop.name));
-                }
-                if name_resp.clicked() {
-                    actions.push(("prop.select".into(), json!({"layer": layer.id.0, "prop": uid, "add": ui.input(|i| i.modifiers.shift)})));
-                }
-                crate::widgets::context_menu(&name_resp, |ui| prop_menu(ui, &mut app.auto, cid, layer, prop, &mut actions));
-                // Value editors.
-                let value = ectx.value(layer, prop);
-                let vx = (cw.switches + 4.0).max(name_x + 120.0);
-                let dims = value_editor(app, ui, &lp, layer, prop, &value, pos2(vx, cy), &mut actions);
-                if !dims.is_empty() {
-                    dim_hits.push((*uid, dims));
-                }
-                // The property pick whip (Parent & Link column, AE CC 2018 and later): dropped on
-                // a property it links this one to it, adding the expression if there is none.
-                if vis.parent && !prop.static_only {
-                    pick_whip(
-                        app,
-                        ui,
-                        &lp,
-                        Rect::from_center_size(pos2(cw.parent + 9.0, cy), vec2(15.0, 15.0)),
-                        (WHIP_PROP, layer.id.0, *uid),
-                        &format!("timeline.prop.{uid}.propertyPickWhip"),
-                        "Property pick whip",
-                        crate::i18n::tr("Property pick whip: drag onto a property"),
-                    );
-                }
-                ui.set_clip_rect(right_clip);
-                // Expression text row hint.
-                if let Some(e) = prop.expr.as_ref().filter(|e| e.enabled && app.ui.timeline.expr_closed.contains(uid)) {
-                    gp.text(
-                        pos2(graph_x0 + 8.0, cy),
-                        Align2::LEFT_CENTER,
-                        format!("= {}", e.text.lines().next().unwrap_or("")),
-                        Tokens::mono(11.0),
-                        t.expr_text,
-                    );
-                }
-                // Keyframes.
-                if !graph_on {
-                    for k in &prop.keys {
-                        let ct = layer.comp_time(k.time);
-                        let x = tm.x(ct.seconds());
-                        if x < graph_x0 - 8.0 || x > rect.max.x + 8.0 {
-                            continue;
-                        }
-                        let kref = effectcraft_engine::KeyRef { layer: layer.id, prop: *uid, time: k.time };
-                        let ks = sel_keys.contains(&kref);
-                        let icon = k.icon();
-                        let c = pos2(x, cy);
-                        // Labelled keys take their label colour (brighter when selected).
-                        let kcol = match effectcraft_engine::color::Label::ALL.get(k.label as usize).filter(|_| k.label > 0) {
-                            Some(l) => {
-                                let c = t.label(*l);
-                                if ks { c } else { c.gamma_multiply(0.75) }
-                            }
-                            None if ks => t.keyframe_selected,
-                            None => t.keyframe,
-                        };
-                        if k.roving {
-                            // Roving keys: a small dot (their time follows their neighbours).
-                            gp.circle_filled(c, 2.5, kcol);
-                        } else {
-                            icons::keyframe(&gp, c, 11.0, icon.left, icon.right, kcol);
-                        }
-                        let kr = Rect::from_center_size(c, vec2(12.0, 14.0));
-                        let kresp = ui.interact(kr, egui::Id::unique(("key", uid, k.time.0)), Sense::click_and_drag());
-                        app.auto.add(&format!("timeline.key.{uid}.{}", fr.frame_at(ct)), kr, &format!("{} key", prop.name));
-                        // Exact (layer-time ticks): keys a Quick Offset puts on subframes can share a frame.
-                        app.auto.add(&format!("timeline.keyAt.{uid}.{}", k.time.0), kr, &format!("{} key", prop.name));
-                        let mods = ui.input(|i| i.modifiers);
-                        let this_key = json!({"keys": [{"layer": layer.id.0, "prop": uid, "time": k.time.seconds()}], "selectProperties": true});
-                        if kresp.clicked() && mods.command {
-                            // Ctrl+click: Linear ↔ Auto Bezier; Ctrl+Alt+click: Hold on / off.
-                            actions.push(("keys.select".into(), this_key.clone()));
-                            if mods.alt {
-                                actions.push(("keys.toggleHold".into(), json!({})));
-                            } else {
-                                let linear = k.in_interp == Interp::Linear && k.out_interp == Interp::Linear;
-                                actions.push(("keys.interpolation".into(), json!({"interpolation": if linear { "autoBezier" } else { "linear" }})));
-                            }
-                        } else if kresp.clicked() && mods.shift {
-                            // Shift+click: in or out of the selection.
-                            let mut p = this_key.clone();
-                            p["toggle"] = json!(true);
-                            actions.push(("keys.select".into(), p));
-                        } else if (kresp.clicked() || kresp.drag_started()) && !ks {
-                            let mut p = this_key.clone();
-                            p["add"] = json!(mods.shift);
-                            actions.push(("keys.select".into(), p));
-                        }
-                        if kresp.double_clicked() {
-                            // Edit the key's value (numeric properties), as After Effects does.
-                            if matches!(k.value, Value::Scalar(_) | Value::Vec2(_) | Value::Vec3(_)) {
-                                actions.push(("keys.set".into(), json!({"layer": layer.id.0, "prop": uid, "time": k.time.seconds()})));
-                            } else {
-                                ui_actions.push(UiAct::SetTime(ct));
-                            }
-                        }
-                        crate::widgets::context_menu(&kresp, |ui| {
-                            for (lbl, cmd, params) in [
-                                ("Edit Value…", "keys.set", json!({"layer": layer.id.0, "prop": uid, "time": k.time.seconds()})),
-                                ("-", "", json!({})),
-                                ("Copy", "keys.copy", json!({})),
-                                ("Paste", "keys.paste", json!({})),
-                                ("-", "", json!({})),
-                                ("Select Equal Keyframes", "keys.selectEqual", json!({})),
-                                ("Select Previous Keyframes", "keys.selectPrevious", json!({})),
-                                ("Select Following Keyframes", "keys.selectFollowing", json!({})),
-                                ("-", "", json!({})),
-                                ("Keyframe Interpolation…", "keys.interpolation", json!({})),
-                                ("Keyframe Velocity…", "keys.velocity", json!({})),
-                                ("Toggle Hold Keyframe", "keys.toggleHold", json!({})),
-                                ("Rove Across Time", "keys.interpolation", json!({"roving": !k.roving})),
-                                ("-", "", json!({})),
-                                ("Easy Ease", "keys.easyEase", json!({})),
-                                ("Easy Ease In", "keys.easyEaseIn", json!({})),
-                                ("Easy Ease Out", "keys.easyEaseOut", json!({})),
-                                ("Time-Reverse Keyframes", "keys.timeReverse", json!({})),
-                                ("-", "", json!({})),
-                                ("Linear", "keys.interpolation", json!({"interpolation": "linear"})),
-                                ("Bezier", "keys.interpolation", json!({"interpolation": "bezier"})),
-                                ("Auto Bezier", "keys.interpolation", json!({"interpolation": "autoBezier"})),
-                                ("Select All Keyframes", "keys.selectAll", json!({})),
-                                ("Delete", "keys.delete", json!({})),
-                            ] {
-                                if lbl == "Select All Keyframes" {
-                                    // Keyframe colour labels.
-                                    ui.menu_button(crate::i18n::tr("Label"), |ui| {
-                                        for (i, l) in effectcraft_engine::color::Label::ALL.into_iter().enumerate() {
-                                            if widgets::label_entry(ui, l, &app.session.prefs.label_name(l), k.label as usize == i, &t).clicked() {
-                                                if !ks {
-                                                    actions.push((
-                                                        "keys.select".into(),
-                                                        json!({"keys": [{"layer": layer.id.0, "prop": uid, "time": k.time.seconds()}]}),
-                                                    ));
-                                                }
-                                                actions.push(("keys.setLabel".into(), json!({"label": i})));
-                                                ui.close();
-                                            }
-                                        }
-                                    });
-                                }
-                                if lbl == "-" {
-                                    ui.separator();
-                                    continue;
-                                }
-                                if lbl == "Rove Across Time" && !prop.spatial {
-                                    continue;
-                                }
-                                if lbl == "Edit Value…" && !matches!(k.value, Value::Scalar(_) | Value::Vec2(_) | Value::Vec3(_)) {
-                                    continue;
-                                }
-                                if ui.button(lbl).clicked() {
-                                    if !ks {
-                                        actions.push(("keys.select".into(), json!({"keys": [{"layer": layer.id.0, "prop": uid, "time": k.time.seconds()}]})));
-                                    }
-                                    actions.push((cmd.into(), params));
-                                    ui.close();
-                                }
-                            }
-                        });
-                        // Alt-drag the first or last key of a selected group: scale the group in time.
-                        if kresp.drag_started()
-                            && mods.alt
-                            && !mods.command
-                            && let Some(c) = app.session.active_comp_arc()
-                        {
-                            super::graph_tools::alt_scale_begin(app, &ctx, &c, ct.seconds());
-                        }
-                        // A plain drag moves the selected keys. The timeline follows it (below): this
-                        // widget's id changes with the key's time, so its own drag would end after
-                        // the first frame of movement.
-                        // (From where the button went down: a drag starts a few pixels later.)
-                        if kresp.drag_started()
-                            && !super::graph_tools::alt_scale_active(&ctx)
-                            && let Some(p) = ctx.input(|i| i.pointer.press_origin())
-                        {
-                            // Ctrl/Cmd+Alt: Quick Offset of the selected keys' layers (#1012).
-                            let quick = (mods.command && mods.alt && ks).then(|| quick_offset_layers(&comp, sel_keys.iter().map(|k| k.layer))).flatten();
-                            ctx.data_mut(|d| d.insert_temp(key_drag_id(), KeyDrag { origin_x: p.x, anchor: ct.seconds(), applied: 0, quick }));
-                        }
-                    }
+            ctx.data_mut(|d| d.insert_temp(acc_id, acc));
+        }
+        if gresp.drag_stopped() {
+            ctx.data_mut(|d| d.remove::(acc_id));
+            ui_actions.push(UiAct::EndMerge);
+        }
+    }
+}
+
+/// The source extent behind the bar (striped past its ends).
+fn layer_source_extent(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>, bar: Rect, lc: Color32) {
+    let tm = g.tl.tm;
+    let gp = g.gp.clone();
+    let layer = rc.layer;
+    let r = rc.r;
+    if let LayerSource::Footage { .. } | LayerSource::Comp { .. } = layer.source
+        && let Some(d) = layer.source.item().and_then(|i| app.session.project.item(i)).and_then(|i| i.duration())
+    {
+        let xs = tm.x(layer.start_time.seconds());
+        let xe = tm.x(layer.comp_time(d).seconds());
+        let ghost = Rect::from_min_max(pos2(xs.min(xe), r.min.y + 9.0), pos2(xs.max(xe), r.max.y - 9.0));
+        gp.rect_filled(ghost, 1.0, lc.gamma_multiply(0.18));
+        // The bar past the source's end (its last frame held) or before its start
+        // is striped, so the source's own span shows (#290). Time remapping
+        // decides what shows there itself.
+        if layer.props.get("timeRemap").is_none() {
+            for (side, part) in [
+                ("beforeSource", Rect::from_min_max(bar.min, pos2(ghost.min.x, bar.max.y))),
+                ("afterSource", Rect::from_min_max(pos2(ghost.max.x, bar.min.y), bar.max)),
+            ] {
+                if part.width() >= 1.0 {
+                    stripes(&gp, part, lc);
+                    app.auto.add(&format!("timeline.layer.{}.bar.{side}", layer.id.0), part, "Past the source");
                 }
             }
         }
+        slip_source_handles(app, ui, g, rc, ghost, bar);
     }
+}
+
+/// The bar's move body and trim edges, click selection.
+fn layer_bar_hits(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>, bar: Rect) -> BarResps {
+    let cid = g.tl.cid;
+    let ctx = g.tl.ctx.clone();
+    let comp = g.tl.comp.clone();
+    let rows = &g.tl.rows;
+    let actions = &mut g.tl.actions;
+    let selected = &g.selected;
+    let layer = rc.layer;
+    let is_sel = rc.is_sel;
+    app.auto.add(&format!("timeline.layer.{}.bar", layer.id.0), bar, &layer.name);
+    // Interactions: move body, trim edges.
+    let (body_r, in_r, out_r) = trim_zones(bar);
+    let body = ui.interact(body_r, egui::Id::unique(("bar", layer.id.0)), Sense::click_and_drag());
+    let lin = ui.interact(in_r, egui::Id::unique(("bar-in", layer.id.0)), Sense::drag());
+    let lout = ui.interact(out_r, egui::Id::unique(("bar-out", layer.id.0)), Sense::drag());
+    app.auto.add(&format!("timeline.layer.{}.bar.in", layer.id.0), in_r, "Trim In");
+    app.auto.add(&format!("timeline.layer.{}.bar.out", layer.id.0), out_r, "Trim Out");
+    if lin.hovered() || lout.hovered() || lin.dragged() || lout.dragged() {
+        trim_cursor(&ctx);
+    }
+    if body.double_clicked()
+        && let Some(a) = double_click_action(layer)
+    {
+        actions.push(a);
+    }
+    let shift = ui.input(|i| i.modifiers.shift);
+    if let Some(layers) = (body.clicked() && shift).then(|| shift_range(&ctx, cid, &comp, rows, selected, layer.id)).flatten() {
+        // Shift-click: the layers from the last one clicked to this one, as in
+        // the layer outline.
+        actions.push(("layer.select".into(), json!({"layers": layers})));
+    } else {
+        if (body.clicked() || body.drag_started()) && !is_sel {
+            actions.push(("layer.select".into(), json!({"layers": [layer.id.0], "add": shift})));
+        }
+        if (body.clicked() || body.drag_started()) && !shift {
+            set_anchor(&ctx, cid, layer.id);
+        }
+    }
+    layer_context_menu(&body, &comp, layer, selected, LayerMenuCaps::of(&app.session.project, layer), &mut app.auto, &mut *actions);
+    BarResps { body, lin, lout }
+}
+
+fn layer_quick_offset_begin(ui: &mut egui::Ui, g: &Rw, rc: &RowCtx<'_>, body: &egui::Response) -> bool {
+    let ctx = g.tl.ctx.clone();
+    let comp = g.tl.comp.clone();
+    let selected = &g.selected;
+    let layer = rc.layer;
+    let is_sel = rc.is_sel;
+    let mods = ui.input(|i| i.modifiers);
+    if body.drag_started()
+        && is_sel
+        && mods.command
+        && mods.alt
+        && let Some(n) = quick_offset_layers(&comp, selected.iter().copied())
+        && let Some(p) = ctx.input(|i| i.pointer.press_origin())
+    {
+        ctx.data_mut(|d| d.insert_temp(quick_bar_id(), QuickBar { layer: layer.id, origin_x: p.x, layers: n, applied: 0 }));
+    }
+    ctx.data(|d| d.get_temp::(quick_bar_id())).is_some_and(|q| q.layer == layer.id)
+}
+
+/// Moving and trimming a layer bar by whole frames.
+fn layer_bar_drag(g: &mut Rw, rc: &RowCtx<'_>, bars: &BarResps, quick: bool) {
+    let BarResps { body, lin, lout } = bars;
+    let pps = g.tl.pps;
+    let fd = g.tl.fd;
+    let ctx = g.tl.ctx.clone();
+    let actions = &mut g.tl.actions;
+    let selected = &g.selected;
+    let ui_actions = &mut g.ui_actions;
+    let layer = rc.layer;
+    let is_sel = rc.is_sel;
+    let drag_key = format!("bar-{}", layer.id.0);
+    for (resp, kind) in [(&body, "move"), (&lin, "in"), (&lout, "out")] {
+        if kind == "move" && quick {
+            continue;
+        }
+        if resp.dragged() {
+            let acc_id = egui::Id::unique(("bar-acc", layer.id.0, kind));
+            let mut acc: f32 = ctx.data(|d| d.get_temp(acc_id).unwrap_or(0.0));
+            acc += resp.drag_delta().x;
+            let frames = ((acc as f64 / pps) / fd).trunc() as i64;
+            if frames != 0 {
+                acc -= (frames as f64 * fd * pps) as f32;
+                let d = frames as f64 * fd;
+                // Moving or trimming a selected layer acts on the whole
+                // selection; each layer trims from its own in/out point.
+                let targets = if is_sel { json!(selected.iter().map(|l| l.0).collect::>()) } else { json!([layer.id.0]) };
+                let params = match kind {
+                    "move" => json!({"layers": targets, "delta": d, "merge": drag_key}),
+                    "in" => json!({"layers": targets, "inDelta": d, "merge": drag_key}),
+                    _ => json!({"layers": targets, "outDelta": d, "merge": drag_key}),
+                };
+                actions.push(("layer.timing".into(), params));
+            }
+            ctx.data_mut(|d| d.insert_temp(acc_id, acc));
+        }
+        if resp.drag_stopped() {
+            ctx.data_mut(|d| d.remove::(egui::Id::unique(("bar-acc", layer.id.0, kind))));
+            ui_actions.push(UiAct::EndMerge);
+        }
+    }
+}
+
+fn layer_bar_markers(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &Rw, rc: &RowCtx<'_>) {
+    let graph_x0 = g.tl.graph_x0;
+    let tm = g.tl.tm;
+    let rows_rect = g.tl.rows_rect;
+    let comp = g.tl.comp.clone();
+    let layer = rc.layer;
+    let r = rc.r;
+    let gclip = rows_rect.intersect(Rect::from_min_max(pos2(graph_x0, rows_rect.min.y), rows_rect.max));
+    super::markers_ui::layer_markers(app, ui, gclip, &comp, layer, tm, r);
+}
+
+/// The group row's background and effect label bar; returns the name to draw.
+fn group_background(app: &mut EffectcraftApp, g: &Rw, rc: &RowCtx<'_>) -> String {
+    let t = g.tl.t;
+    let graph_x0 = g.tl.graph_x0;
+    let lp = g.lp.clone();
+    let gp = g.gp.clone();
+    let layer = rc.layer;
+    let r = rc.r;
+    let left = rc.left;
+    let ri = rc.ri;
+    let RowKind::Group { uid, name, .. } = &rc.row.kind else { return String::new() };
+    let group = layer.props.find_group(*uid);
+    let shown = match group.and_then(|g| match &g.kind {
+        GroupKind::Effect { effect } => Some(effect.as_str()),
+        _ => None,
+    }) {
+        Some(effect) => crate::i18n::effects::group(effect, name),
+        None => name.clone(),
+    };
+    // Selected groups (an effect, a mask, a shape group…) highlight like selected
+    // property rows.
+    let sel_group = app.session.state.selected_props.contains(&(layer.id, *uid));
+    lp.rect_filled(
+        left,
+        0.0,
+        if sel_group {
+            t.row_selected
+        } else if ri.is_multiple_of(2) {
+            t.row
+        } else {
+            t.row_alt
+        },
+    );
+    gp.rect_filled(Rect::from_min_max(pos2(graph_x0, r.min.y), r.max), 0.0, t.tl_bg);
+    // An effect's colour label (26.5): the same bar the Effect Controls header wears,
+    // on the row's left edge.
+    if let Some(lab) =
+        group.filter(|g| matches!(g.kind, GroupKind::Effect { .. })).and_then(|g| g.label).filter(|l| *l != effectcraft_engine::color::Label::None)
+    {
+        let [lr, lg, lb] = app.session.prefs.label_rgb(lab);
+        let bar = Rect::from_min_size(left.min, vec2(4.0, left.height()));
+        lp.rect_filled(bar, 0.0, Color32::from_rgb(lr, lg, lb));
+        app.auto.add(&format!("timeline.group.{uid}.label"), bar, &app.session.prefs.label_name(lab));
+    }
+    shown
+}
+
+/// The group's twirl, effect switch and style eye.
+fn group_switches(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>) {
+    let t = g.tl.t;
+    let cw = &g.tl.cw;
+    let actions = &mut g.tl.actions;
+    let lp = g.lp.clone();
+    let ui_actions = &mut g.ui_actions;
+    let layer = rc.layer;
+    let cy = rc.cy;
+    let indent = rc.indent;
+    let RowKind::Group { uid, name, open, has_children, fx, eye, .. } = &rc.row.kind else { return };
+    if *has_children {
+        let tw = Rect::from_center_size(pos2(indent, cy), vec2(13.0, 13.0));
+        if widgets::twirl(ui, tw, *open, egui::Id::unique(("gtw", uid)), &t).clicked() {
+            // Alt/Cmd-click twirls the whole tree under the group (as on a layer).
+            ui_actions.push(UiAct::ToggleGroup(layer.id.0, *uid, ui.input(|i| i.modifiers.alt || i.modifiers.command)));
+        }
+        app.auto.add(&format!("timeline.group.{uid}.twirl"), tw, name);
+    }
+    if let Some(en) = fx {
+        let fr_ = Rect::from_center_size(pos2(cw.switches + SW * 3.0 + SW / 2.0, cy), vec2(16.0, 16.0));
+        if widgets::icon_toggle(ui, fr_, Icon::Fx, *en, &t, egui::Id::unique(("gfx", uid)), Sense::click()).clicked() {
+            actions.push(("effect.toggle".into(), json!({"layer": layer.id.0, "effect": uid})));
+        }
+        app.auto.add(&format!("timeline.group.{uid}.fx"), fr_, name);
+    }
+    if let Some(en) = eye {
+        // Layer style eye switch in the A/V column, like AE.
+        let er = Rect::from_center_size(pos2(cw.av + 10.0, cy), vec2(15.0, 15.0));
+        lp.rect_stroke(er.shrink(1.5), 2.0, Stroke::new(1.0, t.separator), StrokeKind::Inside);
+        if widgets::icon_toggle(ui, er, Icon::Eye, *en, &t, egui::Id::unique(("geye", uid)), Sense::click()).clicked() {
+            actions.push(("layer.style.toggle".into(), json!({"layer": layer.id.0, "style": uid})));
+        }
+        app.auto.add(&format!("timeline.group.{uid}.eye"), er, name);
+    }
+}
+
+/// The group name, or its rename field.
+fn group_name(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>, shown: &str) {
+    let t = g.tl.t;
+    let ctx = g.tl.ctx.clone();
+    let cw = &g.tl.cw;
+    let actions = &mut g.tl.actions;
+    let lp = g.lp.clone();
+    let layer = rc.layer;
+    let r = rc.r;
+    let cy = rc.cy;
+    let indent = rc.indent;
+    let RowKind::Group { uid, name, .. } = &rc.row.kind else { return };
+    let group_edit: Option<(u64, u64, String)> = ctx.data(|d| d.get_temp(group_rename_id()));
+    if let Some((_, _, mut buf)) = group_edit.filter(|(rl, ru, _)| *rl == layer.id.0 && ru == uid) {
+        let field = Rect::from_min_max(pos2(indent + 8.0, r.min.y + 2.0), pos2((cw.switches - 4.0).max(indent + 60.0), r.max.y - 2.0));
+        let mut child = ui.new_child(egui::UiBuilder::new().max_rect(field));
+        let er = child.add(egui::TextEdit::singleline(&mut buf).font(Tokens::ui(12.0)).desired_width(field.width()));
+        app.auto.add(&format!("timeline.group.{uid}.nameEdit"), er.rect, &buf);
+        if ctx.data_mut(|d| d.remove_temp::(rename_focus_id())).unwrap_or(false) {
+            er.request_focus();
+            widgets::select_all(&ctx, er.id, &buf);
+        }
+        if er.lost_focus() {
+            if !ui.input(|i| i.key_pressed(egui::Key::Escape)) && !buf.trim().is_empty() && buf != *name {
+                actions.push(("prop.renameGroup".into(), json!({"layer": layer.id.0, "prop": uid, "name": buf})));
+            }
+            ctx.data_mut(|d| d.remove::<(u64, u64, String)>(group_rename_id()));
+        } else {
+            ctx.data_mut(|d| d.insert_temp(group_rename_id(), (layer.id.0, *uid, buf)));
+        }
+    } else {
+        lp.text(pos2(indent + 10.0, cy), Align2::LEFT_CENTER, shown, Tokens::ui(12.0), t.text);
+    }
+}
+
+/// Add / animate popups, Change Renderer and dash buttons.
+fn group_popups(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>) {
+    let t = g.tl.t;
+    let ctx = g.tl.ctx.clone();
+    let cw = &g.tl.cw;
+    let actions = &mut g.tl.actions;
+    let lp = g.lp.clone();
+    let ui_actions = &mut g.ui_actions;
+    let layer = rc.layer;
+    let cy = rc.cy;
+    let RowKind::Group { uid, .. } = &rc.row.kind else { return };
+    text_anim_popups(app, ui, &lp, layer, *uid, pos2(cw.switches, cy), &mut *actions);
+    shape_add_popup(app, ui, &lp, layer, *uid, pos2(cw.switches, cy), &mut *actions);
+    if *uid == GEOM_BIT | layer.id.0 {
+        let lr = lp.text(pos2(cw.switches + 6.0, cy), Align2::LEFT_CENTER, crate::i18n::tr("Change Renderer…"), Tokens::ui(11.5), t.accent);
+        let resp = ui
+            .interact(lr, egui::Id::unique(("tl-change-renderer", layer.id.0)), Sense::click())
+            .on_hover_text(crate::i18n::tr("Composition Settings ▸ 3D Renderer"));
+        if resp.hovered() {
+            ctx.set_cursor_icon(egui::CursorIcon::PointingHand);
+        }
+        app.auto.add(&format!("timeline.layer.{}.changeRenderer", layer.id.0), lr, "Change Renderer…");
+        if resp.clicked() {
+            ui_actions.push(UiAct::ChangeRenderer);
+        }
+    }
+    dash_buttons(app, ui, &lp, layer, *uid, pos2(cw.switches, cy), &mut *actions);
+}
+
+/// Mask mode and inverted, inline.
+fn group_mask_mode(ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>) {
+    let t = g.tl.t;
+    let cw = &g.tl.cw;
+    let actions = &mut g.tl.actions;
+    let lp = g.lp.clone();
+    let layer = rc.layer;
+    let cy = rc.cy;
+    let RowKind::Group { uid, .. } = &rc.row.kind else { return };
+    // Mask mode + inverted inline.
+    if let Some(g) = layer.props.find_group(*uid)
+        && let GroupKind::Mask { mode, inverted, .. } = g.kind
+    {
+        let mr = Rect::from_min_size(pos2(cw.switches + 4.0, cy - 9.0), vec2(80.0, 18.0));
+        let pop = egui::Id::unique(("maskmode", uid));
+        if widgets::dropdown(ui, mr, mode.label(), &t, egui::Id::unique(("mm", uid))).clicked() {
+            widgets::open_popup(ui, pop);
+        }
+        let opts: Vec = effectcraft_engine::project::MaskMode::ALL.iter().map(|m| m.label().to_string()).collect();
+        if let Some(i) = widgets::popup_menu(ui, pop, mr.left_bottom(), &opts, None) {
+            actions.push(("layer.setMask".into(), json!({"layer": layer.id.0, "mask": uid, "mode": opts[i]})));
+        }
+        let ir = Rect::from_min_size(pos2(mr.max.x + 8.0, cy - 8.0), vec2(16.0, 16.0));
+        if widgets::checkbox(ui, ir, inverted, &t, egui::Id::unique(("minv", uid))).clicked() {
+            actions.push(("layer.setMask".into(), json!({"layer": layer.id.0, "mask": uid, "inverted": !inverted})));
+        }
+        lp.text(pos2(ir.max.x + 4.0, cy), Align2::LEFT_CENTER, crate::i18n::tr("Inverted"), Tokens::ui(11.0), t.text_dim);
+    }
+}
+
+/// Group row click and double-click.
+fn group_row_click(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>) {
+    let cw = &g.tl.cw;
+    let actions = &mut g.tl.actions;
+    let ui_actions = &mut g.ui_actions;
+    let layer = rc.layer;
+    let r = rc.r;
+    let indent = rc.indent;
+    let RowKind::Group { uid, name, fx, .. } = &rc.row.kind else { return };
+    let gr_rect = Rect::from_min_max(pos2(indent + 8.0, r.min.y), pos2(cw.switches, r.max.y));
+    let gr = ui.interact(gr_rect, egui::Id::unique(("grow", uid)), Sense::click());
+    app.auto.add(&format!("timeline.group.{uid}.name"), gr_rect, name);
+    if gr.clicked() && uid & (WAVE_BIT | GEOM_BIT) == 0 {
+        actions.push(("prop.select".into(), json!({"layer": layer.id.0, "prop": uid, "selectKeys": false})));
+    }
+    if gr.double_clicked() {
+        if fx.is_some() {
+            // An effect's name: open it in Effect Controls (as in After Effects).
+            actions.push(("layer.select".into(), json!({"layers": [layer.id.0]})));
+            actions.push(("prop.select".into(), json!({"layer": layer.id.0, "prop": uid, "selectKeys": false})));
+            actions.push(("window.panel".into(), json!({"panel": "EffectControls"})));
+        } else {
+            ui_actions.push(UiAct::ToggleGroup(layer.id.0, *uid, false));
+        }
+    }
+}
+
+fn waveform_row(app: &mut EffectcraftApp, g: &Rw, rc: &RowCtx<'_>) {
+    let t = g.tl.t;
+    let graph_x0 = g.tl.graph_x0;
+    let tm = g.tl.tm;
+    let ctx = g.tl.ctx.clone();
+    let cw = &g.tl.cw;
+    let lp = g.lp.clone();
+    let gp = g.gp.clone();
+    let row = rc.row;
+    let layer = rc.layer;
+    let r = rc.r;
+    let left = rc.left;
+    let cy = rc.cy;
+    let ri = rc.ri;
+    let RowKind::Waveform { item, .. } = &rc.row.kind else { return };
+    lp.rect_filled(left, 0.0, if ri.is_multiple_of(2) { t.row } else { t.row_alt });
+    let indent = cw.name + 6.0 + 14.0 * row.depth as f32;
+    lp.text(pos2(indent + 10.0, cy), Align2::LEFT_CENTER, "Waveform", Tokens::ui(12.0), t.text);
+    let wr = Rect::from_min_max(pos2(graph_x0, r.min.y), r.max);
+    gp.rect_filled(wr, 0.0, t.tl_bg);
+    app.auto.add(&format!("timeline.layer.{}.waveform", layer.id.0), wr, "Waveform");
+    match super::waveform::summary(app, &ctx, ItemId(*item)) {
+        Some(s) => super::waveform::draw(&gp, wr.shrink2(vec2(0.0, 2.0)), &tm, layer, &s, Color32::from_rgb(0x5f, 0xc8, 0x8a)),
+        None => {
+            gp.text(
+                pos2(tm.x(layer.in_point.seconds()).max(wr.min.x) + 6.0, wr.center().y),
+                Align2::LEFT_CENTER,
+                "Building waveform…",
+                Tokens::ui(11.0),
+                t.text_faint,
+            );
+        }
+    }
+}
+
+/// The expression row's outline side: enable, pick whip and syntax warning.
+fn expr_controls(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>, st: &ExprRow<'_>) {
+    let ExprRow { prop, ex, buf_id, edit_id, .. } = *st;
+    let t = g.tl.t;
+    let ctx = g.tl.ctx.clone();
+    let cw = &g.tl.cw;
+    let actions = &mut g.tl.actions;
+    let lp = g.lp.clone();
+    let row = rc.row;
+    let layer = rc.layer;
+    let left = rc.left;
+    let cy = rc.cy;
+    let ri = rc.ri;
+    let RowKind::Expr { uid, .. } = &rc.row.kind else { return };
+    let sel_prop = app.session.state.selected_props.contains(&(layer.id, *uid));
+    lp.rect_filled(
+        left,
+        0.0,
+        if sel_prop {
+            t.row_selected
+        } else if ri.is_multiple_of(2) {
+            t.row
+        } else {
+            t.row_alt
+        },
+    );
+    let indent = cw.name + 6.0 + 14.0 * row.depth as f32;
+    lp.text(pos2(indent + 12.0, cy), Align2::LEFT_CENTER, crate::i18n::tr("Expression:"), Tokens::ui(11.5), t.text_dim);
+    lp.text(pos2(indent + 82.0, cy), Align2::LEFT_CENTER, &prop.name, Tokens::ui(11.5), t.text);
+    // "=" enable switch and the property pick whip (AE's expression controls).
+    let en_r = Rect::from_center_size(pos2(cw.switches + 10.0, cy), vec2(16.0, 16.0));
+    let en = ui.interact(en_r, egui::Id::unique(("expr-en", uid)), Sense::click()).on_hover_text(crate::i18n::tr("Enable Expression"));
+    lp.rect_stroke(en_r.shrink(1.0), 2.0, Stroke::new(1.0, t.separator), StrokeKind::Inside);
+    let expr_col = t.expr_text;
+    lp.text(en_r.center(), Align2::CENTER_CENTER, if ex.enabled { "=" } else { "≠" }, Tokens::semibold(13.0), if ex.enabled { expr_col } else { t.text_dim });
+    app.auto.add(&format!("timeline.prop.{uid}.exprEnable"), en_r, "Enable Expression");
+    if en.clicked() {
+        actions.push(("prop.setExpression".into(), json!({"layer": layer.id.0, "prop": uid, "enabled": !ex.enabled})));
+    }
+    let pw = pick_whip(
+        app,
+        ui,
+        &lp,
+        Rect::from_center_size(pos2(cw.switches + 30.0, cy), vec2(15.0, 15.0)),
+        (WHIP_EXPR, layer.id.0, *uid),
+        WhipText {
+            auto: &format!("timeline.prop.{uid}.pickWhip"),
+            label: "Expression pick whip",
+            hint: crate::i18n::tr("Expression pick whip: drag onto a property"),
+        },
+    );
+    if pw.is_pointer_button_down_on() && ui.input(|i| i.pointer.primary_pressed()) {
+        // Pressed while the expression is being edited (its text is buffered while
+        // the editor has the focus): the reference will go in at the cursor,
+        // replacing the selection (#284).
+        let into = ctx.data(|d| d.get_temp::(buf_id)).map(|buf| {
+            let n = buf.chars().count();
+            let sel = egui::text_edit::TextEditState::load(&ctx, edit_id)
+                .and_then(|st| st.cursor.char_range())
+                .map_or([n, n], |r| [r.primary.index.0.min(r.secondary.index.0).min(n), r.primary.index.0.max(r.secondary.index.0).min(n)]);
+            (buf, sel)
+        });
+        ctx.data_mut(|d| {
+            if let Some(into) = into {
+                d.insert_temp(whip_into_id(*uid), into);
+            } else {
+                d.remove::(whip_into_id(*uid));
+            }
+        });
+    }
+    // Syntax errors: warning in the outline (the expression is disabled).
+    if let Some(check) = app.session.expr_check
+        && let Err(e) = check(&ex.text)
+    {
+        let wr = Rect::from_center_size(pos2(cw.switches + 50.0, cy), vec2(14.0, 14.0));
+        lp.text(wr.center(), Align2::CENTER_CENTER, "⚠", Tokens::ui(12.0), Color32::from_rgb(0xf0, 0xa0, 0x30));
+        let _ = ui.interact(wr, egui::Id::unique(("expr-err", uid)), Sense::hover()).on_hover_text(e);
+    }
+}
+
+/// The Expression Language menu inserts at the cursor.
+fn expr_language_menu(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>, st: &ExprRow<'_>) {
+    let ExprRow { ex, buf_id, edit_id, .. } = *st;
+    let t = g.tl.t;
+    let ctx = g.tl.ctx.clone();
+    let cw = &g.tl.cw;
+    let actions = &mut g.tl.actions;
+    let lp = g.lp.clone();
+    let layer = rc.layer;
+    let cy = rc.cy;
+    let RowKind::Expr { uid, .. } = &rc.row.kind else { return };
+    // ⓕ: the Expression Language menu inserts at the cursor.
+    let lang_r = Rect::from_center_size(pos2(cw.switches + 70.0, cy), vec2(16.0, 16.0));
+    let lang = ui.interact(lang_r, egui::Id::unique(("expr-lang", uid)), Sense::click()).on_hover_text(crate::i18n::tr("Expression Language Menu"));
+    lp.circle_stroke(lang_r.center(), 6.5, Stroke::new(1.0, if lang.hovered() { t.text } else { t.text_dim }));
+    lp.text(lang_r.center(), Align2::CENTER_CENTER, "f", Tokens::semibold(10.0), if lang.hovered() { t.text } else { t.text_dim });
+    app.auto.add(&format!("timeline.prop.{uid}.exprLanguage"), lang_r, "Expression Language Menu");
+    let mut picked: Option<&'static str> = None;
+    egui::Popup::menu(&lang).style(crate::widgets::menu_style).show(|ui| {
+        for (cat, items) in effectcraft_engine::commands::expr_tools::language_menu() {
+            ui.menu_button(cat, |ui| {
+                widgets::menu_scroll(ui, false, |ui| {
+                    for (label, text) in items {
+                        if ui.button(label).clicked() {
+                            picked = Some(text);
+                            ui.close();
+                        }
+                    }
+                })
+            });
+        }
+    });
+    if let Some(text) = picked {
+        let cur: String = ctx.data(|d| d.get_temp(buf_id)).unwrap_or_else(|| ex.text.clone());
+        let at = egui::text_edit::TextEditState::load(&ctx, edit_id)
+            .and_then(|st| st.cursor.char_range())
+            .map(|r| r.primary.index.0)
+            .unwrap_or(cur.chars().count())
+            .min(cur.chars().count());
+        let byte = cur.char_indices().nth(at).map(|(b, _)| b).unwrap_or(cur.len());
+        let mut next = cur.clone();
+        next.insert_str(byte, text);
+        actions.push(("prop.setExpression".into(), json!({"layer": layer.id.0, "prop": uid, "expression": next})));
+        ctx.data_mut(|d| d.remove::(buf_id));
+    }
+}
+
+/// The expression editor in the time-graph area.
+fn expr_editor_cell(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>, st: &ExprRow<'_>) {
+    let ExprRow { prop, ex, buf_id, edit_id, .. } = *st;
+    let t = g.tl.t;
+    let rect = g.tl.rect;
+    let graph_x0 = g.tl.graph_x0;
+    let rh = g.tl.rh;
+    let ctx = g.tl.ctx.clone();
+    let actions = &mut g.tl.actions;
+    let right_clip = g.right_clip;
+    let gp = g.gp.clone();
+    let layer = rc.layer;
+    let r = rc.r;
+    let RowKind::Expr { uid, lines, .. } = &rc.row.kind else { return };
+    let expr_col = t.expr_text;
+    // The editor in the time-graph area.
+    ui.set_clip_rect(right_clip);
+    gp.rect_filled(Rect::from_min_max(pos2(graph_x0, r.min.y), r.max), 0.0, t.expr_bg);
+    let er = Rect::from_min_max(pos2(graph_x0 + 8.0, r.min.y + 3.0), pos2(rect.max.x - 14.0, r.max.y - 3.0));
+    let mut buf: String = ctx.data(|d| d.get_temp(buf_id)).unwrap_or_else(|| ex.text.clone());
+    if let Some(f) = ctx.data(|d| d.get_temp::(expr_focus_id())).filter(|f| f.prop == *uid) {
+        // Asked for once (a pick whip put a reference in, or an Alt-click added the
+        // expression): editing starts in the expression as it is now.
+        ctx.data_mut(|d| d.remove::(expr_focus_id()));
+        buf = ex.text.clone();
+        let n = buf.chars().count();
+        let [a, b] = f.select.map(|before_end| egui::text::CCursor::new(n.saturating_sub(before_end)));
+        let mut st = egui::text_edit::TextEditState::load(&ctx, edit_id).unwrap_or_default();
+        st.cursor.set_char_range(Some(egui::text::CCursorRange::two(a, b)));
+        st.store(&ctx, edit_id);
+        ctx.memory_mut(|m| m.request_focus(edit_id));
+    }
+    // Settings ▸ Scripting & Expressions ▸ Expressions Editor.
+    let sp = app.session.prefs.scripting.clone();
+    let resp = super::expr_editor::editor(
+        ui,
+        edit_id,
+        &mut buf,
+        er,
+        super::expr_editor::EditorOpts { prefs: &sp, color: if ex.enabled { expr_col } else { t.text_dim }, rows: *lines },
+        &t,
+    );
+    app.auto.add(&format!("timeline.prop.{uid}.expression"), er, &prop.name);
+    // Its bottom edge drags to set its height in whole lines, as in After Effects.
+    let edge = Rect::from_min_max(pos2(er.min.x, r.max.y - 4.0), pos2(er.max.x, r.max.y));
+    let eresp = ui.interact(edge, egui::Id::unique(("expr-edge", uid)), Sense::drag());
+    app.auto.add(&format!("timeline.prop.{uid}.expressionHeight"), edge, "Expression field height");
+    if eresp.hovered() || eresp.dragged() {
+        ctx.set_cursor_icon(egui::CursorIcon::ResizeVertical);
+    }
+    if let Some(py) = eresp.interact_pointer_pos().filter(|_| eresp.dragged()).map(|p| p.y) {
+        let n = ((py - r.min.y) / rh).round().clamp(1.0, EXPR_MAX_LINES as f32);
+        app.ui.timeline.expr_lines.insert(*uid, n as usize);
+    }
+    let commit = resp.has_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter) && (i.modifiers.command || i.modifiers.ctrl));
+    if commit {
+        resp.surrender_focus();
+    }
+    if resp.has_focus() && !commit {
+        ctx.data_mut(|d| d.insert_temp(buf_id, buf));
+    } else {
+        if (resp.lost_focus() || commit) && buf.trim_end() != ex.text {
+            actions.push(("prop.setExpression".into(), json!({"layer": layer.id.0, "prop": uid, "expression": buf.trim_end()})));
+        }
+        ctx.data_mut(|d| d.remove::(buf_id));
+    }
+}
+
+fn prop_background(app: &mut EffectcraftApp, g: &Rw, rc: &RowCtx<'_>, prop: &Property) {
+    let t = g.tl.t;
+    let graph_x0 = g.tl.graph_x0;
+    let lp = g.lp.clone();
+    let gp = g.gp.clone();
+    let layer = rc.layer;
+    let r = rc.r;
+    let left = rc.left;
+    let ri = rc.ri;
+    let uid = &prop.uid;
+
+    app.auto.add(&format!("timeline.prop.{uid}.row"), r, &prop.name);
+    let sel_prop = app.session.state.selected_props.contains(&(layer.id, *uid));
+    lp.rect_filled(
+        left,
+        0.0,
+        if sel_prop {
+            t.row_selected
+        } else if ri.is_multiple_of(2) {
+            t.row
+        } else {
+            t.row_alt
+        },
+    );
+    gp.rect_filled(Rect::from_min_max(pos2(graph_x0, r.min.y), r.max), 0.0, t.tl_bg);
+}
+
+/// The keyframe navigator (previous, toggle, next).
+fn prop_key_navigator(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>, prop: &Property) {
+    let t = g.tl.t;
+    let time = g.tl.time;
+    let fr = g.tl.fr;
+    let vis = g.tl.vis;
+    let cw = &g.tl.cw;
+    let actions = &mut g.tl.actions;
+    let lp = g.lp.clone();
+    let ui_actions = &mut g.ui_actions;
+    let layer = rc.layer;
+    let cy = rc.cy;
+    let uid = &prop.uid;
+
+    // Keyframe navigator (in the A/V column, like AE).
+    // (in the Keys column when it is shown).
+    if prop.is_animated() && (vis.keys || vis.av) {
+        let lt = layer.layer_time(time);
+        let at_key = effectcraft_engine::keyframe::key_at(&prop.keys, lt).is_some();
+        let nav_x = if vis.keys { cw.keys + 12.0 } else { cw.av + 18.0 };
+        let prev = Rect::from_center_size(pos2(nav_x, cy), vec2(12.0, 14.0));
+        let mid = Rect::from_center_size(pos2(nav_x + 16.0, cy), vec2(14.0, 14.0));
+        let next = Rect::from_center_size(pos2(nav_x + 32.0, cy), vec2(12.0, 14.0));
+        icons::paint(&lp, prev.shrink(2.0), Icon::ChevronLeft, t.text_dim);
+        icons::paint(&lp, next.shrink(2.0), Icon::ChevronRight, t.text_dim);
+        let kc = if at_key { t.keyframe_selected } else { Color32::TRANSPARENT };
+        lp.add(egui::Shape::convex_polygon(
+            vec![mid.center() + vec2(0.0, -5.0), mid.center() + vec2(5.0, 0.0), mid.center() + vec2(0.0, 5.0), mid.center() + vec2(-5.0, 0.0)],
+            kc,
+            Stroke::new(1.0, t.text_dim),
+        ));
+        if ui.interact(prev, egui::Id::unique(("kprev", uid)), Sense::click()).clicked()
+            && let Some(k) = prop.keys.iter().rev().find(|k| layer.comp_time(k.time).0 < time.0 - fr.frame_duration().0 / 2)
+        {
+            ui_actions.push(UiAct::SetTime(layer.comp_time(k.time)));
+        }
+        if ui.interact(next, egui::Id::unique(("knext", uid)), Sense::click()).clicked()
+            && let Some(k) = prop.keys.iter().find(|k| layer.comp_time(k.time).0 > time.0 + fr.frame_duration().0 / 2)
+        {
+            ui_actions.push(UiAct::SetTime(layer.comp_time(k.time)));
+        }
+        if ui.interact(mid, egui::Id::unique(("kmid", uid)), Sense::click()).clicked() {
+            actions.push(("prop.toggleKey".into(), json!({"layer": layer.id.0, "prop": uid})));
+        }
+        app.auto.add(&format!("timeline.prop.{uid}.addKey"), mid, "Add or remove keyframe");
+    }
+}
+
+/// The stopwatch.
+fn prop_stopwatch(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>, prop: &Property) {
+    let t = g.tl.t;
+    let ctx = g.tl.ctx.clone();
+    let actions = &mut g.tl.actions;
+    let lp = g.lp.clone();
+    let layer = rc.layer;
+    let r = rc.r;
+    let cy = rc.cy;
+    let indent = rc.indent;
+    let uid = &prop.uid;
+
+    // Stopwatch: Alt+click adds an expression; a press toggles the animation, and
+    // dragging on over other stopwatches gives them the same state (#284).
+    if !prop.static_only {
+        let swr = Rect::from_center_size(pos2(indent, cy), vec2(15.0, 15.0));
+        let resp = ui.interact(swr, egui::Id::unique(("stopwatch", uid)), Sense::click_and_drag());
+        let col = if prop.is_animated() {
+            t.hot_text
+        } else if resp.hovered() {
+            t.text
+        } else {
+            t.text_dim
+        };
+        icons::paint(&lp, swr, Icon::Stopwatch, col);
+        app.auto.add(&format!("timeline.prop.{uid}.stopwatch"), swr, &prop.name);
+        if ui.input(|i| i.modifiers.alt) {
+            if resp.clicked() {
+                actions.push(("prop.setExpression".into(), json!({"layer": layer.id.0, "prop": uid})));
+                if prop.expr.is_none() {
+                    // Adding one: its editor opens, comes into view and takes the
+                    // keyboard with the new expression selected (#1072).
+                    app.ui.timeline.expr_closed.remove(uid);
+                    focus_expression(&ctx, *uid, [usize::MAX, 0]);
+                } else if ctx.data(|d| d.get_temp::(expr_focus_id())).is_some_and(|f| f.prop == *uid) {
+                    // Removing it: nothing is left to take the keyboard.
+                    ctx.data_mut(|d| d.remove::(expr_focus_id()));
+                }
+            }
+        } else if let Some((value, merge)) = switch_gesture(ui, &resp, r, "stopwatch", prop.is_animated()) {
+            actions.push(("prop.toggleAnimation".into(), json!({"layer": layer.id.0, "prop": uid, "value": value, "merge": merge})));
+        } else if resp.clicked() && !resp.clicked_by(egui::PointerButton::Primary) {
+            // Keyboard or accessibility activation.
+            actions.push(("prop.toggleAnimation".into(), json!({"layer": layer.id.0, "prop": uid})));
+        }
+    }
+}
+
+/// The property name; returns its x.
+fn prop_name(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>, prop: &Property) -> f32 {
+    let t = g.tl.t;
+    let cid = g.tl.cid;
+    let ctx = g.tl.ctx.clone();
+    let cw = &g.tl.cw;
+    let actions = &mut g.tl.actions;
+    let lp = g.lp.clone();
+    let layer = rc.layer;
+    let r = rc.r;
+    let cy = rc.cy;
+    let indent = rc.indent;
+    let uid = &prop.uid;
+
+    let name_x = indent + 12.0;
+    // 3D layers show Rotation as "Z Rotation" next to X/Y Rotation.
+    let is_tr_rot = layer.transform().and_then(|t| t.get("rotation")).is_some_and(|r| r.uid == prop.uid);
+    let base = if is_tr_rot && prop.name == "Rotation" && layer.is_3d() { "Z Rotation".to_string() } else { prop.name.clone() };
+    let pname = if prop.has_expression() { format!("{base}  =") } else { base };
+    lp.text(pos2(name_x, cy), Align2::LEFT_CENTER, &pname, Tokens::ui(12.0), if prop.has_expression() { t.expr_text } else { t.text });
+    let name_rect = Rect::from_min_max(pos2(name_x, r.min.y), pos2(cw.switches - 2.0, r.max.y));
+    let name_resp = ui.interact(name_rect, egui::Id::unique(("pname", uid)), Sense::click_and_drag());
+    app.auto.add(&format!("timeline.prop.{uid}.name"), name_rect, &prop.name);
+    if name_resp.drag_started() {
+        // Drop on the Essential Graphics panel to expose the property.
+        egui::DragAndDrop::set_payload(&ctx, crate::panels::DragPayload::Property { layer: layer.id.0, prop: *uid });
+    }
+    if name_resp.dragged() {
+        egui::Tooltip::always_open(ctx.clone(), ui.layer_id(), egui::Id::unique("prop-drag-tip"), egui::PopupAnchor::Pointer).show(|ui| ui.label(&prop.name));
+    }
+    if name_resp.clicked() {
+        actions.push(("prop.select".into(), json!({"layer": layer.id.0, "prop": uid, "add": ui.input(|i| i.modifiers.shift)})));
+    }
+    crate::widgets::context_menu(&name_resp, |ui| prop_menu(ui, &mut app.auto, cid, layer, prop, &mut *actions));
+    name_x
+}
+
+/// The value editors and the property pick whip.
+fn prop_value_cell(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>, ectx: &EvalCtx<'_>, prop: &Property, name_x: f32) {
+    let vis = g.tl.vis;
+    let cw = &g.tl.cw;
+    let actions = &mut g.tl.actions;
+    let lp = g.lp.clone();
+    let dim_hits = &mut g.dim_hits;
+    let layer = rc.layer;
+    let cy = rc.cy;
+    let uid = &prop.uid;
+
+    let value = ectx.value(layer, prop);
+    let vx = (cw.switches + 4.0).max(name_x + 120.0);
+    let ve = ValueEdit::new(ui, &lp, layer, prop, pos2(vx, cy));
+    let dims = value_editor(app, ui, &ve, &value, &mut *actions);
+    if !dims.is_empty() {
+        dim_hits.push((*uid, dims));
+    }
+    // The property pick whip (Parent & Link column, AE CC 2018 and later): dropped on
+    // a property it links this one to it, adding the expression if there is none.
+    if vis.parent && !prop.static_only {
+        pick_whip(
+            app,
+            ui,
+            &lp,
+            Rect::from_center_size(pos2(cw.parent + 9.0, cy), vec2(15.0, 15.0)),
+            (WHIP_PROP, layer.id.0, *uid),
+            WhipText {
+                auto: &format!("timeline.prop.{uid}.propertyPickWhip"),
+                label: "Property pick whip",
+                hint: crate::i18n::tr("Property pick whip: drag onto a property"),
+            },
+        );
+    }
+}
+
+/// The one-line expression hint when its editor is closed.
+fn prop_expression_hint(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &Rw, rc: &RowCtx<'_>, prop: &Property) {
+    let t = g.tl.t;
+    let graph_x0 = g.tl.graph_x0;
+    let right_clip = g.right_clip;
+    let gp = g.gp.clone();
+    let cy = rc.cy;
+    let uid = &prop.uid;
+
+    ui.set_clip_rect(right_clip);
+    // Expression text row hint.
+    if let Some(e) = prop.expr.as_ref().filter(|e| e.enabled && app.ui.timeline.expr_closed.contains(uid)) {
+        gp.text(pos2(graph_x0 + 8.0, cy), Align2::LEFT_CENTER, format!("= {}", e.text.lines().next().unwrap_or("")), Tokens::mono(11.0), t.expr_text);
+    }
+}
+
+/// Key click selection and interpolation shortcuts.
+fn key_select(g: &mut Rw, kh: &KeyHit<'_>) {
+    let KeyHit { k, ks, mods, ref kresp, ref this_key, .. } = *kh;
+    let actions = &mut g.tl.actions;
+    if kresp.clicked() && mods.command {
+        // Ctrl+click: Linear ↔ Auto Bezier; Ctrl+Alt+click: Hold on / off.
+        actions.push(("keys.select".into(), this_key.clone()));
+        if mods.alt {
+            actions.push(("keys.toggleHold".into(), json!({})));
+        } else {
+            let linear = k.in_interp == Interp::Linear && k.out_interp == Interp::Linear;
+            actions.push(("keys.interpolation".into(), json!({"interpolation": if linear { "autoBezier" } else { "linear" }})));
+        }
+    } else if kresp.clicked() && mods.shift {
+        // Shift+click: in or out of the selection.
+        let mut p = this_key.clone();
+        p["toggle"] = json!(true);
+        actions.push(("keys.select".into(), p));
+    } else if (kresp.clicked() || kresp.drag_started()) && !ks {
+        let mut p = this_key.clone();
+        p["add"] = json!(mods.shift);
+        actions.push(("keys.select".into(), p));
+    }
+}
+
+fn key_double_click(g: &mut Rw, rc: &RowCtx<'_>, prop: &Property, kh: &KeyHit<'_>) {
+    let KeyHit { k, ct, ref kresp, .. } = *kh;
+    let actions = &mut g.tl.actions;
+    let ui_actions = &mut g.ui_actions;
+    let layer = rc.layer;
+    let uid = &prop.uid;
+
+    if kresp.double_clicked() {
+        // Edit the key's value (numeric properties), as After Effects does.
+        if matches!(k.value, Value::Scalar(_) | Value::Vec2(_) | Value::Vec3(_)) {
+            actions.push(("keys.set".into(), json!({"layer": layer.id.0, "prop": uid, "time": k.time.seconds()})));
+        } else {
+            ui_actions.push(UiAct::SetTime(ct));
+        }
+    }
+}
+
+/// The keyframe right-click menu.
+fn key_context_menu(app: &mut EffectcraftApp, g: &mut Rw, rc: &RowCtx<'_>, prop: &Property, kh: &KeyHit<'_>) {
+    let KeyHit { k, ks, ref kresp, .. } = *kh;
+    let t = g.tl.t;
+    let actions = &mut g.tl.actions;
+    let layer = rc.layer;
+    let uid = &prop.uid;
+
+    crate::widgets::context_menu(kresp, |ui| {
+        for (lbl, cmd, params) in [
+            ("Edit Value…", "keys.set", json!({"layer": layer.id.0, "prop": uid, "time": k.time.seconds()})),
+            ("-", "", json!({})),
+            ("Copy", "keys.copy", json!({})),
+            ("Paste", "keys.paste", json!({})),
+            ("-", "", json!({})),
+            ("Select Equal Keyframes", "keys.selectEqual", json!({})),
+            ("Select Previous Keyframes", "keys.selectPrevious", json!({})),
+            ("Select Following Keyframes", "keys.selectFollowing", json!({})),
+            ("-", "", json!({})),
+            ("Keyframe Interpolation…", "keys.interpolation", json!({})),
+            ("Keyframe Velocity…", "keys.velocity", json!({})),
+            ("Toggle Hold Keyframe", "keys.toggleHold", json!({})),
+            ("Rove Across Time", "keys.interpolation", json!({"roving": !k.roving})),
+            ("-", "", json!({})),
+            ("Easy Ease", "keys.easyEase", json!({})),
+            ("Easy Ease In", "keys.easyEaseIn", json!({})),
+            ("Easy Ease Out", "keys.easyEaseOut", json!({})),
+            ("Time-Reverse Keyframes", "keys.timeReverse", json!({})),
+            ("-", "", json!({})),
+            ("Linear", "keys.interpolation", json!({"interpolation": "linear"})),
+            ("Bezier", "keys.interpolation", json!({"interpolation": "bezier"})),
+            ("Auto Bezier", "keys.interpolation", json!({"interpolation": "autoBezier"})),
+            ("Select All Keyframes", "keys.selectAll", json!({})),
+            ("Delete", "keys.delete", json!({})),
+        ] {
+            if lbl == "Select All Keyframes" {
+                // Keyframe colour labels.
+                ui.menu_button(crate::i18n::tr("Label"), |ui| {
+                    for (i, l) in effectcraft_engine::color::Label::ALL.into_iter().enumerate() {
+                        if widgets::label_entry(ui, l, &app.session.prefs.label_name(l), k.label as usize == i, &t).clicked() {
+                            if !ks {
+                                actions.push(("keys.select".into(), json!({"keys": [{"layer": layer.id.0, "prop": uid, "time": k.time.seconds()}]})));
+                            }
+                            actions.push(("keys.setLabel".into(), json!({"label": i})));
+                            ui.close();
+                        }
+                    }
+                });
+            }
+            if lbl == "-" {
+                ui.separator();
+                continue;
+            }
+            if lbl == "Rove Across Time" && !prop.spatial {
+                continue;
+            }
+            if lbl == "Edit Value…" && !matches!(k.value, Value::Scalar(_) | Value::Vec2(_) | Value::Vec3(_)) {
+                continue;
+            }
+            if ui.button(lbl).clicked() {
+                if !ks {
+                    actions.push(("keys.select".into(), json!({"keys": [{"layer": layer.id.0, "prop": uid, "time": k.time.seconds()}]})));
+                }
+                actions.push((cmd.into(), params));
+                ui.close();
+            }
+        }
+    });
+}
+
+/// Starts a key drag (or Alt-scale).
+fn key_drag_begin(app: &mut EffectcraftApp, g: &Rw, kh: &KeyHit<'_>) {
+    let KeyHit { ct, ks, mods, ref kresp, .. } = *kh;
+    let ctx = g.tl.ctx.clone();
+    let comp = g.tl.comp.clone();
+    let sel_keys = &g.sel_keys;
+    // Alt-drag the first or last key of a selected group: scale the group in time.
+    if kresp.drag_started()
+        && mods.alt
+        && !mods.command
+        && let Some(c) = app.session.active_comp_arc()
+    {
+        super::graph_tools::alt_scale_begin(app, &ctx, &c, ct.seconds());
+    }
+    // A plain drag moves the selected keys. The timeline follows it (below): this
+    // widget's id changes with the key's time, so its own drag would end after
+    // the first frame of movement.
+    // (From where the button went down: a drag starts a few pixels later.)
+    if kresp.drag_started()
+        && !super::graph_tools::alt_scale_active(&ctx)
+        && let Some(p) = ctx.input(|i| i.pointer.press_origin())
+    {
+        // Ctrl/Cmd+Alt: Quick Offset of the selected keys' layers (#1012).
+        let quick = (mods.command && mods.alt && ks).then(|| quick_offset_layers(&comp, sel_keys.iter().map(|k| k.layer))).flatten();
+        ctx.data_mut(|d| d.insert_temp(key_drag_id(), KeyDrag { origin_x: p.x, anchor: ct.seconds(), applied: 0, quick }));
+    }
+}
+
+/// Paints one keyframe marker; returns its hit rect.
+fn key_paint(g: &Rw, rc: &RowCtx<'_>, k: &effectcraft_engine::keyframe::Keyframe, x: f32, ks: bool) -> Rect {
+    let t = g.tl.t;
+    let gp = g.gp.clone();
+    let cy = rc.cy;
+    let icon = k.icon();
+    let c = pos2(x, cy);
+    // Labelled keys take their label colour (brighter when selected).
+    let kcol = match effectcraft_engine::color::Label::ALL.get(k.label as usize).filter(|_| k.label > 0) {
+        Some(l) => {
+            let c = t.label(*l);
+            if ks { c } else { c.gamma_multiply(0.75) }
+        }
+        None if ks => t.keyframe_selected,
+        None => t.keyframe,
+    };
+    if k.roving {
+        // Roving keys: a small dot (their time follows their neighbours).
+        gp.circle_filled(c, 2.5, kcol);
+    } else {
+        icons::keyframe(&gp, c, 11.0, icon.left, icon.right, kcol);
+    }
+    Rect::from_center_size(c, vec2(12.0, 14.0))
+}
+
+/// The key's hit target and automation ids.
+fn key_hit(
+    app: &mut EffectcraftApp,
+    ui: &mut egui::Ui,
+    g: &Rw,
+    rc: &RowCtx<'_>,
+    prop: &Property,
+    k: &effectcraft_engine::keyframe::Keyframe,
+    kr: Rect,
+) -> egui::Response {
+    let fr = g.tl.fr;
+    let layer = rc.layer;
+    let uid = &prop.uid;
+    let ct = layer.comp_time(k.time);
+    let kresp = ui.interact(kr, egui::Id::unique(("key", uid, k.time.0)), Sense::click_and_drag());
+    app.auto.add(&format!("timeline.key.{uid}.{}", fr.frame_at(ct)), kr, &format!("{} key", prop.name));
+    // Exact (layer-time ticks): keys a Quick Offset puts on subframes can share a frame.
+    app.auto.add(&format!("timeline.keyAt.{uid}.{}", k.time.0), kr, &format!("{} key", prop.name));
+    kresp
+}
+
+/// Right-click menus on the empty outline and graph areas.
+fn background_menus(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw) {
+    let actions = &mut g.tl.actions;
+    let graph_on = g.graph_on;
+    let full_clip = g.full_clip;
+    let empty_rect = g.empty_rect;
+    let empty = g.empty.clone();
+    let outline_empty = g.outline_empty.clone();
     ui.set_clip_rect(full_clip);
     // The background responses sit below rows, bars and editors, preserving their menus.
     if !graph_on {
@@ -3231,6 +4195,18 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
             }
         });
     }
+}
+
+/// Click and marquee-select on the empty outline.
+fn outline_marquee(ui: &mut egui::Ui, g: &mut Rw) {
+    let t = g.tl.t;
+    let ctx = g.tl.ctx.clone();
+    let comp = g.tl.comp.clone();
+    let actions = &mut g.tl.actions;
+    let outline_rect = g.outline_rect;
+    let lp = g.lp.clone();
+    let outline_empty = g.outline_empty.clone();
+    let hit_rows = &mut g.hit_rows;
     if outline_empty.clicked() && !ui.input(|i| i.modifiers.shift || i.modifiers.command) {
         actions.push(("layer.select".into(), json!({"layers": []})));
     }
@@ -3255,6 +4231,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         actions.push(("layer.select".into(), json!({"layers": layers, "add": ui.input(|i| i.modifiers.shift || i.modifiers.command)})));
     }
     // Pick whips: a line follows the pointer; releasing over a row links to it (an expression's
+}
+
+/// A pick whip in flight: its line, the target and the link on release.
+fn pick_whip_drag(g: &mut Rw) {
+    let t = g.tl.t;
+    let graph_x0 = g.tl.graph_x0;
+    let ctx = g.tl.ctx.clone();
+    let actions = &mut g.tl.actions;
+    let hit_rows = &mut g.hit_rows;
+    let dim_hits = &mut g.dim_hits;
     // or a property's, also over a property in Effect Controls).
     if let Some((kind, src_layer, src_prop, start)) = ctx.data(|d| d.get_temp::(pick_whip_id())) {
         let ptr = ctx.input(|i| i.pointer.latest_pos()).unwrap_or(start);
@@ -3314,16 +4300,42 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         }
     }
     // Alt-drag key group scaling (follows the pointer until release).
+}
+
+fn alt_scale_and_graph(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, ectx: &EvalCtx<'_>) {
+    let graph_x0 = g.tl.graph_x0;
+    let tm = g.tl.tm;
+    let rows_rect = g.tl.rows_rect;
+    let ctx = g.tl.ctx.clone();
+    let comp = g.tl.comp.clone();
+    let actions = &mut g.tl.actions;
+    let graph_on = g.graph_on;
+    let gp = g.gp.clone();
     if let Some(c) = app.session.active_comp_arc() {
-        super::graph_tools::alt_scale_update(app, &ctx, &c, tm, &mut actions);
+        super::graph_tools::alt_scale_update(app, &ctx, &c, tm, &mut *actions);
     }
     // Graph editor.
     if graph_on {
-        super::graph::show(app, ui, &gp, &comp, &ectx, tm, Rect::from_min_max(pos2(graph_x0, rows_rect.min.y), rows_rect.max), &mut actions);
+        super::graph::show(
+            app,
+            ui,
+            &gp,
+            super::graph::GraphView { comp: &comp, ectx, tm },
+            Rect::from_min_max(pos2(graph_x0, rows_rect.min.y), rows_rect.max),
+            &mut *actions,
+        );
     }
     // A plain click on empty track area (beside a layer's bar, on a property row's empty span or
     // below the last row) deselects the layers and their keys and properties, as in After
-    // Effects; Shift / Cmd keep the selection. Drag: box-select keys.
+}
+
+/// Click / marquee on empty track area.
+fn graph_empty_area(ui: &mut egui::Ui, g: &mut Rw) {
+    let t = g.tl.t;
+    let ctx = g.tl.ctx.clone();
+    let actions = &mut g.tl.actions;
+    let gp = g.gp.clone();
+    let empty = g.empty.clone();
     if empty.clicked() && !ui.input(|i| i.modifiers.shift || i.modifiers.command) {
         actions.push(("layer.select".into(), json!({"layers": []})));
     }
@@ -3335,7 +4347,19 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         gp.rect_stroke(br, 0.0, Stroke::new(1.0, t.accent), StrokeKind::Middle);
         ctx.data_mut(|d| d.insert_temp(egui::Id::unique("tl-box"), br));
     }
-    // Layer reorder drag: a line where the layers will land; the release moves them there.
+}
+
+/// Layer reorder drag: a line where the layers land.
+fn layer_reorder_drag(ui: &mut egui::Ui, g: &mut Rw) {
+    let t = g.tl.t;
+    let rect = g.tl.rect;
+    let graph_x0 = g.tl.graph_x0;
+    let rows_rect = g.tl.rows_rect;
+    let ctx = g.tl.ctx.clone();
+    let comp = g.tl.comp.clone();
+    let p = g.tl.p.clone();
+    let actions = &mut g.tl.actions;
+    let hit_rows = &mut g.hit_rows;
     if let Some(moving) = ctx.data(|d| d.get_temp::>(layer_drag_id())) {
         let dropped = ctx.data_mut(|d| d.remove_temp::(layer_drop_id())).unwrap_or(false);
         let layer_rows: Vec<(Rect, LayerId)> = hit_rows.iter().filter(|(_, r)| matches!(r.kind, RowKind::Layer)).map(|(rr, r)| (*rr, r.layer)).collect();
@@ -3366,7 +4390,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         }
     }
     // Quick Offset of layer bars: the last selected layer moves by whole frames with the pointer
-    // until the button is released, wherever the bar's row has scrolled to.
+}
+
+/// Quick Offset of layer bars follows the pointer.
+fn quick_offset_follow(g: &mut Rw) {
+    let t = g.tl.t;
+    let pps = g.tl.pps;
+    let fd = g.tl.fd;
+    let ctx = g.tl.ctx.clone();
+    let actions = &mut g.tl.actions;
+    let ui_actions = &mut g.ui_actions;
     if let Some(mut q) = ctx.data(|d| d.get_temp::(quick_bar_id())) {
         let (down, ptr) = ctx.input(|i| (i.pointer.primary_down(), i.pointer.latest_pos()));
         match ptr.filter(|_| down) {
@@ -3388,7 +4421,19 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
     }
     // Keyframe drag: the selected keys move by whole frames with the pointer until it is
     // released (one undo step); Shift snaps the grabbed key to the current time, the work area
-    // and composition markers.
+}
+
+/// The key drag moves the selected keys by whole frames.
+fn key_drag_follow(app: &mut EffectcraftApp, g: &mut Rw) {
+    let t = g.tl.t;
+    let time = g.tl.time;
+    let pps = g.tl.pps;
+    let fd = g.tl.fd;
+    let ctx = g.tl.ctx.clone();
+    let comp = g.tl.comp.clone();
+    let rows = &g.tl.rows;
+    let actions = &mut g.tl.actions;
+    let ui_actions = &mut g.ui_actions;
     if let Some(mut kd) = ctx.data(|d| d.get_temp::(key_drag_id())) {
         let (down, ptr, shift) = ctx.input(|i| (i.pointer.primary_down(), i.pointer.latest_pos(), i.modifiers.shift));
         match ptr.filter(|_| down) {
@@ -3399,7 +4444,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
                     // not the moving keys themselves.
                     let moving: Vec =
                         app.session.state.selected_keys.iter().filter_map(|k| comp.layer(k.layer).map(|l| l.comp_time(k.time).seconds())).collect();
-                    let mut cands = snap_candidates(&comp, &rows);
+                    let mut cands = snap_candidates(&comp, rows);
                     cands.retain(|c| !moving.iter().any(|m| (m - c).abs() < 1e-9));
                     cands.push(time.seconds());
                     target = snap_time(&cands, target, 8.0 / pps);
@@ -3430,13 +4475,25 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
             }
         }
     }
+}
+
+/// Releasing the key marquee selects the keys inside it.
+fn key_box_select(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw) {
+    let tm = g.tl.tm;
+    let rows_rect = g.tl.rows_rect;
+    let rh = g.tl.rh;
+    let ctx = g.tl.ctx.clone();
+    let comp = g.tl.comp.clone();
+    let rows = &g.tl.rows;
+    let actions = &mut g.tl.actions;
+    let empty = g.empty.clone();
     if empty.drag_stopped()
         && let Some(br) = ctx.data(|d| d.get_temp::(egui::Id::unique("tl-box")))
     {
         ctx.data_mut(|d| d.remove::(egui::Id::unique("tl-box")));
         let mut keys = vec![];
         let mut yy = rows_rect.min.y - app.ui.timeline.scroll_y;
-        for row in &rows {
+        for row in rows.iter() {
             let cy = yy + rh / 2.0;
             yy += row_height(row, rh);
             if let RowKind::Prop { uid } = row.kind
@@ -3461,8 +4518,17 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
             actions.push(("keys.select".into(), json!({"keys": keys, "add": mods.shift, "selectProperties": true})));
         }
     }
+}
 
-    // CTI.
+fn current_time_indicator(app: &mut EffectcraftApp, g: &Rw) {
+    let t = g.tl.t;
+    let time = g.tl.time;
+    let rect = g.tl.rect;
+    let graph_x0 = g.tl.graph_x0;
+    let tm = g.tl.tm;
+    let rows_rect = g.tl.rows_rect;
+    let ruler = g.tl.ruler;
+    let p = g.tl.p.clone();
     let cx = tm.x(time.seconds());
     if cx >= graph_x0 && cx <= rect.max.x {
         p.line_segment([pos2(cx, ruler.min.y + 12.0), pos2(cx, rows_rect.max.y)], Stroke::new(1.0, t.cti));
@@ -3476,9 +4542,19 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         p.add(egui::Shape::convex_polygon(head, t.cti, Stroke::NONE));
         app.auto.add("timeline.cti", Rect::from_center_size(pos2(cx, ruler.center().y), vec2(12.0, 20.0)), "Current time indicator");
     }
-    let _ = graph_rect;
+}
 
-    // Footer.
+/// The footer: scroll bars, the switches / modes toggle and render time.
+fn footer_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &Rw) -> Rect {
+    let t = g.tl.t;
+    let rect = g.tl.rect;
+    let footer_h = g.tl.footer_h;
+    let overflow = g.tl.overflow;
+    let oscroll = g.tl.oscroll;
+    let graph_x0 = g.tl.graph_x0;
+    let rows_rect = g.tl.rows_rect;
+    let max_scroll = g.tl.max_scroll;
+    let p = g.tl.p.clone();
     let footer = Rect::from_min_max(pos2(rect.min.x, rect.max.y - footer_h), rect.max);
     p.rect_filled(footer, 0.0, t.panel_bg);
     p.line_segment([footer.left_top(), footer.right_top()], Stroke::new(1.0, t.separator));
@@ -3514,6 +4590,20 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         t.text_faint,
     );
     // Zoom slider.
+    footer
+}
+
+/// The zoom buttons and slider.
+fn footer_zoom(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &Rw, footer: Rect) {
+    let t = g.tl.t;
+    let time = g.tl.time;
+    let rect = g.tl.rect;
+    let graph_x0 = g.tl.graph_x0;
+    let fit_pps = g.tl.fit_pps;
+    let pps = g.tl.pps;
+    let tm = g.tl.tm;
+    let comp = g.tl.comp.clone();
+    let p = g.tl.p.clone();
     let zs = Rect::from_min_max(pos2(graph_x0 + 30.0, footer.center().y - 2.0), pos2((graph_x0 + 230.0).min(rect.max.x - 30.0), footer.center().y + 2.0));
     // Zoom out / zoom in buttons either side of the slider: the `-` / `=` steps. Everything here
     // zooms around the CTI and sets the one time zoom the layer bars and the Graph Editor share.
@@ -3545,16 +4635,34 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         let f = ((pt.x - zs.min.x) / zs.width().max(1.0)).clamp(0.0, 1.0) as f64;
         set_zoom(&mut app.ui.timeline, &comp, fit_pps * (maxpps / fit_pps).powf(f), fit_pps, cti, cti_px);
     }
+}
 
+fn comp_markers_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &Rw) {
+    let graph_x0 = g.tl.graph_x0;
+    let tm = g.tl.tm;
+    let rows_rect = g.tl.rows_rect;
+    let ruler = g.tl.ruler;
+    let bin = g.tl.bin;
+    let comp = g.tl.comp.clone();
     // Composition markers in the ruler; protected regions shade the layer rows.
     let mstrip = Rect::from_min_max(pos2(graph_x0, ruler.max.y - 12.0), pos2(bin.min.x, ruler.max.y));
     let mrows = rows_rect.intersect(Rect::from_min_max(pos2(graph_x0, rows_rect.min.y), rows_rect.max));
     super::markers_ui::comp_markers(app, ui, &comp, tm, mstrip, mrows);
     super::markers_ui::marker_bin(app, ui, &comp, tm, bin, mstrip);
+}
 
-    // Spacebar held: the Hand tool. Dragging pans the rows vertically as well as scrolling the
-    // time graph in time when zoomed in (registered last, so it takes the drag from the bars,
-    // keys and markers there).
+/// Spacebar held: the Hand tool.
+fn hand_tool_pan(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &Rw) {
+    let rect = g.tl.rect;
+    let graph_x0 = g.tl.graph_x0;
+    let graph_x1 = g.tl.graph_x1;
+    let pps = g.tl.pps;
+    let tm = g.tl.tm;
+    let rows_rect = g.tl.rows_rect;
+    let ruler = g.tl.ruler;
+    let max_scroll = g.tl.max_scroll;
+    let ctx = g.tl.ctx.clone();
+    let comp = g.tl.comp.clone();
     if super::space_hand(&ctx) {
         let area = Rect::from_min_max(pos2(graph_x0, ruler.min.y), pos2(rect.max.x, rows_rect.max.y));
         let hand = ui.interact(area, egui::Id::unique("tl-hand"), Sense::drag());
@@ -3572,184 +4680,130 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
             }
         }
     }
+}
 
-    // Drop targets: effects and animation presets from Effects & Presets go on the layer under the
-    // pointer. Footage and
-    // comps from the Project panel and files from the Media Browser go where they are dropped: a
-    // line shows the place in the stack, and over the time graph a marker shows the In point too
-    // (Shift: at the current time).
-    if let Some(payload) = egui::DragAndDrop::payload::(&ctx)
-        && !matches!(*payload, crate::panels::DragPayload::Property { .. })
-        && let Some(ptr) = ctx.input(|i| i.pointer.hover_pos()).filter(|_| ui.rect_contains_pointer(rows_rect))
-    {
-        let released = ctx.input(|i| i.pointer.any_released());
-        // Alt/Option-drag from the Project panel replaces the source of the layer under the
-        // pointer (After Effects). The shortcut replaces every selected layer instead.
-        let alt_item = ctx.input(|i| i.modifiers.alt) && matches!(payload.as_ref(), crate::panels::DragPayload::Item(_));
-        if alt_item {
-            if let crate::panels::DragPayload::Item(id) = payload.as_ref() {
-                let target = hit_rows.iter().find(|(r, _)| ptr.y >= r.min.y && ptr.y < r.max.y).map(|(_, row)| row.layer);
-                if let Some(l) = target
-                    && replace_source_target(app, cid, ItemId(*id), l, &comp)
-                    && let Some(span) = hit_rows.iter().filter(|(_, row)| row.layer == l).map(|(r, _)| *r).reduce(|a, b| a.union(b))
-                {
-                    let lit = span.intersect(rows_rect);
-                    let rp = p.with_clip_rect(rows_rect);
-                    rp.rect_filled(lit, 0.0, t.accent.gamma_multiply(0.18));
-                    rp.rect_stroke(lit, 0.0, Stroke::new(2.0, t.accent), StrokeKind::Inside);
-                    app.auto.add("timeline.replaceSourceTarget", lit, "Replace the layer's source");
-                    if released {
-                        actions.push(("layer.replaceSource".into(), json!({"layers": [l.0], "item": *id, "fixExpressions": false})));
-                    }
-                }
-            }
-        } else if matches!(payload.as_ref(), crate::panels::DragPayload::Effect(_) | crate::panels::DragPayload::Preset { .. }) {
-            let target = hit_rows.iter().find(|(r, _)| ptr.y >= r.min.y && ptr.y < r.max.y).map(|(_, row)| row.layer);
-            // The layer the effect would land on (its row and its open properties) is the one
-            // that lights up, not the whole timeline.
-            if let Some(l) = target
-                && let Some(span) = hit_rows.iter().filter(|(_, row)| row.layer == l).map(|(r, _)| *r).reduce(|a, b| a.union(b))
-            {
-                let lit = span.intersect(rows_rect);
-                let rp = p.with_clip_rect(rows_rect);
-                rp.rect_filled(lit, 0.0, t.accent.gamma_multiply(0.18));
-                rp.rect_stroke(lit, 0.0, Stroke::new(2.0, t.accent), StrokeKind::Inside);
-                app.auto.add("timeline.dropTarget", lit, "Drop target layer");
-            }
-            if released && let Some(l) = target {
-                match payload.as_ref() {
-                    crate::panels::DragPayload::Effect(e) => actions.push(("effect.apply".into(), json!({"effect": e, "layers": [l.0]}))),
-                    crate::panels::DragPayload::Preset { preset, .. } => {
-                        let (id, params) = preset.apply_to(l.0);
-                        actions.push((id.into(), params));
-                    }
-                    _ => {}
-                }
-            }
-        } else {
-            let layer_rows: Vec<(Rect, LayerId)> = hit_rows.iter().filter(|(_, r)| matches!(r.kind, RowKind::Layer)).map(|(rr, r)| (*rr, r.layer)).collect();
-            let (at, line_y) = insertion(&layer_rows, ptr.y, rows_rect.min.y);
-            let index = match at.and_then(|i| layer_rows.get(i)) {
-                Some((_, l)) => comp.index_of(*l).unwrap_or(1),
-                None => layer_rows.last().and_then(|(_, l)| comp.index_of(*l)).unwrap_or(comp.layers.len()) + 1,
-            };
-            let start = (ptr.x >= graph_x0 && !graph_on).then(|| {
-                let t = if ctx.input(|i| i.modifiers.shift) { time } else { Tick::from_seconds_f64(tm.t(ptr.x)) };
-                comp.frame_rate.snap_nearest(t).clamp(Tick::ZERO, comp.duration - comp.frame_duration())
-            });
+/// Alt-drag of an item over a layer: replace its source.
+fn drop_replace_source(app: &mut EffectcraftApp, g: &mut Rw, payload: &std::sync::Arc, ptr: Pos2, released: bool) {
+    let t = g.tl.t;
+    let cid = g.tl.cid;
+    let rows_rect = g.tl.rows_rect;
+    let comp = g.tl.comp.clone();
+    let p = g.tl.p.clone();
+    let actions = &mut g.tl.actions;
+    let hit_rows = &mut g.hit_rows;
+    if let crate::panels::DragPayload::Item(id) = payload.as_ref() {
+        let target = hit_rows.iter().find(|(r, _)| ptr.y >= r.min.y && ptr.y < r.max.y).map(|(_, row)| row.layer);
+        if let Some(l) = target
+            && replace_source_target(app, cid, ItemId(*id), l, &comp)
+            && let Some(span) = hit_rows.iter().filter(|(_, row)| row.layer == l).map(|(r, _)| *r).reduce(|a, b| a.union(b))
+        {
+            let lit = span.intersect(rows_rect);
             let rp = p.with_clip_rect(rows_rect);
-            rp.line_segment([pos2(rect.min.x, line_y), pos2(rows_rect.max.x, line_y)], Stroke::new(2.0, t.accent));
-            if let Some(s) = start {
-                let x = tm.x(s.seconds());
-                rp.line_segment([pos2(x, rows_rect.min.y), pos2(x, rows_rect.max.y)], Stroke::new(1.0, t.accent));
-                // The In point's time in a tag beside the marker.
-                let g = rp.layout_no_wrap(super::timecode(&app.session, &comp, s), Tokens::ui(11.0), Color32::WHITE);
-                let tag = Align2::LEFT_BOTTOM.anchor_size(pos2(x + 4.0, line_y - 3.0), g.size());
-                rp.rect_filled(tag.expand(2.0), 2.0, t.accent);
-                rp.galley(tag.min, g, Color32::WHITE);
-            }
+            rp.rect_filled(lit, 0.0, t.accent.gamma_multiply(0.18));
+            rp.rect_stroke(lit, 0.0, Stroke::new(2.0, t.accent), StrokeKind::Inside);
+            app.auto.add("timeline.replaceSourceTarget", lit, "Replace the layer's source");
             if released {
-                let time = json!(start.map(Tick::seconds));
-                match payload.as_ref() {
-                    crate::panels::DragPayload::Item(id) => {
-                        // Insert each selected item at the drop point, in selection order.
-                        for (offset, item) in crate::panels::project_drop_items(app, *id).into_iter().enumerate() {
-                            actions.push(("layer.addItem".into(), json!({"item": item, "index": index.saturating_add(offset), "time": time.clone()})));
-                        }
-                    }
-                    crate::panels::DragPayload::Files(paths) => {
-                        actions.push(("mediaBrowser.import".into(), json!({"paths": paths, "addToComp": true, "index": index, "time": time})))
-                    }
-                    crate::panels::DragPayload::Effect(_) | crate::panels::DragPayload::Preset { .. } | crate::panels::DragPayload::Property { .. } => {}
-                }
+                actions.push(("layer.replaceSource".into(), json!({"layers": [l.0], "item": *id, "fixExpressions": false})));
             }
         }
-        if released {
-            egui::DragAndDrop::clear_payload(&ctx);
-        }
     }
+}
 
-    // Apply.
-    for a in ui_actions {
-        match a {
-            UiAct::ToggleLayer(id, all) => {
-                let tl = &mut app.ui.timeline;
-                // Twirled by hand, the layer shows its whole tree again.
-                tl.reveal.clear();
-                tl.layer_reveal.remove(&id);
-                tl.full_groups.remove(&id);
-                let open = !tl.open_layers.contains(&id);
-                if open {
-                    tl.open_layers.insert(id);
-                } else {
-                    tl.open_layers.remove(&id);
-                }
-                if all && let Some(l) = comp.layer(LayerId(id)) {
-                    let mut uids = vec![];
-                    walk_groups(&l.props, &mut uids);
-                    for u in uids {
-                        if open {
-                            tl.open_groups.insert(u);
-                        } else {
-                            tl.open_groups.remove(&u);
-                        }
-                    }
-                }
-            }
-            UiAct::ToggleGroup(layer, uid, all) => {
-                let tl = &mut app.ui.timeline;
-                tl.toggle_group(layer, uid);
-                let open = tl.open_groups.contains(&uid);
-                if all && let Some(g) = comp.layer(LayerId(layer)).and_then(|l| find_group(&l.props, uid)) {
-                    let mut uids = vec![];
-                    walk_groups(g, &mut uids);
-                    for u in uids {
-                        if open {
-                            tl.open_groups.insert(u);
-                        } else {
-                            tl.open_groups.remove(&u);
-                        }
-                    }
-                }
-            }
-            UiAct::SetTime(tt) => app.session.set_time(tt),
-            UiAct::EndMerge => app.session.history.merge_key = None,
-            UiAct::ChangeRenderer => {
-                if super::dialogs::open_comp_settings(app).is_ok() {
-                    app.dialog_state.comp.tab = super::comp_settings::Tab::Renderer;
-                }
+/// Effects and presets drop onto the layer under the pointer.
+fn drop_on_layer(app: &mut EffectcraftApp, g: &mut Rw, payload: &std::sync::Arc, ptr: Pos2, released: bool) {
+    let t = g.tl.t;
+    let rows_rect = g.tl.rows_rect;
+    let p = g.tl.p.clone();
+    let actions = &mut g.tl.actions;
+    let hit_rows = &mut g.hit_rows;
+    let target = hit_rows.iter().find(|(r, _)| ptr.y >= r.min.y && ptr.y < r.max.y).map(|(_, row)| row.layer);
+    // The layer the effect would land on (its row and its open properties) is the one
+    // that lights up, not the whole timeline.
+    if let Some(l) = target
+        && let Some(span) = hit_rows.iter().filter(|(_, row)| row.layer == l).map(|(r, _)| *r).reduce(|a, b| a.union(b))
+    {
+        let lit = span.intersect(rows_rect);
+        let rp = p.with_clip_rect(rows_rect);
+        rp.rect_filled(lit, 0.0, t.accent.gamma_multiply(0.18));
+        rp.rect_stroke(lit, 0.0, Stroke::new(2.0, t.accent), StrokeKind::Inside);
+        app.auto.add("timeline.dropTarget", lit, "Drop target layer");
+    }
+    if released && let Some(l) = target {
+        match payload.as_ref() {
+            crate::panels::DragPayload::Effect(e) => actions.push(("effect.apply".into(), json!({"effect": e, "layers": [l.0]}))),
+            crate::panels::DragPayload::Preset { preset, .. } => {
+                let (id, params) = preset.apply_to(l.0);
+                actions.push((id.into(), params));
             }
+            _ => {}
         }
     }
-    if ui.input(|i| i.pointer.primary_down()) && actions.iter().any(|(id, _)| id == "prop.set") {
-        app.ui.viewer.property_interacting = true;
-        ctx.request_repaint();
+}
+
+/// Footage and files drop into the stack: a line shows the place.
+fn drop_insert_layers(app: &mut EffectcraftApp, g: &mut Rw, payload: &std::sync::Arc, ptr: Pos2, released: bool) {
+    let t = g.tl.t;
+    let time = g.tl.time;
+    let rect = g.tl.rect;
+    let graph_x0 = g.tl.graph_x0;
+    let tm = g.tl.tm;
+    let rows_rect = g.tl.rows_rect;
+    let ctx = g.tl.ctx.clone();
+    let comp = g.tl.comp.clone();
+    let p = g.tl.p.clone();
+    let actions = &mut g.tl.actions;
+    let graph_on = g.graph_on;
+    let hit_rows = &mut g.hit_rows;
+    let layer_rows: Vec<(Rect, LayerId)> = hit_rows.iter().filter(|(_, r)| matches!(r.kind, RowKind::Layer)).map(|(rr, r)| (*rr, r.layer)).collect();
+    let (at, line_y) = insertion(&layer_rows, ptr.y, rows_rect.min.y);
+    let index = match at.and_then(|i| layer_rows.get(i)) {
+        Some((_, l)) => comp.index_of(*l).unwrap_or(1),
+        None => layer_rows.last().and_then(|(_, l)| comp.index_of(*l)).unwrap_or(comp.layers.len()) + 1,
+    };
+    let start = (ptr.x >= graph_x0 && !graph_on).then(|| {
+        let t = if ctx.input(|i| i.modifiers.shift) { time } else { Tick::from_seconds_f64(tm.t(ptr.x)) };
+        comp.frame_rate.snap_nearest(t).clamp(Tick::ZERO, comp.duration - comp.frame_duration())
+    });
+    let rp = p.with_clip_rect(rows_rect);
+    rp.line_segment([pos2(rect.min.x, line_y), pos2(rows_rect.max.x, line_y)], Stroke::new(2.0, t.accent));
+    if let Some(s) = start {
+        let x = tm.x(s.seconds());
+        rp.line_segment([pos2(x, rows_rect.min.y), pos2(x, rows_rect.max.y)], Stroke::new(1.0, t.accent));
+        // The In point's time in a tag beside the marker.
+        let g = rp.layout_no_wrap(super::timecode(&app.session, &comp, s), Tokens::ui(11.0), Color32::WHITE);
+        let tag = Align2::LEFT_BOTTOM.anchor_size(pos2(x + 4.0, line_y - 3.0), g.size());
+        rp.rect_filled(tag.expand(2.0), 2.0, t.accent);
+        rp.galley(tag.min, g, Color32::WHITE);
     }
-    for (id, params) in actions {
-        if id == "__endMerge" {
-            app.session.history.merge_key = None;
-            continue;
-        }
-        if let Err(e) = crate::menus::invoke(app, &ctx, &id, params) {
-            app.ui.status = e;
+    if released {
+        let time = json!(start.map(Tick::seconds));
+        match payload.as_ref() {
+            crate::panels::DragPayload::Item(id) => {
+                // Insert each selected item at the drop point, in selection order.
+                for (offset, item) in crate::panels::project_drop_items(app, *id).into_iter().enumerate() {
+                    actions.push(("layer.addItem".into(), json!({"item": item, "index": index.saturating_add(offset), "time": time.clone()})));
+                }
+            }
+            crate::panels::DragPayload::Files(paths) => {
+                actions.push(("mediaBrowser.import".into(), json!({"paths": paths, "addToComp": true, "index": index, "time": time})))
+            }
+            crate::panels::DragPayload::Effect(_) | crate::panels::DragPayload::Preset { .. } | crate::panels::DragPayload::Property { .. } => {}
         }
     }
 }
 
 /// The optional per-layer cells: Comment (double-click to edit), In / Out / Duration (drag to
 /// change by frames), Stretch (click: Time Stretch dialog).
-#[allow(clippy::too_many_arguments)]
 fn layer_cells(
     app: &mut EffectcraftApp,
     ui: &mut egui::Ui,
     lp: &egui::Painter,
     cw: &Cols,
     comp: &Comp,
-    layer: &Layer,
-    r: Rect,
-    cy: f32,
+    rc: &RowCtx<'_>,
     actions: &mut Vec<(String, serde_json::Value)>,
 ) {
+    let (layer, r, cy) = (rc.layer, rc.r, rc.cy);
     let t = app.tokens;
     let ctx = ui.ctx().clone();
     let lid = layer.id.0;
@@ -3863,17 +4917,16 @@ fn find_group(g: &PropGroup, uid: u64) -> Option<&PropGroup> {
 }
 
 /// A stroke's Dashes "+" / "−" buttons (add or remove a Dash/Gap pair).
-#[allow(clippy::too_many_arguments)]
 fn dash_buttons(
     app: &mut EffectcraftApp,
     ui: &mut egui::Ui,
     p: &egui::Painter,
     layer: &Layer,
     uid: u64,
-    x: f32,
-    cy: f32,
+    at: Pos2,
     actions: &mut Vec<(String, serde_json::Value)>,
 ) {
+    let (x, cy) = (at.x, at.y);
     if !matches!(layer.source, LayerSource::Shape) || layer.props.find_group(uid).is_none_or(|g| g.match_id != "dashes") {
         return;
     }
@@ -3926,17 +4979,16 @@ const SHAPE_ADD_ITEMS: &[(&str, &str)] = &[
 
 /// A shape layer's Contents "Add:" pop-up menu: adds a shape, paint or path operation to the
 /// selected shape group, else to the layer's contents.
-#[allow(clippy::too_many_arguments)]
 fn shape_add_popup(
     app: &mut EffectcraftApp,
     ui: &mut egui::Ui,
     p: &egui::Painter,
     layer: &Layer,
     uid: u64,
-    x: f32,
-    cy: f32,
+    at: Pos2,
     actions: &mut Vec<(String, serde_json::Value)>,
 ) {
+    let (x, cy) = (at.x, at.y);
     if !matches!(layer.source, LayerSource::Shape) || layer.props.sub("contents").is_none_or(|c| c.uid != uid) {
         return;
     }
@@ -3969,17 +5021,16 @@ fn shape_add_popup(
 }
 
 /// The Text group's "Animate:" and an animator's "Add:" pop-up menus (AE's twirl-down menus).
-#[allow(clippy::too_many_arguments)]
 fn text_anim_popups(
     app: &mut EffectcraftApp,
     ui: &mut egui::Ui,
     p: &egui::Painter,
     layer: &Layer,
     uid: u64,
-    x: f32,
-    cy: f32,
+    at: Pos2,
     actions: &mut Vec<(String, serde_json::Value)>,
 ) {
+    let (x, cy) = (at.x, at.y);
     use effectcraft_engine::project::build::TEXT_ANIMATOR_KINDS;
     if !matches!(layer.source, LayerSource::Text) {
         return;
@@ -4160,7 +5211,6 @@ impl LayerMenuCaps {
 /// A layer row's or bar's context menu. As in After Effects, right-clicking a layer that is part
 /// of the selection keeps the selection and the menu acts on all of it; right-clicking an
 /// unselected layer selects only that layer.
-#[allow(clippy::too_many_arguments)]
 fn layer_context_menu(
     resp: &egui::Response,
     comp: &Comp,
@@ -4337,200 +5387,270 @@ fn fmt_num(v: f64, d: usize) -> String {
     format!("{v:.d$}")
 }
 
-/// Inline value editor for a property row; pushes `prop.set` actions. Returns the rects of a
-/// multi-dimension value's fields, one per dimension (none for other values).
-fn value_editor(
-    app: &mut EffectcraftApp,
-    ui: &mut egui::Ui,
-    p: &egui::Painter,
-    layer: &Layer,
-    prop: &Property,
-    value: &Value,
+/// What the property value editors share: where to draw, what they edit, and how a change is
+/// worded as a `prop.set` command.
+struct ValueEdit<'a> {
+    p: &'a egui::Painter,
+    layer: &'a Layer,
+    prop: &'a Property,
     at: Pos2,
-    actions: &mut Vec<(String, serde_json::Value)>,
-) -> Vec {
-    let t = app.tokens;
-    let uid = prop.uid;
-    let mut dims = vec![];
-    let merge = format!("scrub-{uid}");
-    // With several layers selected the edit applies to all of them (#491): a scrub by the same
-    // change, a typed value as it is.
-    let scrub = ui.ctx().dragged_id().is_some() && ui.input(|i| i.pointer.is_decidedly_dragging());
-    // Proportional Scrubbing (After Effects 26.2): Ctrl+Alt (Cmd+Opt) spreads the change over the
-    // selected layers by selection order, the first unchanged and the last by all of it.
-    let proportional = scrub && ui.input(|i| widgets::proportional_scrub(i.modifiers));
-    let set = |actions: &mut Vec<(String, serde_json::Value)>, v: serde_json::Value| {
+    uid: u64,
+    merge: String,
+    scrub: bool,
+    proportional: bool,
+}
+
+impl<'a> ValueEdit<'a> {
+    /// The editor of `prop` on `layer` at `at`; `ui` says whether a scrub is under way.
+    fn new(ui: &egui::Ui, p: &'a egui::Painter, layer: &'a Layer, prop: &'a Property, at: Pos2) -> Self {
+        let uid = prop.uid;
+        // With several layers selected the edit applies to all of them (#491): a scrub by the same
+        // change, a typed value as it is.
+        let scrub = ui.ctx().dragged_id().is_some() && ui.input(|i| i.pointer.is_decidedly_dragging());
+        // Proportional Scrubbing (After Effects 26.2): Ctrl+Alt (Cmd+Opt) spreads the change over the
+        // selected layers by selection order, the first unchanged and the last by all of it.
+        let proportional = scrub && ui.input(|i| widgets::proportional_scrub(i.modifiers));
+        Self { p, layer, prop, at, uid, merge: format!("scrub-{uid}"), scrub, proportional }
+    }
+
+    /// Queues the `prop.set` of one edit.
+    fn set(&self, actions: &mut Vec<(String, serde_json::Value)>, v: serde_json::Value) {
         actions.push((
             "prop.set".into(),
-            json!({"layer": layer.id.0, "prop": uid, "value": v, "merge": merge, "selected": true, "offset": scrub, "proportional": proportional}),
+            json!({"layer": self.layer.id.0, "prop": self.uid, "value": v, "merge": self.merge, "selected": true, "offset": self.scrub, "proportional": self.proportional}),
         ))
-    };
-    let is_3d = layer.is_3d();
-    let mut x = at.x;
-    let y = at.y - 9.0;
+    }
+}
+
+/// Inline value editor for a property row; pushes `prop.set` actions. Returns the rects of a
+/// multi-dimension value's fields, one per dimension (none for other values).
+fn value_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, ve: &ValueEdit<'_>, value: &Value, actions: &mut Vec<(String, serde_json::Value)>) -> Vec {
     match value {
-        Value::Scalar(v) => {
-            let (min, max, dec, speed) = match &prop.ui {
-                ParamUi::Slider { min, max, decimals, .. } => (*min, *max, *decimals as usize, widgets::slider_scrub_speed(&prop.ui)),
-                ParamUi::Percent => (-1e6, 1e6, 1, 0.5),
-                _ => (-1e9, 1e9, 1, 1.0),
-            };
-            if matches!(prop.ui, ParamUi::Angle) {
-                let (rr, dr, nv) = super::fx_widgets::angle_field(ui, pos2(x, y), egui::Id::unique(("v", uid, 0)), *v, 1, &t);
-                app.auto.add(&format!("timeline.prop.{uid}.value"), dr, &prop.name);
-                app.auto.add(&format!("timeline.prop.{uid}.revolutions"), rr, &prop.name);
-                if let Some(nv) = nv {
-                    set(actions, json!(nv));
-                }
-            } else {
-                // A Range Selector's Start, End and Offset count characters with Units = Index (#1814).
-                let pct = (matches!(prop.ui, ParamUi::Percent) || prop.match_id == "opacity") && !effectcraft_engine::render::text::in_index_units(layer, uid);
-                let suffix = if pct { "%" } else { "" };
-                let (r, nv, _) = widgets::hot_number_at(ui, pos2(x, y), egui::Id::unique(("v", uid, 0)), *v, speed, (min, max), dec.max(1), suffix, &t);
-                app.auto.add(&format!("timeline.prop.{uid}.value"), r, &prop.name);
-                if let Some(nv) = nv {
-                    set(actions, json!(nv));
-                }
-            }
-        }
-        Value::Vec2(_) | Value::Vec3(_) => {
-            let c = value.components();
-            let n = if prop.shown_dims > 0 && !(is_3d && c.len() == 3) { prop.shown_dims as usize } else { c.len() };
-            let pct = matches!(prop.ui, ParamUi::Percent);
-            // Mask Feather: two linked values, never negative (as in After Effects, #203).
-            let feather = prop.match_id == "feather" && layer.props.parent_of(uid).is_some_and(|g| matches!(g.kind, GroupKind::Mask { .. }));
-            let range = if feather { (0.0, 1e9) } else { (-1e9, 1e9) };
-            // Scale and Mask Feather: the chain link (Constrain Proportions, on by default).
-            let linkable = feather || (pct && prop.match_id == "scale");
-            let linked = linkable && !app.ui.timeline.unlinked.contains(&uid);
-            if linkable {
-                let lr = Rect::from_center_size(pos2(x + 7.0, at.y), vec2(14.0, 14.0));
-                let resp = ui.interact(lr, egui::Id::unique(("tl-link", uid)), Sense::click()).on_hover_text(crate::i18n::tr("Constrain Proportions"));
-                icons::paint(p, lr, Icon::Link, if linked { t.accent } else { t.text_faint });
-                app.auto.add(&format!("timeline.prop.{uid}.link"), lr, "Constrain Proportions");
-                if resp.clicked() && !app.ui.timeline.unlinked.remove(&uid) {
-                    app.ui.timeline.unlinked.insert(uid);
-                }
-                x += 18.0;
-            }
-            for d in 0..n.min(c.len()) {
-                let suffix = if pct && d + 1 == n { "%" } else { "" };
-                let (r, nv, _) =
-                    widgets::hot_number_at(ui, pos2(x, y), egui::Id::unique(("v", uid, d)), c[d], if pct { 0.5 } else { 1.0 }, range, 1, suffix, &t);
-                app.auto.add(&format!("timeline.prop.{uid}.value.{d}"), r, &prop.name);
-                dims.push(r);
-                if let Some(nv) = nv {
-                    let mut nc = c.clone();
-                    // Alt edits one value of a linked pair (Ctrl+Alt is a proportional scrub).
-                    let shown = if linked && !ui.input(|i| i.modifiers.alt && !widgets::proportional_scrub(i.modifiers)) { n.min(c.len()) } else { 1 };
-                    let from = if shown == 1 { d } else { 0 };
-                    for (e, v) in widgets::constrained(c.get(from..from + shown).unwrap_or_default(), d - from, nv).into_iter().enumerate() {
-                        if let Some(slot) = nc.get_mut(from + e) {
-                            *slot = v;
-                        }
-                    }
-                    set(actions, json!(nc));
-                    // Typed into these components only: other selected layers keep the rest of
-                    // their own value (#1434).
-                    if let Some((_, a)) = actions.last_mut() {
-                        a["dims"] = json!((from..from + shown).collect::>());
-                    }
-                }
-                x = r.max.x + if d + 1 < n { 2.0 } else { 0.0 };
-                if d + 1 < n {
-                    p.text(pos2(x, at.y), Align2::LEFT_CENTER, ",", Tokens::ui(12.0), t.hot_text);
-                    x += 6.0;
-                }
-            }
-        }
-        Value::Color(c) => {
-            let r = Rect::from_min_size(pos2(x, at.y - 7.0), vec2(26.0, 14.0));
-            let resp = widgets::swatch(ui, r, [c[0] as f32, c[1] as f32, c[2] as f32, 1.0], egui::Id::unique(("sw", uid)), &t);
-            app.auto.add(&format!("timeline.prop.{uid}.value"), r, &prop.name);
-            let pop = egui::Id::unique(("cpop", uid));
-            if resp.clicked() {
-                widgets::open_popup(ui, pop);
-            }
-            // The eyedropper after the swatch samples the Composition panel (shape Fill and
-            // Stroke colours, text and effect colours), as in After Effects' Timeline (#633).
-            let er = Rect::from_center_size(pos2(r.max.x + 12.0, at.y), vec2(16.0, 16.0));
-            let active = app.ui.fx_pick.as_ref().is_some_and(|k| k.prop == uid && k.kind == "color");
-            if super::fx_widgets::eyedropper_button(ui, er, active, egui::Id::unique(("tl-eye", uid)), &t)
-                .on_hover_text(crate::i18n::tr("Eyedropper: click, then click in the Composition panel"))
-                .clicked()
-            {
-                super::effect_controls::toggle_pick(app, "color", layer, prop);
-            }
-            app.auto.add(&format!("timeline.prop.{uid}.eyedropper"), er, &prop.name);
-            let mut rgb = [c[0] as f32, c[1] as f32, c[2] as f32];
-            if crate::widgets::color_popup(ui, pop, r.left_bottom(), &mut rgb) {
-                set(actions, json!([rgb[0], rgb[1], rgb[2], 1.0]));
-            }
-        }
-        Value::Bool(b) => {
-            let r = Rect::from_min_size(pos2(x, at.y - 8.0), vec2(16.0, 16.0));
-            if widgets::checkbox(ui, r, *b, &t, egui::Id::unique(("cb", uid))).clicked() {
-                set(actions, json!(!b));
-            }
-            app.auto.add(&format!("timeline.prop.{uid}.value"), r, &prop.name);
-        }
-        Value::Enum(i) => {
-            // Text Path Options ▸ Path lists the layer's masks.
-            let mask_opts: Option> = layer
-                .props
-                .prop("text/pathOptions/path")
-                .filter(|q| q.uid == uid)
-                .map(|_| std::iter::once("None".to_string()).chain(layer.masks().into_iter().flat_map(|m| m.groups().map(|g| g.name.clone()))).collect());
-            let ui_opts = mask_opts.map(|options| ParamUi::Popup { options });
-            if let ParamUi::Popup { options } = ui_opts.as_ref().unwrap_or(&prop.ui) {
-                let r = Rect::from_min_size(pos2(x, at.y - 9.0), vec2(150.0, 18.0));
-                let label = options.get(*i as usize).cloned().unwrap_or_default();
-                let pop = egui::Id::unique(("epop", uid));
-                if widgets::dropdown(ui, r, &label, &t, egui::Id::unique(("en", uid))).clicked() {
-                    widgets::open_popup(ui, pop);
-                }
-                app.auto.add(&format!("timeline.prop.{uid}.value"), r, &prop.name);
-                if let Some(ni) = widgets::popup_menu(ui, pop, r.left_bottom(), options, Some(*i as usize)) {
-                    set(actions, json!(ni));
-                }
-            } else {
-                p.text(pos2(x, at.y), Align2::LEFT_CENTER, fmt_num(*i as f64, 0), Tokens::ui(12.0), t.hot_text);
-            }
-        }
+        Value::Scalar(_) => scalar_editor(app, ui, ve, actions, value),
+        Value::Vec2(_) | Value::Vec3(_) => return vector_editor(app, ui, ve, actions, value),
+        Value::Color(_) => color_editor(app, ui, ve, actions, value),
+        Value::Bool(_) => bool_editor(app, ui, ve, actions, value),
+        Value::Enum(_) => enum_editor(app, ui, ve, actions, value),
+        Value::Layer(_) => layer_editor(app, ui, ve, actions, value),
+        Value::Gradient(_) => gradient_editor(app, ui, ve, actions),
         Value::Path(_) => {
-            p.text(pos2(x, at.y), Align2::LEFT_CENTER, crate::i18n::tr("Shape…"), Tokens::ui(12.0), t.hot_text);
+            ve.p.text(pos2(ve.at.x, ve.at.y), Align2::LEFT_CENTER, crate::i18n::tr("Shape…"), Tokens::ui(12.0), app.tokens.hot_text);
         }
         Value::Text(_) => {}
-        Value::Layer(l) => {
-            // Layer references (a light's Source, a Displacement Map's layer…) pick from the
-            // comp's layers, as in Effect Controls; the choice is written through the same
-            // undoable prop.set as the other value arms.
-            let r = Rect::from_min_size(pos2(x, at.y - 9.0), vec2(150.0, 18.0));
-            if let Some(comp) = app.session.active_comp_arc()
-                && let Some(chosen) = widgets::layer_picker(ui, r, &comp.layers, *l, &t, egui::Id::unique(("tl-lay", uid)))
-            {
-                set(actions, chosen.map(|id| json!(id)).unwrap_or(serde_json::Value::Null));
-            }
-            app.auto.add(&format!("timeline.prop.{uid}.value"), r, &prop.name);
-        }
-        Value::Gradient(_) => {
-            // Opens the Gradient Editor at the current time (#612).
-            let r = p.text(pos2(x, at.y), Align2::LEFT_CENTER, crate::i18n::tr("Edit Gradient…"), Tokens::ui(12.0), t.hot_text);
-            let resp = ui.interact(r.expand(2.0), egui::Id::unique(("gradient-edit", uid)), Sense::click());
-            if resp.hovered() {
-                ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
-            }
-            app.auto.add(&format!("timeline.prop.{uid}.value"), r, &prop.name);
-            if resp.clicked() {
-                actions.push(("prop.editGradient".into(), json!({"layer": layer.id.0, "prop": uid})));
-            }
-        }
         Value::Str(s) => {
-            p.text(pos2(x, at.y), Align2::LEFT_CENTER, s.chars().take(30).collect::(), Tokens::ui(12.0), t.text_dim);
+            ve.p.text(pos2(ve.at.x, ve.at.y), Align2::LEFT_CENTER, s.chars().take(30).collect::(), Tokens::ui(12.0), app.tokens.text_dim);
+        }
+    }
+    Vec::new()
+}
+
+/// A number: scrubbable hot text, or the angle field.
+fn scalar_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, ve: &ValueEdit<'_>, actions: &mut Vec<(String, serde_json::Value)>, value: &Value) {
+    let Value::Scalar(v) = value else { return };
+    let (layer, prop, at, uid) = (ve.layer, ve.prop, ve.at, ve.uid);
+    let t = app.tokens;
+    let (x, y) = (at.x, at.y - 9.0);
+    let (min, max, dec, speed) = match &prop.ui {
+        ParamUi::Slider { min, max, decimals, .. } => (*min, *max, *decimals as usize, widgets::slider_scrub_speed(&prop.ui)),
+        ParamUi::Percent => (-1e6, 1e6, 1, 0.5),
+        _ => (-1e9, 1e9, 1, 1.0),
+    };
+    if matches!(prop.ui, ParamUi::Angle) {
+        let (rr, dr, nv) = super::fx_widgets::angle_field(ui, pos2(x, y), egui::Id::unique(("v", uid, 0)), *v, 1, &t);
+        app.auto.add(&format!("timeline.prop.{uid}.value"), dr, &prop.name);
+        app.auto.add(&format!("timeline.prop.{uid}.revolutions"), rr, &prop.name);
+        if let Some(nv) = nv {
+            ve.set(actions, json!(nv));
+        }
+    } else {
+        // A Range Selector's Start, End and Offset count characters with Units = Index (#1814).
+        let pct = (matches!(prop.ui, ParamUi::Percent) || prop.match_id == "opacity") && !effectcraft_engine::render::text::in_index_units(layer, uid);
+        let suffix = if pct { "%" } else { "" };
+        let (r, nv, _) = widgets::hot_number_at(
+            ui,
+            pos2(x, y),
+            egui::Id::unique(("v", uid, 0)),
+            *v,
+            &widgets::HotSpec { speed, range: (min, max), decimals: dec.max(1), suffix },
+            &t,
+        );
+        app.auto.add(&format!("timeline.prop.{uid}.value"), r, &prop.name);
+        if let Some(nv) = nv {
+            ve.set(actions, json!(nv));
+        }
+    }
+}
+
+/// A multi-dimension value: one hot-text field per dimension, with the link chain. Returns their rects.
+fn vector_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, ve: &ValueEdit<'_>, actions: &mut Vec<(String, serde_json::Value)>, value: &Value) -> Vec {
+    let (p, layer, prop, at, uid) = (ve.p, ve.layer, ve.prop, ve.at, ve.uid);
+    let t = app.tokens;
+    let (mut x, y) = (at.x, at.y - 9.0);
+    let is_3d = layer.is_3d();
+    let mut dims = vec![];
+    let c = value.components();
+    let n = if prop.shown_dims > 0 && !(is_3d && c.len() == 3) { prop.shown_dims as usize } else { c.len() };
+    let pct = matches!(prop.ui, ParamUi::Percent);
+    // Mask Feather: two linked values, never negative (as in After Effects, #203).
+    let feather = prop.match_id == "feather" && layer.props.parent_of(uid).is_some_and(|g| matches!(g.kind, GroupKind::Mask { .. }));
+    let range = if feather { (0.0, 1e9) } else { (-1e9, 1e9) };
+    // Scale and Mask Feather: the chain link (Constrain Proportions, on by default).
+    let linkable = feather || (pct && prop.match_id == "scale");
+    let linked = linkable && !app.ui.timeline.unlinked.contains(&uid);
+    if linkable {
+        let lr = Rect::from_center_size(pos2(x + 7.0, at.y), vec2(14.0, 14.0));
+        let resp = ui.interact(lr, egui::Id::unique(("tl-link", uid)), Sense::click()).on_hover_text(crate::i18n::tr("Constrain Proportions"));
+        icons::paint(p, lr, Icon::Link, if linked { t.accent } else { t.text_faint });
+        app.auto.add(&format!("timeline.prop.{uid}.link"), lr, "Constrain Proportions");
+        if resp.clicked() && !app.ui.timeline.unlinked.remove(&uid) {
+            app.ui.timeline.unlinked.insert(uid);
+        }
+        x += 18.0;
+    }
+    for d in 0..n.min(c.len()) {
+        let suffix = if pct && d + 1 == n { "%" } else { "" };
+        let (r, nv, _) = widgets::hot_number_at(
+            ui,
+            pos2(x, y),
+            egui::Id::unique(("v", uid, d)),
+            c[d],
+            &widgets::HotSpec { speed: if pct { 0.5 } else { 1.0 }, range, decimals: 1, suffix },
+            &t,
+        );
+        app.auto.add(&format!("timeline.prop.{uid}.value.{d}"), r, &prop.name);
+        dims.push(r);
+        if let Some(nv) = nv {
+            let mut nc = c.clone();
+            // Alt edits one value of a linked pair (Ctrl+Alt is a proportional scrub).
+            let shown = if linked && !ui.input(|i| i.modifiers.alt && !widgets::proportional_scrub(i.modifiers)) { n.min(c.len()) } else { 1 };
+            let from = if shown == 1 { d } else { 0 };
+            for (e, v) in widgets::constrained(c.get(from..from + shown).unwrap_or_default(), d - from, nv).into_iter().enumerate() {
+                if let Some(slot) = nc.get_mut(from + e) {
+                    *slot = v;
+                }
+            }
+            ve.set(actions, json!(nc));
+            // Typed into these components only: other selected layers keep the rest of
+            // their own value (#1434).
+            if let Some((_, a)) = actions.last_mut() {
+                a["dims"] = json!((from..from + shown).collect::>());
+            }
+        }
+        x = r.max.x + if d + 1 < n { 2.0 } else { 0.0 };
+        if d + 1 < n {
+            p.text(pos2(x, at.y), Align2::LEFT_CENTER, ",", Tokens::ui(12.0), t.hot_text);
+            x += 6.0;
         }
     }
     dims
 }
 
+/// A colour: swatch, eyedropper and picker.
+fn color_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, ve: &ValueEdit<'_>, actions: &mut Vec<(String, serde_json::Value)>, value: &Value) {
+    let Value::Color(c) = value else { return };
+    let (layer, prop, at, uid) = (ve.layer, ve.prop, ve.at, ve.uid);
+    let t = app.tokens;
+    let x = at.x;
+    let r = Rect::from_min_size(pos2(x, at.y - 7.0), vec2(26.0, 14.0));
+    let resp = widgets::swatch(ui, r, [c[0] as f32, c[1] as f32, c[2] as f32, 1.0], egui::Id::unique(("sw", uid)), &t);
+    app.auto.add(&format!("timeline.prop.{uid}.value"), r, &prop.name);
+    let pop = egui::Id::unique(("cpop", uid));
+    if resp.clicked() {
+        widgets::open_popup(ui, pop);
+    }
+    // The eyedropper after the swatch samples the Composition panel (shape Fill and
+    // Stroke colours, text and effect colours), as in After Effects' Timeline (#633).
+    let er = Rect::from_center_size(pos2(r.max.x + 12.0, at.y), vec2(16.0, 16.0));
+    let active = app.ui.fx_pick.as_ref().is_some_and(|k| k.prop == uid && k.kind == "color");
+    if super::fx_widgets::eyedropper_button(ui, er, active, egui::Id::unique(("tl-eye", uid)), &t)
+        .on_hover_text(crate::i18n::tr("Eyedropper: click, then click in the Composition panel"))
+        .clicked()
+    {
+        super::effect_controls::toggle_pick(app, "color", layer, prop);
+    }
+    app.auto.add(&format!("timeline.prop.{uid}.eyedropper"), er, &prop.name);
+    let mut rgb = [c[0] as f32, c[1] as f32, c[2] as f32];
+    if crate::widgets::color_popup(ui, pop, r.left_bottom(), &mut rgb) {
+        ve.set(actions, json!([rgb[0], rgb[1], rgb[2], 1.0]));
+    }
+}
+
+/// A checkbox.
+fn bool_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, ve: &ValueEdit<'_>, actions: &mut Vec<(String, serde_json::Value)>, value: &Value) {
+    let Value::Bool(b) = value else { return };
+    let (prop, at, uid) = (ve.prop, ve.at, ve.uid);
+    let t = app.tokens;
+    let x = at.x;
+    let r = Rect::from_min_size(pos2(x, at.y - 8.0), vec2(16.0, 16.0));
+    if widgets::checkbox(ui, r, *b, &t, egui::Id::unique(("cb", uid))).clicked() {
+        ve.set(actions, json!(!b));
+    }
+    app.auto.add(&format!("timeline.prop.{uid}.value"), r, &prop.name);
+}
+
+/// A popup (or the number when the property has no options).
+fn enum_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, ve: &ValueEdit<'_>, actions: &mut Vec<(String, serde_json::Value)>, value: &Value) {
+    let Value::Enum(i) = value else { return };
+    let (p, layer, prop, at, uid) = (ve.p, ve.layer, ve.prop, ve.at, ve.uid);
+    let t = app.tokens;
+    let x = at.x;
+    // Text Path Options ▸ Path lists the layer's masks.
+    let mask_opts: Option> = layer
+        .props
+        .prop("text/pathOptions/path")
+        .filter(|q| q.uid == uid)
+        .map(|_| std::iter::once("None".to_string()).chain(layer.masks().into_iter().flat_map(|m| m.groups().map(|g| g.name.clone()))).collect());
+    let ui_opts = mask_opts.map(|options| ParamUi::Popup { options });
+    if let ParamUi::Popup { options } = ui_opts.as_ref().unwrap_or(&prop.ui) {
+        let r = Rect::from_min_size(pos2(x, at.y - 9.0), vec2(150.0, 18.0));
+        let label = options.get(*i as usize).cloned().unwrap_or_default();
+        let pop = egui::Id::unique(("epop", uid));
+        if widgets::dropdown(ui, r, &label, &t, egui::Id::unique(("en", uid))).clicked() {
+            widgets::open_popup(ui, pop);
+        }
+        app.auto.add(&format!("timeline.prop.{uid}.value"), r, &prop.name);
+        if let Some(ni) = widgets::popup_menu(ui, pop, r.left_bottom(), options, Some(*i as usize)) {
+            ve.set(actions, json!(ni));
+        }
+    } else {
+        p.text(pos2(x, at.y), Align2::LEFT_CENTER, fmt_num(*i as f64, 0), Tokens::ui(12.0), t.hot_text);
+    }
+}
+
+/// A layer reference picker.
+fn layer_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, ve: &ValueEdit<'_>, actions: &mut Vec<(String, serde_json::Value)>, value: &Value) {
+    let Value::Layer(l) = value else { return };
+    let (prop, at, uid) = (ve.prop, ve.at, ve.uid);
+    let t = app.tokens;
+    let x = at.x;
+    // Layer references (a light's Source, a Displacement Map's layer…) pick from the
+    // comp's layers, as in Effect Controls; the choice is written through the same
+    // undoable prop.set as the other value arms.
+    let r = Rect::from_min_size(pos2(x, at.y - 9.0), vec2(150.0, 18.0));
+    if let Some(comp) = app.session.active_comp_arc()
+        && let Some(chosen) = widgets::layer_picker(ui, r, &comp.layers, *l, &t, egui::Id::unique(("tl-lay", uid)))
+    {
+        ve.set(actions, chosen.map(|id| json!(id)).unwrap_or(serde_json::Value::Null));
+    }
+    app.auto.add(&format!("timeline.prop.{uid}.value"), r, &prop.name);
+}
+
+/// Opens the Gradient Editor at the current time (#612).
+fn gradient_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, ve: &ValueEdit<'_>, actions: &mut Vec<(String, serde_json::Value)>) {
+    let (p, layer, prop, at, uid) = (ve.p, ve.layer, ve.prop, ve.at, ve.uid);
+    let t = app.tokens;
+    let x = at.x;
+    // Opens the Gradient Editor at the current time (#612).
+    let r = p.text(pos2(x, at.y), Align2::LEFT_CENTER, crate::i18n::tr("Edit Gradient…"), Tokens::ui(12.0), t.hot_text);
+    let resp = ui.interact(r.expand(2.0), egui::Id::unique(("gradient-edit", uid)), Sense::click());
+    if resp.hovered() {
+        ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
+    }
+    app.auto.add(&format!("timeline.prop.{uid}.value"), r, &prop.name);
+    if resp.clicked() {
+        actions.push(("prop.editGradient".into(), json!({"layer": layer.id.0, "prop": uid})));
+    }
+}
+
 #[cfg(test)]
 mod tests;
diff --git a/crates/ui-egui/src/panels/tracker.rs b/crates/ui-egui/src/panels/tracker.rs
index f1c5b8b25..3edde09df 100644
--- a/crates/ui-egui/src/panels/tracker.rs
+++ b/crates/ui-egui/src/panels/tracker.rs
@@ -115,6 +115,32 @@ fn analyze_glyph(p: &egui::Painter, r: Rect, forward: bool, frame: bool, stop: b
     }
 }
 
+/// The two button rows at `y`: Track Camera / Warp Stabilizer, then Track Motion / Stabilize Motion.
+fn top_buttons(app: &mut EffectcraftApp, ui: &mut egui::Ui, ctx: &egui::Context, x0: f32, y: f32, half: f32, source: Option) {
+    let can_track = app.session.active_comp().is_some() && source.is_some();
+    // 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!({}));
+    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!({}));
+    }
+    let y = y + 28.0;
+    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));
+    if button(app, ui, r1, "Track Motion", can_track, "tracker.trackMotion") {
+        run(app, ctx, "track.motion", src_p.clone());
+    }
+    if button(app, ui, r2, "Stabilize Motion", can_track, "tracker.stabilizeMotion") {
+        run(app, ctx, "track.stabilize", src_p.clone());
+    }
+}
+
 /// The Tracker panel: the camera/warp and motion/stabilize buttons, Motion Source and Current
 /// Track pickers, the track type and its channels, Motion Target with Edit Target and Options, the
 /// four Analyze buttons (the one whose direction is running offers Stop), Reset and Apply, and the
@@ -132,33 +158,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
     let w = (rect.width() - 20.0).max(120.0);
     let half = (w - 6.0) / 2.0;
     let mut y = rect.min.y + 10.0;
-    let has_comp = app.session.active_comp().is_some();
     let source = motion_source(app);
     let cur = current(app);
-    let can_track = has_comp && source.is_some();
 
-    // 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!({}));
-    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!({}));
-    }
-    y += 28.0;
-    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));
-    if button(app, ui, r1, "Track Motion", can_track, "tracker.trackMotion") {
-        run(app, &ctx, "track.motion", src_p.clone());
-    }
-    if button(app, ui, r2, "Stabilize Motion", can_track, "tracker.stabilizeMotion") {
-        run(app, &ctx, "track.stabilize", src_p.clone());
-    }
-    y += 34.0;
+    top_buttons(app, ui, &ctx, x0, y, half, source);
+    y += 62.0;
     let Some(comp) = app.session.active_comp_arc() else {
         p.text(pos2(x0, y + 8.0), Align2::LEFT_CENTER, crate::i18n::tr("Open a composition to track motion."), Tokens::ui(12.0), t.text_faint);
         return;
diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs
index a9770d9ed..bcae99bc5 100644
--- a/crates/ui-egui/src/panels/viewer.rs
+++ b/crates/ui-egui/src/panels/viewer.rs
@@ -72,6 +72,52 @@ thread_local! {
 /// (move/scale/rotate/anchor/camera/shape/mask/guide edits, picks, marquees); failures worth showing go to
 /// `app.ui.status`, while per-frame drag updates ignore errors so a gesture is not aborted mid-drag. With no active
 /// composition it draws `empty_state` and returns.
+/// What the passes of one viewer frame share (see [`show`]).
+struct Vw<'a> {
+    t: Tokens,
+    cid: effectcraft_engine::project::ItemId,
+    nav: Rect,
+    bar: Rect,
+    err_bar: Rect,
+    err_h: f32,
+    pasteboard: Color32,
+    area: Rect,
+    outer: Rect,
+    comp_rect: Rect,
+    zoom: f32,
+    ppp: f32,
+    cw: f32,
+    ch: f32,
+    map: ViewerMap,
+    time: Tick,
+    overlays: bool,
+    mods: egui::Modifiers,
+    space_pan: bool,
+    text_owns: bool,
+    gid: egui::Id,
+    track_cur: Option<(LayerId, effectcraft_engine::project::Uid)>,
+    has_3d: bool,
+    ctx: egui::Context,
+    comp_name: String,
+    p: egui::Painter,
+    painter: egui::Painter,
+    selected: Vec,
+    sel_vertices: Vec,
+    ft: Option,
+    comp: &'a Comp,
+    gesture: Option,
+    handle_hits: Vec<(LayerId, usize, Pos2, egui::CursorIcon)>,
+    axis_hits: Vec<(LayerId, [Pos2; 2], [f64; 3], [f64; 3])>,
+    vertex_hits: Vec,
+    vertex_marks: Vec,
+    key_hits: Vec,
+    motion_frames: Vec,
+    paths: Vec,
+    track_hits: Vec,
+    pin_hits: Vec,
+    puppet_layers: Vec<(&'a Layer, super::puppet_tool::Overlay)>,
+}
+
 pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
     let t = app.tokens;
     let ctx = ui.ctx().clone();
@@ -83,7 +129,75 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
     let comp_name = app.session.project.item(cid).map(|i| i.name.clone()).unwrap_or_default();
     let p = ui.painter().clone();
     VIEW_CAM.with(|c| c.set(app.session.view_camera(cid)));
+    let mut v = Vw {
+        t,
+        cid,
+        nav: Rect::NOTHING,
+        bar: Rect::NOTHING,
+        err_bar: Rect::NOTHING,
+        err_h: 0.0,
+        pasteboard: Color32::BLACK,
+        area: Rect::NOTHING,
+        outer: Rect::NOTHING,
+        comp_rect: Rect::NOTHING,
+        zoom: 1.0,
+        ppp: 1.0,
+        cw: 0.0,
+        ch: 0.0,
+        map: ViewerMap { origin: Pos2::ZERO, zoom: 1.0, comp: [0.0; 2], area: Rect::NOTHING },
+        time: Tick::ZERO,
+        overlays: false,
+        mods: egui::Modifiers::NONE,
+        space_pan: false,
+        text_owns: false,
+        gid: egui::Id::unique("viewer-gesture"),
+        track_cur: None,
+        has_3d: false,
+        ctx: ctx.clone(),
+        comp_name,
+        painter: p.clone(),
+        p,
+        selected: vec![],
+        sel_vertices: vec![],
+        ft: None,
+        comp: &comp,
+        gesture: None,
+        handle_hits: vec![],
+        axis_hits: vec![],
+        vertex_hits: vec![],
+        vertex_marks: vec![],
+        key_hits: vec![],
+        motion_frames: vec![],
+        paths: vec![],
+        track_hits: vec![],
+        pin_hits: vec![],
+        puppet_layers: vec![],
+    };
+    phase_nav(app, ui, rect, &mut v);
+    phase_layout(app, ui, rect, &mut v);
+    phase_frame(app, &mut v);
+    let snap_project = app.session.project.clone();
+    let snap_expr = app.session.expr.clone();
+    let ectx = EvalCtx { project: &snap_project, comp_id: cid, comp: &comp, time: v.time, expr: snap_expr.as_deref(), footage: None };
+    phase_guides(app, &ectx, &mut v);
+    phase_layers(app, ui, &ectx, &mut v);
+    phase_scene(app, &ectx, &mut v);
+    phase_rigs(app, ui, &ectx, &mut v);
+    let resp = phase_menu(app, ui, &ectx, &mut v);
+    phase_hover(app, ui, &ectx, &resp, &mut v);
+    phase_press(app, ui, &ectx, &resp, &mut v);
+    phase_dragstart(app, ui, &ectx, &resp, &mut v);
+    phase_drag(app, ui, &ectx, &resp, &mut v);
+    phase_stopped(app, ui, &ectx, &resp, &mut v);
+    phase_clicked(app, &ectx, &resp, &mut v);
+    phase_dblclick(app, &ectx, &resp, &mut v);
+    phase_tail(app, ui, &ectx, &mut v);
+}
 
+fn phase_nav(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect, v: &mut Vw<'_>) {
+    let t = v.t;
+    let cid = v.cid;
+    let p = v.p.clone();
     // Navigator bar (open comps as breadcrumbs). After Effects shows a single breadcrumb row:
     // when the Composition Navigator (nested comp flow) is above the viewer, this row is left
     // out.
@@ -116,7 +230,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         }
         x = r.max.x + 4.0;
     }
+    v.nav = nav;
+}
 
+fn phase_layout(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect, v: &mut Vw<'_>) {
+    let t = v.t;
+    let cid = v.cid;
+    let nav = v.nav;
+    let ctx = v.ctx.clone();
+    let p = v.p.clone();
+    let comp = v.comp;
     // Bottom control bar.
     let bar_h = 36.0;
     let bar = Rect::from_min_max(pos2(rect.min.x, rect.max.y - bar_h), rect.max);
@@ -128,7 +251,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
     // View ▸ Switch View Layout: extra views (Top / Front / Right) beside the main view, which
     // keeps the overlays and the interaction.
     let full = locked_pane(app, ui, full, pasteboard);
-    let area = aux_views(app, ui, &comp, cid, full, pasteboard);
+    let area = aux_views(app, ui, comp, cid, full, pasteboard);
     p.rect_filled(area, 0.0, pasteboard);
     // View ▸ Show Rulers takes a strip on the top and left.
     let outer = area;
@@ -151,8 +274,30 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
     let painter = p.with_clip_rect(area);
     // Settings ▸ 3D ▸ Extended Viewer: custom 3D views (and Draft 3D) render the visible
     // pasteboard too, so 3D layers reaching past the comp frame stay visible there.
-    app.ui.viewer.extended = extended_region(app, &comp, cid, map);
+    app.ui.viewer.extended = extended_region(app, comp, cid, map);
+    v.bar = bar;
+    v.err_bar = err_bar;
+    v.err_h = err_h;
+    v.pasteboard = pasteboard;
+    v.area = area;
+    v.outer = outer;
+    v.comp_rect = comp_rect;
+    v.zoom = zoom;
+    v.ppp = ppp;
+    v.cw = cw;
+    v.ch = ch;
+    v.map = map;
+    v.painter = painter;
+}
 
+fn phase_frame(app: &mut EffectcraftApp, v: &mut Vw<'_>) {
+    let cid = v.cid;
+    let comp_rect = v.comp_rect;
+    let zoom = v.zoom;
+    let ppp = v.ppp;
+    let ctx = v.ctx.clone();
+    let painter = v.painter.clone();
+    let comp = v.comp;
     // Frame.
     let scale = app.viewer_scale(zoom, ppp);
     if !app.playback.playing {
@@ -185,11 +330,21 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         let bg = comp.background;
         painter.rect_filled(comp_rect, 0.0, Color32::from_rgb((bg[0] * 255.0) as u8, (bg[1] * 255.0) as u8, (bg[2] * 255.0) as u8));
     }
-    let snap_project = app.session.project.clone();
-    let snap_expr = app.session.expr.clone();
-    let ectx = EvalCtx { project: &snap_project, comp_id: cid, comp: &comp, time, expr: snap_expr.as_deref(), footage: None };
+    v.time = time;
+}
+
+fn phase_guides(app: &mut EffectcraftApp, ectx: &EvalCtx, v: &mut Vw<'_>) {
+    let cid = v.cid;
+    let area = v.area;
+    let comp_rect = v.comp_rect;
+    let zoom = v.zoom;
+    let map = v.map;
+    let ctx = v.ctx.clone();
+    let comp_name = v.comp_name.clone();
+    let painter = v.painter.clone();
+    let comp = v.comp;
     // The frame (or snapshot) through Show Channel and exposure; ROI frames cover the region.
-    vt::draw_frame(app, &ctx, &painter, comp_rect, cid, &ectx);
+    vt::draw_frame(app, &ctx, &painter, comp_rect, cid, ectx);
     if app.ui.viewer.extended.is_some() {
         // The comp frame outlined over the extended render.
         painter.rect_stroke(comp_rect, 0.0, Stroke::new(1.0, Color32::from_gray(150)), StrokeKind::Outside);
@@ -275,17 +430,30 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         }
         app.auto.add("viewer.regionOfInterest", r, "Region of interest");
     }
+    v.overlays = overlays;
+}
+
+fn phase_layers(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, v: &mut Vw<'_>) {
+    let t = v.t;
+    let zoom = v.zoom;
+    let cw = v.cw;
+    let ch = v.ch;
+    let map = v.map;
+    let time = v.time;
+    let ctx = v.ctx.clone();
+    let painter = v.painter.clone();
+    let mut handle_hits = std::mem::take(&mut v.handle_hits);
+    let mut axis_hits = std::mem::take(&mut v.axis_hits);
+    let mut vertex_hits = std::mem::take(&mut v.vertex_hits);
+    let mut vertex_marks = std::mem::take(&mut v.vertex_marks);
+    let mut key_hits = std::mem::take(&mut v.key_hits);
+    let mut motion_frames = std::mem::take(&mut v.motion_frames);
+    let mut paths = std::mem::take(&mut v.paths);
+    let comp = v.comp;
     // Overlays + interaction.
     let selected = app.session.state.selected_layers.clone();
     // Bounding-box handles: (layer, handle, screen point, its resize cursor).
-    let mut handle_hits: Vec<(LayerId, usize, Pos2, egui::CursorIcon)> = Vec::new();
     // 3D gizmo arrows: (layer, grabbable screen segment, position change per comp px in x and y).
-    let mut axis_hits: Vec<(LayerId, [Pos2; 2], [f64; 3], [f64; 3])> = Vec::new();
-    let mut vertex_hits: Vec = Vec::new();
-    let mut vertex_marks: Vec = Vec::new();
-    let mut key_hits: Vec = Vec::new();
-    let mut motion_frames: Vec = Vec::new();
-    let mut paths: Vec = Vec::new();
     let sel_vertices = app.session.state.selected_vertices.clone();
     // Pen path in progress ends when the tool changes or its mask is gone; Esc / Enter also end
     // free transform.
@@ -308,7 +476,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         for l in comp.layers.iter().filter(|l| {
             matches!(l.source, LayerSource::Null) && !selected.contains(&l.id) && l.is_active_at(time) && l.switches.video && (!any_solo || l.switches.solo)
         }) {
-            let outline = layer_outline(&ectx, l);
+            let outline = layer_outline(ectx, l);
             if outline.len() > 2 {
                 let col = if label_handles { t.label(l.label) } else { t.accent };
                 painter.add(egui::Shape::closed_line(outline.into_iter().map(|p| map.to_screen(p)).collect(), Stroke::new(1.0, col)));
@@ -322,12 +490,12 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         for l in comp.layers.iter().filter(|l| selected.contains(&l.id) && l.is_active_at(time) && (l.switches.video || l.source.is_av())) {
             let col = if label_handles { t.label(l.label) } else { t.accent };
             // Motion path of animated position (keyframes and spatial tangents are draggable).
-            ov::motion_path(app, &painter, &map, &ectx, l, col, &mut key_hits, &mut motion_frames);
+            ov::motion_path(app, &painter, &map, ectx, l, col, &mut key_hits, &mut motion_frames);
             // Masks and shape paths.
             if app.ui.viewer.show_masks {
                 let pen = ui.data(|d| d.get_temp::<(LayerId, u64)>(pen_id()));
                 let hover = ui.input(|i| i.pointer.hover_pos());
-                ov::draw_paths(app, &painter, &map, &ectx, l, col, &sel_vertices, pen, hover, &mut vertex_hits, &mut vertex_marks, &mut paths);
+                ov::draw_paths(app, &painter, &map, ectx, l, col, &sel_vertices, pen, hover, &mut vertex_hits, &mut vertex_marks, &mut paths);
             }
             // Settings ▸ Previews ▸ Show Internal Wireframes: outlines of the layers inside a
             // selected collapsed precomp.
@@ -336,7 +504,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
                 && let effectcraft_engine::project::LayerSource::Comp { item } = &l.source
                 && let Some(nc) = app.session.project.comp(*item)
             {
-                let (outer, _) = l2c(&ectx, l);
+                let (outer, _) = l2c(ectx, l);
                 let nctx = EvalCtx::new(&app.session.project, *item, nc, ectx.nested_time(l));
                 for nl in nc.layers.iter().filter(|nl| nl.is_active_at(nctx.time) && !nl.is_3d()) {
                     if let Some((_, q, _)) = layer_quad(&nctx, nl) {
@@ -359,9 +527,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
             if editing_path {
                 continue;
             }
-            let Some((m, q, _)) = layer_quad(&ectx, l) else {
+            let Some((m, q, _)) = layer_quad(ectx, l) else {
                 // A 3D layer reaching past the camera: the part of its box in front of it.
-                let outline = layer_outline(&ectx, l);
+                let outline = layer_outline(ectx, l);
                 if outline.len() > 2 {
                     painter.add(egui::Shape::closed_line(outline.iter().map(|p| map.to_screen(*p)).collect(), Stroke::new(1.0, col)));
                     continue;
@@ -396,10 +564,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
                 // 3D axis gizmo.
                 if l.is_3d() {
                     let w = ectx.world_matrix(l);
-                    let pc = cam_state(&ectx).projection(cw as f64, ch as f64) * w;
+                    let pc = cam_state(ectx).projection(cw as f64, ch as f64) * w;
                     let a3 = effectcraft_engine::geom::vec3(a[0], a[1], a[2]);
                     let len = 60.0 / zoom as f64;
-                    let pinv = parent_world_inverse(&ectx, l);
+                    let pinv = parent_world_inverse(ectx, l);
                     for (axis, col, name) in [
                         (effectcraft_engine::geom::vec3(len, 0.0, 0.0), Color32::from_rgb(0xe0, 0x50, 0x50), "x"),
                         (effectcraft_engine::geom::vec3(0.0, len, 0.0), Color32::from_rgb(0x60, 0xd0, 0x60), "y"),
@@ -428,7 +596,31 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
             }
         }
     }
+    v.handle_hits = handle_hits;
+    v.axis_hits = axis_hits;
+    v.vertex_hits = vertex_hits;
+    v.vertex_marks = vertex_marks;
+    v.key_hits = key_hits;
+    v.motion_frames = motion_frames;
+    v.paths = paths;
+    v.selected = selected;
+    v.sel_vertices = sel_vertices;
+}
 
+fn phase_scene(app: &mut EffectcraftApp, ectx: &EvalCtx, v: &mut Vw<'_>) {
+    let t = v.t;
+    let cid = v.cid;
+    let area = v.area;
+    let comp_rect = v.comp_rect;
+    let cw = v.cw;
+    let ch = v.ch;
+    let map = v.map;
+    let time = v.time;
+    let painter = v.painter.clone();
+    let selected = v.selected.clone();
+    let vertex_marks = std::mem::take(&mut v.vertex_marks);
+    let mut track_hits = std::mem::take(&mut v.track_hits);
+    let comp = v.comp;
     // Path points last, over the boxes' handles (#1042).
     ov::draw_vertex_marks(&painter, &vertex_marks, app.session.prefs.general.path_point_size as f32 + 1.0);
 
@@ -437,7 +629,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
     let has_3d = comp.layers.iter().any(|l| l.is_3d() && l.is_active_at(time));
     if has_3d && app.session.prefs.three_d.extended_viewer {
         for l in comp.layers.iter().filter(|l| l.is_3d() && l.is_active_at(time) && l.has_video()) {
-            let q = layer_outline(&ectx, l);
+            let q = layer_outline(ectx, l);
             if q.len() > 2 {
                 let sq: Vec = q.iter().map(|p| map.to_screen(*p)).collect();
                 let outside = sq.iter().any(|p| !comp_rect.contains(*p));
@@ -448,7 +640,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         }
     }
     if has_3d && app.session.prefs.three_d.show_reference_axes {
-        let pc = cam_state(&ectx).projection(cw as f64, ch as f64);
+        let pc = cam_state(ectx).projection(cw as f64, ch as f64);
         let o3 = effectcraft_engine::geom::vec3(cw as f64 / 2.0, ch as f64 / 2.0, 0.0);
         let o = pc.apply(o3);
         let corner = pos2(area.min.x + 34.0, area.max.y - 34.0);
@@ -467,7 +659,6 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
     }
 
     // Track points of the current track (Tracker panel) on its layer, while that layer is selected.
-    let mut track_hits = vec![];
     let track_cur = super::tracker::viewer_track(app);
     if app.layer_controls_visible()
         && let Some((tl, tu)) = track_cur
@@ -475,7 +666,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         && let Some(layer) = comp.layer(tl)
         && layer.is_active_at(time)
     {
-        let (m, _) = l2c(&ectx, layer);
+        let (m, _) = l2c(ectx, layer);
         track_hits = super::tracker::draw_overlay(app, &painter, &map, &m, layer, tu, time);
     }
 
@@ -487,7 +678,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         && let Some(layer) = comp.layer(wl)
         && layer.is_active_at(time)
     {
-        let (m, _) = l2c(&ectx, layer);
+        let (m, _) = l2c(ectx, layer);
         let (w, h) = effectcraft_engine::render::source_size(&app.session.project, layer);
         let c = m.apply(effectcraft_engine::geom::vec2(w as f64 / 2.0, h as f64 / 2.0));
         let at = map.to_screen([c.x, c.y]);
@@ -509,7 +700,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         if notices >= 3 {
             break;
         }
-        let (m, _) = l2c(&ectx, layer);
+        let (m, _) = l2c(ectx, layer);
         let (w, h) = effectcraft_engine::render::source_size(&app.session.project, layer);
         let c = m.apply(effectcraft_engine::geom::vec2(w as f64 / 2.0, h as f64 / 2.0));
         let at = map.to_screen([c.x, c.y]);
@@ -522,14 +713,32 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         app.auto.add(&format!("viewer.footageProblem.{}", layer.id.0), r, &text);
         notices += 1;
     }
+    v.vertex_marks = vertex_marks;
+    v.track_hits = track_hits;
+    v.track_cur = track_cur;
+    v.has_3d = has_3d;
+}
 
+fn phase_rigs(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, v: &mut Vw<'_>) {
+    let t = v.t;
+    let cid = v.cid;
+    let area = v.area;
+    let map = v.map;
+    let time = v.time;
+    let ctx = v.ctx.clone();
+    let painter = v.painter.clone();
+    let selected = v.selected.clone();
+    let paths = std::mem::take(&mut v.paths);
+    let mut pin_hits = std::mem::take(&mut v.pin_hits);
+    let mut puppet_layers = std::mem::take(&mut v.puppet_layers);
+    let comp = v.comp;
     // Free transform box of a path (double-click a path with the Selection tool).
     let ft = ov::draw_free_transform(app, &ctx, &painter, &map, &paths);
 
     // Cameras and lights (wireframes) and the 3D view name.
     if comp.has_3d() {
         let through_active = VIEW_CAM.with(|c| c.get()).is_none();
-        draw_rigs(app, &painter, &map, &ectx, &selected, cam_state(&ectx), through_active, None);
+        draw_rigs(app, &painter, &map, ectx, &selected, cam_state(ectx), through_active, None);
         let view = app.session.state.views3d.get(&cid).map(|v| v.current).unwrap_or_default();
         let label = match (view, comp.active_camera(time)) {
             (View3D::ActiveCamera, Some(c)) => format!("Active Camera ({})", c.name),
@@ -539,10 +748,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
     }
 
     // Puppet pins and meshes of the selected layers (Puppet tools).
-    let mut pin_hits: Vec = vec![];
     // The selected layers with a Puppet effect, top of the stack first (the layers the Puppet
     // tools work on).
-    let mut puppet_layers: Vec<(&Layer, super::puppet_tool::Overlay)> = vec![];
     if app.ui.tool.puppet_kind().is_some() {
         let sel_props: Vec = app.session.state.selected_props.iter().map(|(_, u)| *u).collect();
         for lid in &selected {
@@ -550,7 +757,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
                 && let Some(ov) = super::puppet_tool::overlay(app, &ctx, cid, l, time)
             {
                 let hover = ui.input(|i| i.pointer.hover_pos());
-                pin_hits.extend(super::puppet_tool::draw(&painter, &map, &ectx, l, &ov, app.session.state.puppet.show_mesh, &sel_props, hover));
+                pin_hits.extend(super::puppet_tool::draw(&painter, &map, ectx, l, &ov, app.session.state.puppet.show_mesh, &sel_props, hover));
                 puppet_layers.push((l, ov));
             }
         }
@@ -564,17 +771,21 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
             }
         }
     }
-    // The layer a Puppet tool press works on: while selected layers are rigged, only those
-    // ([`super::puppet_tool::target`]); otherwise the layer under the pointer, selected.
-    let puppet_layer = |app: &mut EffectcraftApp, cpt: [f64; 2]| -> Option {
-        if puppet_layers.is_empty() {
-            return pick(app, &ectx, cpt, false);
-        }
-        super::puppet_tool::target(&ectx, &puppet_layers, cpt, |l| layers_at(&ectx, cpt).any(|h| h.id == l.id))
-    };
+    v.paths = paths;
+    v.pin_hits = pin_hits;
+    v.puppet_layers = puppet_layers;
+    v.ft = ft;
+}
 
+fn phase_menu(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, v: &mut Vw<'_>) -> egui::Response {
+    let cid = v.cid;
+    let area = v.area;
+    let map = v.map;
+    let ctx = v.ctx.clone();
+    let painter = v.painter.clone();
+    let comp = v.comp;
     // Project items, files and effects dropped on the viewer.
-    super::viewer_drop::show(app, ui, &painter, &map, &ectx);
+    super::viewer_drop::show(app, ui, &painter, &map, ectx);
 
     // Interaction.
     let resp = ui.interact(area, egui::Id::unique("viewer-interact"), Sense::click_and_drag());
@@ -584,9 +795,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
     {
         // Picking an already selected layer preserves the group; an unselected hit
         // becomes the target, using the same visible/unlocked hit test as left-click.
-        pick(app, &ectx, map.to_comp(pos), false);
+        pick(app, ectx, map.to_comp(pos), false);
         let point = map.to_comp(pos);
-        let hits: Vec<(u64, String)> = layers_at(&ectx, point).map(|l| (l.id.0, l.name.clone())).collect();
+        let hits: Vec<(u64, String)> = layers_at(ectx, point).map(|l| (l.id.0, l.name.clone())).collect();
         ui.data_mut(|d| d.insert_temp(menu_hits_id, hits));
     }
     let context_layers: Vec =
@@ -641,6 +852,26 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
             app.ui.status = e;
         }
     }
+    resp
+}
+
+fn phase_hover(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, resp: &egui::Response, v: &mut Vw<'_>) {
+    let area = v.area;
+    let zoom = v.zoom;
+    let cw = v.cw;
+    let ch = v.ch;
+    let map = v.map;
+    let ctx = v.ctx.clone();
+    let painter = v.painter.clone();
+    let handle_hits = std::mem::take(&mut v.handle_hits);
+    let axis_hits = std::mem::take(&mut v.axis_hits);
+    let vertex_hits = std::mem::take(&mut v.vertex_hits);
+    let key_hits = std::mem::take(&mut v.key_hits);
+    let motion_frames = std::mem::take(&mut v.motion_frames);
+    let paths = std::mem::take(&mut v.paths);
+    let track_hits = std::mem::take(&mut v.track_hits);
+    let pin_hits = std::mem::take(&mut v.pin_hits);
+    let comp = v.comp;
     // Path vertices and tangent handles take the press before the bounding-box handles (#1042).
     let vertex_at = |pos: Pos2, tangents: bool| -> Option {
         let reach = vertex_grab_radius(pos, handle_hits.iter().map(|(_, _, h, _)| *h));
@@ -675,7 +906,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
                 // The tool draws its own crosshair (and the layer it would matte) while no box
                 // is being dragged.
                 if ui.data(|d| d.get_temp::(egui::Id::unique("viewer-gesture"))).is_none() {
-                    super::object_matte_tool::hover(app, &painter, &map, &ectx, hp);
+                    super::object_matte_tool::hover(app, &painter, &map, ectx, hp);
                 }
                 egui::CursorIcon::None
             }
@@ -689,7 +920,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
             _ => {
                 if super::tracker::hit_at(&track_hits, hp).is_some() || key_hits.iter().any(|h| h.pos.distance(hp) < 6.0) {
                     egui::CursorIcon::Move
-                } else if let Some((_, vertical)) = vt::guide_at(app, &comp, &map, hp) {
+                } else if let Some((_, vertical)) = vt::guide_at(app, comp, &map, hp) {
                     if vertical { egui::CursorIcon::ResizeHorizontal } else { egui::CursorIcon::ResizeVertical }
                 } else if vertex_at(hp, true).or_else(|| vertex_at(hp, false)).is_some() {
                     // A path point under a box handle is grabbed first: no resize cursor.
@@ -719,6 +950,24 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
     } else {
         app.pointer_comp = None;
     }
+    v.handle_hits = handle_hits;
+    v.axis_hits = axis_hits;
+    v.vertex_hits = vertex_hits;
+    v.key_hits = key_hits;
+    v.motion_frames = motion_frames;
+    v.paths = paths;
+    v.track_hits = track_hits;
+    v.pin_hits = pin_hits;
+    v.gid = gid;
+}
+
+fn phase_press(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, resp: &egui::Response, v: &mut Vw<'_>) {
+    let map = v.map;
+    let ctx = v.ctx.clone();
+    let painter = v.painter.clone();
+    let key_hits = std::mem::take(&mut v.key_hits);
+    let motion_frames = std::mem::take(&mut v.motion_frames);
+    let paths = std::mem::take(&mut v.paths);
     app.ui.viewer.interacting = resp.dragged();
     let mods = ui.input(|i| i.modifiers);
     let space_pan = super::space_hand(&ctx);
@@ -730,7 +979,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         && ui.input(|i| i.pointer.primary_pressed())
         && let Some(pos) = ui.input(|i| i.pointer.press_origin())
     {
-        pen_press(app, ui, &ectx, &map, &paths, ov::motion_frame_at(&motion_frames, &key_hits, pos), pos, mods);
+        pen_press(app, ui, ectx, &map, &paths, ov::motion_frame_at(&motion_frames, &key_hits, pos), pos, mods);
     }
     // A pen click (press + release without a drag) ends its tangent gesture on release.
     if ui.input(|i| i.pointer.primary_released())
@@ -740,7 +989,45 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         ui.data_mut(|d| d.remove::(egui::Id::unique("viewer-gesture")));
     }
     // On-canvas text editing (Type tool, double-click a text layer) owns its clicks and drags.
-    let text_owns = super::viewer_text::hook(app, ui, &painter, &map, &ectx, &resp, space_pan);
+    let text_owns = super::viewer_text::hook(app, ui, &painter, &map, ectx, resp, space_pan);
+    v.key_hits = key_hits;
+    v.motion_frames = motion_frames;
+    v.paths = paths;
+    v.mods = mods;
+    v.space_pan = space_pan;
+    v.text_owns = text_owns;
+}
+
+fn phase_dragstart(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, resp: &egui::Response, v: &mut Vw<'_>) {
+    let comp_rect = v.comp_rect;
+    let zoom = v.zoom;
+    let map = v.map;
+    let time = v.time;
+    let mods = v.mods;
+    let space_pan = v.space_pan;
+    let text_owns = v.text_owns;
+    let gid = v.gid;
+    let track_cur = v.track_cur;
+    let ft = std::mem::take(&mut v.ft);
+    let handle_hits = std::mem::take(&mut v.handle_hits);
+    let axis_hits = std::mem::take(&mut v.axis_hits);
+    let vertex_hits = std::mem::take(&mut v.vertex_hits);
+    let key_hits = std::mem::take(&mut v.key_hits);
+    let paths = std::mem::take(&mut v.paths);
+    let track_hits = std::mem::take(&mut v.track_hits);
+    let pin_hits = std::mem::take(&mut v.pin_hits);
+    let puppet_layers = std::mem::take(&mut v.puppet_layers);
+    let comp = v.comp;
+    let vertex_at = |pos: Pos2, tangents: bool| -> Option {
+        let reach = vertex_grab_radius(pos, handle_hits.iter().map(|(_, _, h, _)| *h));
+        vertex_hits.iter().rev().filter(|h| h.tangent.is_some() == tangents).find(|h| h.pos.distance(pos) < reach).copied()
+    };
+    let puppet_layer = |app: &mut EffectcraftApp, cpt: [f64; 2]| -> Option {
+        if puppet_layers.is_empty() {
+            return pick(app, ectx, cpt, false);
+        }
+        super::puppet_tool::target(ectx, &puppet_layers, cpt, |l| layers_at(ectx, cpt).any(|h| h.id == l.id))
+    };
     if resp.drag_started()
         && !text_owns
         && let Some(pos) = resp.interact_pointer_pos()
@@ -760,8 +1047,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
             Tool::Selection if key_hits.iter().any(|h| h.pos.distance(press) < 6.0) => {
                 ov::begin_key_drag(app, &key_hits, press, map.to_comp(press), mods.shift).map(Gesture::Overlay)
             }
-            Tool::Selection if vt::guide_at(app, &comp, &map, press).is_some() => {
-                vt::guide_at(app, &comp, &map, press).map(|(index, vertical)| Gesture::Overlay(ov::Drag::Guide { index, vertical }))
+            Tool::Selection if vt::guide_at(app, comp, &map, press).is_some() => {
+                vt::guide_at(app, comp, &map, press).map(|(index, vertical)| Gesture::Overlay(ov::Drag::Guide { index, vertical }))
             }
             // Alt-drag a vertex with the Selection tool (or drag one with Convert Vertex): pull
             // new tangents out of it.
@@ -771,9 +1058,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
                     Gesture::Pen { layer: h.layer, mask: h.mask, index: h.index, vertex: h.vertex, inv, press }
                 })
             }
-            Tool::MaskFeather => ov::begin_feather(app, &ectx, &paths, &map, press).map(Gesture::Overlay),
-            Tool::Rotate => pick(app, &ectx, cpt, mods.shift).map(|l| {
-                let others = selected_others(app, &comp, l).into_iter().filter_map(|o| Some((o.id, ectx.f(o, o.transform()?, "rotation", 0.0)))).collect();
+            Tool::MaskFeather => ov::begin_feather(app, ectx, &paths, &map, press).map(Gesture::Overlay),
+            Tool::Rotate => pick(app, ectx, cpt, mods.shift).map(|l| {
+                let others = selected_others(app, comp, l).into_iter().filter_map(|o| Some((o.id, ectx.f(o, o.transform()?, "rotation", 0.0)))).collect();
                 let layer = comp.layer(l).cloned();
                 let center = layer
                     .as_ref()
@@ -790,10 +1077,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
                     others,
                 }
             }),
-            Tool::PanBehind => pick(app, &ectx, cpt, false).and_then(|l| {
+            Tool::PanBehind => pick(app, ectx, cpt, false).and_then(|l| {
                 let layer = comp.layer(l)?;
                 let tr = layer.transform()?;
-                let (l2c, _) = l2c(&ectx, layer);
+                let (l2c, _) = l2c(ectx, layer);
                 let l2p = ectx.local_matrix(layer);
                 let l2p = Mat3([[l2p.0[0][0], l2p.0[0][1], 0.0], [l2p.0[1][0], l2p.0[1][1], 0.0], [0.0, 0.0, 1.0]]);
                 Some(Gesture::Anchor {
@@ -804,7 +1091,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
                     start: map.to_comp(press),
                     inv: l2c.inverse().unwrap_or(Mat3::IDENTITY),
                     l2p,
-                    own: effectcraft_engine::viewer::layer_targets(&ectx, layer, false, app.session.state.snap_features),
+                    own: effectcraft_engine::viewer::layer_targets(ectx, layer, false, app.session.state.snap_features),
                     anchor_only: mods.alt,
                 })
             }),
@@ -830,7 +1117,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
                     PinPart::Center => {}
                 }
                 let l = comp.layer(h.layer)?;
-                let inv = fx2c(&ectx, l).inverse()?;
+                let inv = fx2c(ectx, l).inverse()?;
                 if mods.command {
                     let lp = inv.apply(gv2(cpt[0], cpt[1]));
                     let began = ui.input(|i| i.time);
@@ -850,7 +1137,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
                 let hit = super::tracker::hit_at(&track_hits, press);
                 track_cur.zip(hit).and_then(|((tl, tu), hit)| {
                     let layer = comp.layer(tl)?;
-                    let (m, _) = l2c(&ectx, layer);
+                    let (m, _) = l2c(ectx, layer);
                     super::tracker::begin_drag(layer, tu, time, &m, hit, map.to_comp(press)).map(|drag| Gesture::Track { layer: tl, tracker: tu, drag })
                 })
             }
@@ -890,11 +1177,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
                     let layer = comp.layer(lid).cloned();
                     layer.and_then(|layer| {
                         let tr = layer.transform()?;
-                        let (l2c, _) = l2c(&ectx, &layer);
+                        let (l2c, _) = l2c(ectx, &layer);
                         let inv = l2c.inverse()?;
                         let a = ectx.v3(&layer, tr, "anchor", [0.0; 3]);
                         let lp = inv.apply(gv2(cpt[0], cpt[1]));
-                        let hp = handle_point(effectcraft_engine::render::content_bounds(&ectx, &layer)?, hi);
+                        let hp = handle_point(effectcraft_engine::render::content_bounds(ectx, &layer)?, hi);
                         Some(Gesture::Scale {
                             layer: lid,
                             start_scale: ectx.v3(&layer, tr, "scale", [100.0; 3]),
@@ -904,13 +1191,13 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
                             axes: handle_axes(hi),
                             l2c,
                             inv,
-                            others: selected_others(app, &comp, lid)
+                            others: selected_others(app, comp, lid)
                                 .into_iter()
                                 .filter_map(|o| Some((o.id, ectx.v3(o, o.transform()?, "scale", [100.0; 3]))))
                                 .collect(),
                         })
                     })
-                } else if let Some(l) = pick(app, &ectx, cpt, mods.shift) {
+                } else if let Some(l) = pick(app, ectx, cpt, mods.shift) {
                     let layers: Vec<(LayerId, [f64; 3], [f64; 3], [f64; 3])> = app
                         .session
                         .state
@@ -920,12 +1207,12 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
                         .filter(|l| !l.switches.locked)
                         .filter_map(|l| {
                             let p0 = ectx.v3(l, l.transform()?, "position", [0.0; 3]);
-                            let (ax, ay) = drag_axes(&ectx, l);
+                            let (ax, ay) = drag_axes(ectx, l);
                             Some((l.id, p0, ax, ay))
                         })
                         .collect();
                     // Snap the grabbed layer's feature nearest the pointer (AE's snap handle).
-                    let snap_src = comp.layer(l).map(|layer| effectcraft_engine::viewer::layer_features(&ectx, layer)).and_then(|f| {
+                    let snap_src = comp.layer(l).map(|layer| effectcraft_engine::viewer::layer_features(ectx, layer)).and_then(|f| {
                         let c = map.to_comp(press);
                         f.into_iter().min_by(|a, b| {
                             let da = (a[0] - c[0]).powi(2) + (a[1] - c[1]).powi(2);
@@ -954,6 +1241,25 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
             ui.data_mut(|d| d.insert_temp(gid, g));
         }
     }
+    v.ft = ft;
+    v.handle_hits = handle_hits;
+    v.axis_hits = axis_hits;
+    v.vertex_hits = vertex_hits;
+    v.key_hits = key_hits;
+    v.paths = paths;
+    v.track_hits = track_hits;
+    v.pin_hits = pin_hits;
+    v.puppet_layers = puppet_layers;
+}
+
+fn phase_drag(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, resp: &egui::Response, v: &mut Vw<'_>) {
+    let t = v.t;
+    let zoom = v.zoom;
+    let map = v.map;
+    let mods = v.mods;
+    let gid = v.gid;
+    let ctx = v.ctx.clone();
+    let painter = v.painter.clone();
     let gesture: Option = ui.data(|d| d.get_temp(gid));
     if let (Some(g), Some(pos)) = (gesture.clone(), resp.interact_pointer_pos()) {
         let cpt = map.to_comp(pos);
@@ -970,7 +1276,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
                 let mut d = one_axis([cpt[0] - start[0], cpt[1] - start[1]], mods.shift);
                 if let Some(src) = snap_src {
                     let ids: Vec = layers.iter().map(|x| x.0).collect();
-                    let c = vt::snap(app, &ctx, &ectx, &map, &ids, &[[src[0] + d[0], src[1] + d[1]]], mods);
+                    let c = vt::snap(app, &ctx, ectx, &map, &ids, &[[src[0] + d[0], src[1] + d[1]]], mods);
                     d = [d[0] + c[0], d[1] + c[1]];
                     if vt::snapping_on(app, mods) {
                         vt::draw_handle(&painter, map.to_screen([src[0] + d[0], src[1] + d[1]]));
@@ -987,7 +1293,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
                 let f = drag_ratio(start_local, [lp.x - anchor[0], lp.y - anchor[1]], axes, uniform);
                 // The handle snaps (to the comp's and other layers' edges, corners and centres),
                 // so a layer scales exactly to them, e.g. to the comp's size.
-                let f = snap_handle(f, handle, anchor, &l2c, axes, uniform, |p| vt::snap(app, &ctx, &ectx, &map, &[layer], &[p], mods));
+                let f = snap_handle(f, handle, anchor, &l2c, axes, uniform, |p| vt::snap(app, &ctx, ectx, &map, &[layer], &[p], mods));
                 for (lid, s0) in std::iter::once((layer, start_scale)).chain(others) {
                     let v = [s0[0] * f[0], s0[1] * f[1], s0[2]];
                     let _ = app.session.execute("prop.set", json!({"layer": lid.0, "path": "transform/scale", "value": v, "merge": merge}));
@@ -1013,7 +1319,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
                 let d = one_axis([cpt[0] - start[0], cpt[1] - start[1]], mods.shift);
                 let anchor_c = inv.inverse().map(|m| m.apply(gv2(start_anchor[0], start_anchor[1]))).unwrap_or(gv2(start[0], start[1]));
                 let want = [anchor_c.x + d[0], anchor_c.y + d[1]];
-                let sc = vt::snap_with(app, &ctx, &ectx, &map, &[layer], &[want], mods, &own);
+                let sc = vt::snap_with(app, &ctx, ectx, &map, &vt::SnapQuery { exclude: &[layer], sources: &[want], mods, extra: &own });
                 let a = inv.apply(gv2(start[0], start[1]));
                 let b = inv.apply(gv2(start[0] + d[0] + sc[0], start[1] + d[1] + sc[1]));
                 let dl = gv2(b.x - a.x, b.y - a.y);
@@ -1048,7 +1354,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
             Gesture::Vertices { start, src, applied, inv, layer } => {
                 // The grabbed vertex snaps (to other layers' vertices and features, guides, grid).
                 let want = [cpt[0] - start[0], cpt[1] - start[1]];
-                let c = vt::snap(app, &ctx, &ectx, &map, &[layer], &[[src[0] + want[0], src[1] + want[1]]], mods);
+                let c = vt::snap(app, &ctx, ectx, &map, &[layer], &[[src[0] + want[0], src[1] + want[1]]], mods);
                 let total = [want[0] + c[0], want[1] + c[1]];
                 let d = inv.apply_vec(gv2(total[0] - applied[0], total[1] - applied[1]));
                 if d.x != 0.0 || d.y != 0.0 {
@@ -1067,7 +1373,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
                     painter.rect_stroke(r, 0.0, Stroke::new(1.0, Color32::WHITE), StrokeKind::Middle);
                 }
                 d => {
-                    let mut snapper = |a: &EffectcraftApp, p: [f64; 2], l: LayerId| vt::snap(a, &ctx, &ectx, &map, &[l], &[p], mods);
+                    let mut snapper = |a: &EffectcraftApp, p: [f64; 2], l: LayerId| vt::snap(a, &ctx, ectx, &map, &[l], &[p], mods);
                     if let Some(nd) = ov::update(app, &d, cpt, pos, mods, &merge, zoom, &mut snapper) {
                         ui.data_mut(|dd| dd.insert_temp(gid, Gesture::Overlay(nd)));
                     }
@@ -1148,6 +1454,20 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
             }
         }
     }
+    v.gesture = gesture;
+}
+
+fn phase_stopped(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, resp: &egui::Response, v: &mut Vw<'_>) {
+    let area = v.area;
+    let comp_rect = v.comp_rect;
+    let map = v.map;
+    let time = v.time;
+    let mods = v.mods;
+    let gid = v.gid;
+    let vertex_hits = std::mem::take(&mut v.vertex_hits);
+    let pin_hits = std::mem::take(&mut v.pin_hits);
+    let gesture = v.gesture.clone();
+    let comp = v.comp;
     if resp.drag_stopped() {
         if let (Some(g), Some(pos)) = (gesture, resp.interact_pointer_pos()) {
             match g {
@@ -1181,7 +1501,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
                     }
                 }
                 Gesture::PuppetPin { layer, pin, .. } => super::puppet_tool::reveal_pin(app, layer, pin),
-                Gesture::ObjectMatteBox { start } => super::object_matte_tool::apply(app, &ectx, &map, start, Some(pos), mods.shift),
+                Gesture::ObjectMatteBox { start } => super::object_matte_tool::apply(app, ectx, &map, start, Some(pos), mods.shift),
                 Gesture::Create { tool, start } => {
                     let end = map.to_comp(pos);
                     create_shape(app, tool, start, end, mods);
@@ -1214,8 +1534,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
                 Gesture::Marquee { start } => {
                     let a = map.to_comp(start);
                     let b = map.to_comp(pos);
-                    let hits: Vec = selectable_layers(&comp, time)
-                        .filter(|l| layer_quad(&ectx, l).is_some_and(|(_, q, _)| quad_intersects_box(&q, a, b)))
+                    let hits: Vec = selectable_layers(comp, time)
+                        .filter(|l| layer_quad(ectx, l).is_some_and(|(_, q, _)| quad_intersects_box(&q, a, b)))
                         .map(|l| l.id.0)
                         .collect();
                     let _ = app.session.execute("layer.select", json!({"layers": hits, "toggle": mods.shift}));
@@ -1229,6 +1549,38 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
             d.insert_temp(egui::Id::unique("viewer-drag-n"), n + 1);
         });
     }
+    v.vertex_hits = vertex_hits;
+    v.pin_hits = pin_hits;
+}
+
+fn phase_clicked(app: &mut EffectcraftApp, ectx: &EvalCtx, resp: &egui::Response, v: &mut Vw<'_>) {
+    let area = v.area;
+    let zoom = v.zoom;
+    let cw = v.cw;
+    let ch = v.ch;
+    let map = v.map;
+    let mods = v.mods;
+    let text_owns = v.text_owns;
+    let ctx = v.ctx.clone();
+    let ft = std::mem::take(&mut v.ft);
+    let handle_hits = std::mem::take(&mut v.handle_hits);
+    let vertex_hits = std::mem::take(&mut v.vertex_hits);
+    let key_hits = std::mem::take(&mut v.key_hits);
+    let motion_frames = std::mem::take(&mut v.motion_frames);
+    let paths = std::mem::take(&mut v.paths);
+    let pin_hits = std::mem::take(&mut v.pin_hits);
+    let puppet_layers = std::mem::take(&mut v.puppet_layers);
+    let comp = v.comp;
+    let vertex_at = |pos: Pos2, tangents: bool| -> Option {
+        let reach = vertex_grab_radius(pos, handle_hits.iter().map(|(_, _, h, _)| *h));
+        vertex_hits.iter().rev().filter(|h| h.tangent.is_some() == tangents).find(|h| h.pos.distance(pos) < reach).copied()
+    };
+    let puppet_layer = |app: &mut EffectcraftApp, cpt: [f64; 2]| -> Option {
+        if puppet_layers.is_empty() {
+            return pick(app, ectx, cpt, false);
+        }
+        super::puppet_tool::target(ectx, &puppet_layers, cpt, |l| layers_at(ectx, cpt).any(|h| h.id == l.id))
+    };
     if resp.clicked()
         && !text_owns
         && let Some(pos) = resp.interact_pointer_pos()
@@ -1285,13 +1637,13 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
                 // Clicking away from the free-transform box ends it.
                 ov::end_free_transform(&ctx);
             }
-            Tool::ObjectMatte => super::object_matte_tool::apply(app, &ectx, &map, pos, None, mods.shift),
+            Tool::ObjectMatte => super::object_matte_tool::apply(app, ectx, &map, pos, None, mods.shift),
             t if t.puppet_kind().is_some() => {
                 if let Some((h, _)) = super::puppet_tool::hit(&pin_hits, pos) {
                     // Click selects the pin; Shift-click adds or removes it.
                     let _ = app.session.execute("puppet.selectPins", json!({"layer": h.layer.0, "pins": [h.pin], "toggle": mods.shift}));
                 } else if let Some(l) = puppet_layer(app, cpt).and_then(|l| comp.layer(l))
-                    && let Some(inv) = fx2c(&ectx, l).inverse()
+                    && let Some(inv) = fx2c(ectx, l).inverse()
                 {
                     let lp = inv.apply(gv2(cpt[0], cpt[1]));
                     match app.session.execute("puppet.addPin", json!({"layer": l.id.0, "kind": t.puppet_kind(), "position": [lp.x, lp.y]})) {
@@ -1312,13 +1664,13 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
             }
             // The Roto Brush paints in the Layer panel only: select as usual and say so.
             rt if rt.roto_kind(false).is_some() => {
-                if pick(app, &ectx, cpt, mods.shift).is_none() {
+                if pick(app, ectx, cpt, mods.shift).is_none() {
                     let _ = app.session.execute("edit.deselectAll", json!({}));
                 }
                 app.ui.status = crate::i18n::tr(ROTO_HINT).to_string();
             }
             _ => {
-                if pick(app, &ectx, cpt, mods.shift).is_none() {
+                if pick(app, ectx, cpt, mods.shift).is_none() {
                     let _ = app.session.execute("edit.deselectAll", json!({}));
                 } else if !mods.shift && !app.session.state.selected_vertices.is_empty() && ov::segment_at(&paths, &map, pos, 6.0).is_none() {
                     // A click away from the paths, even on their layer, deselects the mask
@@ -1328,6 +1680,28 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
             }
         }
     }
+    v.ft = ft;
+    v.handle_hits = handle_hits;
+    v.vertex_hits = vertex_hits;
+    v.key_hits = key_hits;
+    v.motion_frames = motion_frames;
+    v.paths = paths;
+    v.pin_hits = pin_hits;
+    v.puppet_layers = puppet_layers;
+}
+
+fn phase_dblclick(app: &mut EffectcraftApp, ectx: &EvalCtx, resp: &egui::Response, v: &mut Vw<'_>) {
+    let map = v.map;
+    let text_owns = v.text_owns;
+    let ctx = v.ctx.clone();
+    let handle_hits = std::mem::take(&mut v.handle_hits);
+    let vertex_hits = std::mem::take(&mut v.vertex_hits);
+    let paths = std::mem::take(&mut v.paths);
+    let comp = v.comp;
+    let vertex_at = |pos: Pos2, tangents: bool| -> Option {
+        let reach = vertex_grab_radius(pos, handle_hits.iter().map(|(_, _, h, _)| *h));
+        vertex_hits.iter().rev().filter(|h| h.tangent.is_some() == tangents).find(|h| h.pos.distance(pos) < reach).copied()
+    };
     if resp.double_clicked()
         && !text_owns
         && app.ui.tool == Tool::Selection
@@ -1349,7 +1723,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
     {
         let cpt = map.to_comp(pos);
         let roto = app.ui.tool.roto_kind(false).is_some();
-        if let Some(l) = pick(app, &ectx, cpt, false).and_then(|l| comp.layer(l)) {
+        if let Some(l) = pick(app, ectx, cpt, false).and_then(|l| comp.layer(l)) {
             match &l.source {
                 effectcraft_engine::project::LayerSource::Comp { item } => app.session.open_comp(*item),
                 // With a Roto Brush tool, double-clicking a layer opens it in the Layer panel,
@@ -1365,15 +1739,34 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
             }
         }
     }
+    v.handle_hits = handle_hits;
+    v.vertex_hits = vertex_hits;
+    v.paths = paths;
+}
+
+fn phase_tail(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, v: &mut Vw<'_>) {
+    let t = v.t;
+    let cid = v.cid;
+    let bar = v.bar;
+    let err_bar = v.err_bar;
+    let err_h = v.err_h;
+    let area = v.area;
+    let outer = v.outer;
+    let zoom = v.zoom;
+    let map = v.map;
+    let time = v.time;
+    let ctx = v.ctx.clone();
+    let painter = v.painter.clone();
+    let comp = v.comp;
     // 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);
+    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);
+    crate::panels::effect_controls::viewer_hook(app, ui, &painter, &map, ectx, &|c, l| l2c(c, l).0);
     // Selected shape gradients' start and end points (#612).
-    crate::panels::gradient_handles::viewer_hook(app, ui, &painter, &map, &ectx, &|c, l| l2c(c, l).0);
+    crate::panels::gradient_handles::viewer_hook(app, ui, &painter, &map, ectx, &|c, l| l2c(c, l).0);
     // Selected text Range Selectors' Start and End bars (#1011).
-    crate::panels::text_range_handles::viewer_hook(app, ui, &painter, &map, &ectx, &|c, l| l2c(c, l).0);
+    crate::panels::text_range_handles::viewer_hook(app, ui, &painter, &map, ectx, &|c, l| l2c(c, l).0);
 
     // Status chips (render time / caching).
     if app.playback.playing && app.playback.waiting {
@@ -1388,14 +1781,13 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         ui.ctx().data_mut(|d| d.insert_temp(egui::Id::unique("viewer-video-off-chip"), r));
     }
 
-    vt::draw_snap(&ctx, &painter, &map, &ectx);
+    vt::draw_snap(&ctx, &painter, &map, ectx);
     vt::rulers(app, ui, &map, outer, area);
-    vt::bottom_bar(app, ui, bar, zoom, time, &comp);
+    vt::bottom_bar(app, ui, bar, zoom, time, comp);
     if err_h > 0.0 {
         super::expr_bar::draw(app, ui, err_bar, cid);
     }
 }
-
 pub(crate) use crate::widgets::hex_rgb;
 
 mod frame;
diff --git a/crates/ui-egui/src/panels/viewer/frame.rs b/crates/ui-egui/src/panels/viewer/frame.rs
index 215a067ac..ac467ecd5 100644
--- a/crates/ui-egui/src/panels/viewer/frame.rs
+++ b/crates/ui-egui/src/panels/viewer/frame.rs
@@ -1,4 +1,4 @@
-use super::*;
+use super::{Align2, EffectcraftApp, Pos2, Rect, Stroke, StrokeKind, Tokens, View3D, ViewerMap, json, pos2, vec2, vt, widgets};
 
 /// Draw the no-composition viewer with New Composition and New Composition From Footage buttons, accepting Project
 /// items dropped on it to create the first composition.
diff --git a/crates/ui-egui/src/panels/viewer/geometry.rs b/crates/ui-egui/src/panels/viewer/geometry.rs
index 9c42cc5ef..847332bcf 100644
--- a/crates/ui-egui/src/panels/viewer/geometry.rs
+++ b/crates/ui-egui/src/panels/viewer/geometry.rs
@@ -1,4 +1,4 @@
-use super::*;
+use super::{CameraState, Comp, EffectcraftApp, EvalCtx, Layer, LayerId, Mat3, Pos2, VIEW_CAM, gv2, three_d, vec2};
 
 /// The camera the viewer shows (view camera or the comp's active camera).
 pub(super) fn cam_state(ctx: &EvalCtx) -> CameraState {
diff --git a/crates/ui-egui/src/panels/viewer/gesture.rs b/crates/ui-egui/src/panels/viewer/gesture.rs
index 72f6d14b5..db024e720 100644
--- a/crates/ui-egui/src/panels/viewer/gesture.rs
+++ b/crates/ui-egui/src/panels/viewer/gesture.rs
@@ -1,4 +1,4 @@
-use super::*;
+use super::{EffectcraftApp, LayerId, Mat3, Pos2, Rect, Tick, Tool, ViewerMap, ov, vec2};
 
 /// What a drag in the viewer is doing.
 #[derive(Clone, Debug)]
diff --git a/crates/ui-egui/src/panels/viewer/shapes.rs b/crates/ui-egui/src/panels/viewer/shapes.rs
index 0cf72bb52..395219a05 100644
--- a/crates/ui-egui/src/panels/viewer/shapes.rs
+++ b/crates/ui-egui/src/panels/viewer/shapes.rs
@@ -1,5 +1,8 @@
 use super::geometry::{l2c, layer_quad, point_in_quad};
-use super::*;
+use super::{
+    Color32, Comp, EffectcraftApp, EvalCtx, Layer, LayerId, LayerSource, Mat3, PaintKind, Pos2, Rect, Stroke, Tick, Tokens, Tool, ViewerMap, gv2, json, ov,
+    pos2,
+};
 
 /// Visible, unlocked layers eligible for viewer hit tests, in front-to-back order.
 /// Solo scope matches the renderer: any soloed AV layer activates it at every time (as in After
diff --git a/crates/ui-egui/src/panels/viewer/tools.rs b/crates/ui-egui/src/panels/viewer/tools.rs
index 3703d999d..8a3fbf0f3 100644
--- a/crates/ui-egui/src/panels/viewer/tools.rs
+++ b/crates/ui-egui/src/panels/viewer/tools.rs
@@ -1,6 +1,6 @@
 use super::geometry::{cam_state, l2c, parent_inverse};
 use super::gesture::{Gesture, pen_id};
-use super::*;
+use super::{CameraState, Color32, EffectcraftApp, EvalCtx, Layer, LayerId, Mat3, Pos2, Rect, Stroke, Tool, ViewerMap, gv2, json, ov, three_d, vec2, vt};
 
 /// The position change per comp pixel of drag (x, y) that moves a layer by `dir` when the
 /// pointer covers the axis' on-screen vector `s` (comp px); drag across the axis is ignored.
diff --git a/crates/ui-egui/src/panels/viewer/view.rs b/crates/ui-egui/src/panels/viewer/view.rs
index 1b8ef2292..30b3f4d00 100644
--- a/crates/ui-egui/src/panels/viewer/view.rs
+++ b/crates/ui-egui/src/panels/viewer/view.rs
@@ -1,5 +1,5 @@
 use super::tools::draw_rigs;
-use super::*;
+use super::{Align2, Color32, EffectcraftApp, EvalCtx, Rect, Sense, Stroke, StrokeKind, Tokens, View3D, ViewerMap, json, pos2, three_d, vec2, widgets};
 
 /// Draw the extra views of a 2- or 4-view layout and return the main view's rectangle.
 pub(super) fn aux_views(
diff --git a/crates/ui-egui/src/panels/viewer_text.rs b/crates/ui-egui/src/panels/viewer_text.rs
index f2becd9b1..ce387f6ca 100644
--- a/crates/ui-egui/src/panels/viewer_text.rs
+++ b/crates/ui-egui/src/panels/viewer_text.rs
@@ -810,6 +810,91 @@ fn typing_key(ui: &egui::Ui, bump: bool) -> String {
     format!("text-typing-{n}")
 }
 
+/// A press inside the edited text layer: grab a box handle, place the caret (shift extends from
+/// `shift_anchor`), or, outside the text, commit the edit. Returns the drag it starts, if any.
+fn press_on_edited(app: &mut EffectcraftApp, ui: &egui::Ui, map: &ViewerMap, e: &Edited, pos: Pos2, shift_anchor: Option) -> Option {
+    if let Some(h) = e.handles().iter().position(|h| e.to_screen(map, *h).distance(pos) < 7.0) {
+        let b = e.doc.box_size.unwrap_or([0.0; 2]);
+        return Some(Drag::Resize { layer: e.layer.id, handle: h, start_box: [e.doc.box_pos[0], e.doc.box_pos[1], b[0], b[1]] });
+    }
+    if e.contains(map, pos) {
+        let ci = e.hit(map, pos);
+        exec(app, "text.setSelection", json!({"anchor": shift_anchor.unwrap_or(ci), "caret": ci}));
+        typing_key(ui, true);
+        return Some(Drag::Select { layer: e.layer.id });
+    }
+    // Clicking elsewhere commits the edit.
+    exec(app, "text.endEdit", json!({}));
+    None
+}
+
+/// A press with the Type tool that missed the edited text: edit the text layer under it, or
+/// start creating a new one.
+fn press_type_tool(app: &mut EffectcraftApp, ectx: &EvalCtx, map: &ViewerMap, pos: Pos2, tool: Tool) -> Drag {
+    if let Some(e) = text_layer_at(app, ectx, map, pos, true) {
+        let ci = e.hit(map, pos);
+        exec(app, "text.edit", json!({"layer": e.layer.id.0, "caret": ci}));
+        Drag::Select { layer: e.layer.id }
+    } else {
+        Drag::Create { start: map.to_comp(pos), vertical: tool == Tool::TypeVertical }
+    }
+}
+
+/// The paragraph box after dragging `handle` by (`dx`, `dy`) in layer space.
+fn resized_box(start_box: [f64; 4], handle: usize, dx: f64, dy: f64) -> [f64; 4] {
+    let [mut x0, mut y0, w, h] = start_box;
+    let (mut x1, mut y1) = (x0 + w, y0 + h);
+    if matches!(handle, 0 | 6 | 7) {
+        x0 += dx;
+    }
+    if matches!(handle, 2..=4) {
+        x1 += dx;
+    }
+    if matches!(handle, 0..=2) {
+        y0 += dy;
+    }
+    if matches!(handle, 4..=6) {
+        y1 += dy;
+    }
+    [x0.min(x1), y0.min(y1), (x1 - x0).abs().max(4.0), (y1 - y0).abs().max(4.0)]
+}
+
+/// Keyboard routing for the text editor: while a session is live, keep the focus on it and feed
+/// it this frame's keys; otherwise release the IME and the focus.
+fn keyboard_focus(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
+    if app.session.state.text_edit.is_some() && app.dialog.is_none() && ui.is_enabled() {
+        // Register after pointer handling so new edits take focus in this frame. An inactive
+        // editor must not take Tab focus and immediately surrender it before viewer controls.
+        ui.interact(Rect::from_min_size(rect.min, vec2(1.0, 1.0)), focus_id(), egui::Sense::focusable_noninteractive());
+        ui.memory_mut(|m| {
+            m.set_focus_lock_filter(focus_id(), egui::EventFilter { tab: true, horizontal_arrows: true, vertical_arrows: true, escape: true });
+        });
+        // A session started elsewhere (menu, agent, Properties) takes the keyboard once…
+        let seen = ime_seen_id(ui.ctx().viewport_id());
+        let key = app.session.state.text_edit.as_ref().map(|e| (e.layer.0, e.session));
+        // …and gets it back when a click in the viewer or a formatting panel left nothing holding
+        // it; not merely because nothing has the focus (#831).
+        let regain = ui.memory(|m| m.focused().is_none()) && keeps_text_edit(app.ui.focused);
+        if ui.data(|d| d.get_temp::>(seen)).flatten() != key || regain {
+            ui.data_mut(|d| d.insert_temp(seen, key));
+            ui.memory_mut(|m| m.request_focus(focus_id()));
+        }
+        if ui.memory(|m| m.has_focus(focus_id())) {
+            keyboard(app, ui, rect);
+        }
+    } else {
+        // Resolve the viewport before taking the data lock: `viewport_id()`
+        // acquires the same context lock that `data_mut` holds.
+        let viewport = ui.ctx().viewport_id();
+        ui.data_mut(|d| d.insert_temp::>(ime_seen_id(viewport), None));
+        invalidate_ime_composition(ui, app);
+        clear_preedit(ui);
+        if ui.memory(|m| m.has_focus(focus_id())) {
+            ui.memory_mut(|m| m.surrender_focus(focus_id()));
+        }
+    }
+}
+
 /// Viewer hook: draws the edit overlay and handles text pointer and keyboard input. Returns
 /// true when it owns this frame's pointer interaction (the viewer then skips its own gestures).
 pub fn hook(
@@ -858,7 +943,6 @@ pub fn hook(
         }
     });
     let pointer = ui.input(|i| i.pointer.interact_pos());
-    let handle_hit = |e: &Edited, s: Pos2| e.handles().iter().position(|h| e.to_screen(map, *h).distance(s) < 7.0);
 
     // ---- press
     if pressed
@@ -870,30 +954,14 @@ pub fn hook(
     {
         let mut handled = false;
         if let Some(e) = &cur {
-            if let Some(h) = handle_hit(e, pos) {
-                let b = e.doc.box_size.unwrap_or([0.0; 2]);
-                drag = Some(Drag::Resize { layer: e.layer.id, handle: h, start_box: [e.doc.box_pos[0], e.doc.box_pos[1], b[0], b[1]] });
+            let shift_anchor = if mods.shift { Some(editing.as_ref().map_or_else(|| e.hit(map, pos), |sel| sel.anchor)) } else { None };
+            if let Some(d) = press_on_edited(app, ui, map, e, pos, shift_anchor) {
+                drag = Some(d);
                 handled = true;
-            } else if e.contains(map, pos) {
-                let ci = e.hit(map, pos);
-                let anchor = if mods.shift { editing.as_ref().map_or(ci, |sel| sel.anchor) } else { ci };
-                exec(app, "text.setSelection", json!({"anchor": anchor, "caret": ci}));
-                typing_key(ui, true);
-                drag = Some(Drag::Select { layer: e.layer.id });
-                handled = true;
-            } else {
-                // Clicking elsewhere commits the edit.
-                exec(app, "text.endEdit", json!({}));
             }
         }
         if !handled && type_tool {
-            if let Some(e) = text_layer_at(app, ectx, map, pos, true) {
-                let ci = e.hit(map, pos);
-                exec(app, "text.edit", json!({"layer": e.layer.id.0, "caret": ci}));
-                drag = Some(Drag::Select { layer: e.layer.id });
-            } else {
-                drag = Some(Drag::Create { start: map.to_comp(pos), vertical: tool == Tool::TypeVertical });
-            }
+            drag = Some(press_type_tool(app, ectx, map, pos, tool));
             handled = true;
         }
         if handled {
@@ -948,22 +1016,7 @@ pub fn hook(
             {
                 let (px, py) = e.to_layer(map, p);
                 let (qx, qy) = e.to_layer(map, q);
-                let (dx, dy) = (px - qx, py - qy);
-                let [mut x0, mut y0, w, h] = start_box;
-                let (mut x1, mut y1) = (x0 + w, y0 + h);
-                if matches!(handle, 0 | 6 | 7) {
-                    x0 += dx;
-                }
-                if matches!(handle, 2..=4) {
-                    x1 += dx;
-                }
-                if matches!(handle, 0..=2) {
-                    y0 += dy;
-                }
-                if matches!(handle, 4..=6) {
-                    y1 += dy;
-                }
-                let bx = [x0.min(x1), y0.min(y1), (x1 - x0).abs().max(4.0), (y1 - y0).abs().max(4.0)];
+                let bx = resized_box(start_box, handle, px - qx, py - qy);
                 exec(app, "layer.setText", json!({"layer": layer.0, "box": bx, "merge": format!("text-box-{}", layer.0)}));
             }
         }
@@ -1009,37 +1062,7 @@ pub fn hook(
     // composition's identity as a stale guard before this pass's IME events are read.
     sync_ime_target(ui, app);
     sync_ime_selection(ui, app);
-    if app.session.state.text_edit.is_some() && app.dialog.is_none() && ui.is_enabled() {
-        // Register after pointer handling so new edits take focus in this frame. An inactive
-        // editor must not take Tab focus and immediately surrender it before viewer controls.
-        ui.interact(Rect::from_min_size(resp.rect.min, vec2(1.0, 1.0)), focus_id(), egui::Sense::focusable_noninteractive());
-        ui.memory_mut(|m| {
-            m.set_focus_lock_filter(focus_id(), egui::EventFilter { tab: true, horizontal_arrows: true, vertical_arrows: true, escape: true });
-        });
-        // A session started elsewhere (menu, agent, Properties) takes the keyboard once…
-        let seen = ime_seen_id(ui.ctx().viewport_id());
-        let key = app.session.state.text_edit.as_ref().map(|e| (e.layer.0, e.session));
-        // …and gets it back when a click in the viewer or a formatting panel left nothing holding
-        // it; not merely because nothing has the focus (#831).
-        let regain = ui.memory(|m| m.focused().is_none()) && keeps_text_edit(app.ui.focused);
-        if ui.data(|d| d.get_temp::>(seen)).flatten() != key || regain {
-            ui.data_mut(|d| d.insert_temp(seen, key));
-            ui.memory_mut(|m| m.request_focus(focus_id()));
-        }
-        if ui.memory(|m| m.has_focus(focus_id())) {
-            keyboard(app, ui, resp.rect);
-        }
-    } else {
-        // Resolve the viewport before taking the data lock: `viewport_id()`
-        // acquires the same context lock that `data_mut` holds.
-        let viewport = ui.ctx().viewport_id();
-        ui.data_mut(|d| d.insert_temp::>(ime_seen_id(viewport), None));
-        invalidate_ime_composition(ui, app);
-        clear_preedit(ui);
-        if ui.memory(|m| m.has_focus(focus_id())) {
-            ui.memory_mut(|m| m.surrender_focus(focus_id()));
-        }
-    }
+    keyboard_focus(app, ui, resp.rect);
 
     // ---- overlay
     if let Some(s) = app.session.state.text_edit.clone()
@@ -1219,111 +1242,7 @@ fn keyboard(app: &mut EffectcraftApp, ui: &mut egui::Ui, viewer: Rect) {
                 }
                 typing_key(ui, true);
             }
-            egui::Event::Key { key, pressed: true, modifiers: m, .. } => {
-                use egui::Key;
-                let cmd = m.command;
-                let mv = |app: &mut EffectcraftApp, to: &str| {
-                    exec(app, "text.moveCaret", json!({"to": to, "extend": m.shift}));
-                };
-                match key {
-                    Key::ArrowLeft | Key::ArrowRight if m.alt => {
-                        // Kerning at the caret, tracking over a selection.
-                        let step = if cmd { 100.0 } else { 20.0 } * if key == Key::ArrowLeft { -1.0 } else { 1.0 };
-                        let doc = layer_doc(&app.session, sel.layer).unwrap_or_default();
-                        if sel.is_empty() {
-                            if sel.caret < doc.char_len() {
-                                let cur = match doc.style_at(sel.caret).kerning {
-                                    Kerning::Manual(v) => v,
-                                    _ => 0.0,
-                                };
-                                exec(
-                                    app,
-                                    "layer.setText",
-                                    json!({"layer": sel.layer.0, "range": [sel.caret, sel.caret + 1], "kerning": cur + step, "merge": format!("text-kern-{}", sel.caret)}),
-                                );
-                            }
-                        } else {
-                            let r = sel.range();
-                            let cur = doc.style_at(r.start).tracking;
-                            exec(app, "layer.setText", json!({"layer": sel.layer.0, "range": [r.start, r.end], "tracking": cur + step, "merge": "text-track"}));
-                        }
-                    }
-                    Key::ArrowLeft => mv(app, if cmd { "wordLeft" } else { "left" }),
-                    Key::ArrowRight => mv(app, if cmd { "wordRight" } else { "right" }),
-                    Key::ArrowUp => mv(app, if cmd { "paraUp" } else { "up" }),
-                    Key::ArrowDown => mv(app, if cmd { "paraDown" } else { "down" }),
-                    Key::Home => mv(app, if cmd { "start" } else { "lineStart" }),
-                    Key::End => mv(app, if cmd { "end" } else { "lineEnd" }),
-                    Key::Backspace => {
-                        let k = typing_key(ui, false);
-                        let before = ime_stamp(app, &ctx);
-                        if exec(app, "text.delete", json!({"word": cmd || m.alt, "merge": k})).is_some() {
-                            note_ime_application_edit(app, &ctx, before);
-                        }
-                    }
-                    Key::Delete => {
-                        let k = typing_key(ui, false);
-                        let before = ime_stamp(app, &ctx);
-                        if exec(app, "text.delete", json!({"direction": "forward", "word": cmd || m.alt, "merge": k})).is_some() {
-                            note_ime_application_edit(app, &ctx, before);
-                        }
-                    }
-                    Key::Enter if cmd => {
-                        exec(app, "text.endEdit", json!({}));
-                    }
-                    Key::Enter => {
-                        let k = typing_key(ui, false);
-                        let before = ime_stamp(app, &ctx);
-                        if exec(app, "text.insert", json!({"text": "\n", "merge": k})).is_some() {
-                            note_ime_application_edit(app, &ctx, before);
-                        }
-                    }
-                    Key::Tab => {
-                        let k = typing_key(ui, false);
-                        let before = ime_stamp(app, &ctx);
-                        if exec(app, "text.insert", json!({"text": "\t", "merge": k})).is_some() {
-                            note_ime_application_edit(app, &ctx, before);
-                        }
-                    }
-                    Key::Escape => {
-                        exec(app, "text.endEdit", json!({}));
-                    }
-                    // The editor owns exactly Cmd/Ctrl+A (Select All) and Cmd/Ctrl+Z with or
-                    // without Shift (Undo, Redo). Other modifiers on those keys aren't editing
-                    // chords, so they fall through to the binding match below: Shift+A is
-                    // Deselect All's default, and Alt variants are free for a rebound one.
-                    Key::A if cmd && !m.shift && !m.alt => {
-                        exec(app, "text.setSelection", json!({"select": "all"}));
-                    }
-                    Key::Z if cmd && !m.alt => {
-                        let before = ime_stamp(app, &ctx);
-                        if crate::menus::invoke(app, &ctx, if m.shift { "edit.redo" } else { "edit.undo" }, json!({})).is_ok() {
-                            note_ime_application_edit(app, &ctx, before);
-                        }
-                    }
-                    Key::Comma | Key::Period if cmd && m.shift => {
-                        let doc = layer_doc(&app.session, sel.layer).unwrap_or_default();
-                        let r = sel.range();
-                        let size = sel.pending.as_ref().map_or_else(|| doc.style_at(r.start).size, |p| p.size);
-                        let ns = (size + if key == Key::Period { 2.0 } else { -2.0 }).max(1.0);
-                        exec(app, "layer.setText", json!({"layer": sel.layer.0, "range": [r.start, r.end], "size": ns}));
-                    }
-                    // The app-wide shortcut dispatcher is skipped while this editor holds the
-                    // keyboard, so the Deselect All chord (Cmd/Ctrl+Shift+A or F2 by default)
-                    // must be matched here and run through the command dispatcher like undo
-                    // above. Matching the command's current binding, not a fixed key, keeps
-                    // rebound shortcuts working and gives every route one final state; the
-                    // forwarding test also refuses chords a focused editor must never surrender
-                    // (see `forwardable_during_text_edit`).
-                    _ if crate::menus::chord_matches(&app.session, "edit.deselectAll", key, m) && crate::menus::forwardable_during_text_edit(m, key) => {
-                        let _ = crate::menus::invoke(app, &ctx, "edit.deselectAll", json!({}));
-                    }
-                    _ => {}
-                }
-                if !matches!(key, Key::Backspace | Key::Delete | Key::Enter | Key::Tab) {
-                    typing_key(ui, true);
-                }
-            }
+            egui::Event::Key { key, pressed: true, modifiers: m, .. } => key_event(app, ui, &ctx, &sel, key, m),
             _ => {}
         }
         let route_ended = app.session.state.text_edit.is_none() || app.dialog.is_some();
@@ -1344,6 +1263,121 @@ fn keyboard(app: &mut EffectcraftApp, ui: &mut egui::Ui, viewer: Rect) {
     }
 }
 
+/// An application key press in the edited text: caret moves, deletes, insertions, formatting and
+/// the editor's own chords.
+fn key_event(
+    app: &mut EffectcraftApp,
+    ui: &egui::Ui,
+    ctx: &egui::Context,
+    sel: &effectcraft_engine::commands::text_edit::TextEdit,
+    key: egui::Key,
+    m: egui::Modifiers,
+) {
+    use egui::Key;
+    let cmd = m.command;
+    let mv = |app: &mut EffectcraftApp, to: &str| {
+        exec(app, "text.moveCaret", json!({"to": to, "extend": m.shift}));
+    };
+    match key {
+        Key::ArrowLeft | Key::ArrowRight if m.alt => {
+            // Kerning at the caret, tracking over a selection.
+            let step = if cmd { 100.0 } else { 20.0 } * if key == Key::ArrowLeft { -1.0 } else { 1.0 };
+            let doc = layer_doc(&app.session, sel.layer).unwrap_or_default();
+            if sel.is_empty() {
+                if sel.caret < doc.char_len() {
+                    let cur = match doc.style_at(sel.caret).kerning {
+                        Kerning::Manual(v) => v,
+                        _ => 0.0,
+                    };
+                    exec(
+                        app,
+                        "layer.setText",
+                        json!({"layer": sel.layer.0, "range": [sel.caret, sel.caret + 1], "kerning": cur + step, "merge": format!("text-kern-{}", sel.caret)}),
+                    );
+                }
+            } else {
+                let r = sel.range();
+                let cur = doc.style_at(r.start).tracking;
+                exec(app, "layer.setText", json!({"layer": sel.layer.0, "range": [r.start, r.end], "tracking": cur + step, "merge": "text-track"}));
+            }
+        }
+        Key::ArrowLeft => mv(app, if cmd { "wordLeft" } else { "left" }),
+        Key::ArrowRight => mv(app, if cmd { "wordRight" } else { "right" }),
+        Key::ArrowUp => mv(app, if cmd { "paraUp" } else { "up" }),
+        Key::ArrowDown => mv(app, if cmd { "paraDown" } else { "down" }),
+        Key::Home => mv(app, if cmd { "start" } else { "lineStart" }),
+        Key::End => mv(app, if cmd { "end" } else { "lineEnd" }),
+        Key::Backspace => {
+            let k = typing_key(ui, false);
+            let before = ime_stamp(app, ctx);
+            if exec(app, "text.delete", json!({"word": cmd || m.alt, "merge": k})).is_some() {
+                note_ime_application_edit(app, ctx, before);
+            }
+        }
+        Key::Delete => {
+            let k = typing_key(ui, false);
+            let before = ime_stamp(app, ctx);
+            if exec(app, "text.delete", json!({"direction": "forward", "word": cmd || m.alt, "merge": k})).is_some() {
+                note_ime_application_edit(app, ctx, before);
+            }
+        }
+        Key::Enter if cmd => {
+            exec(app, "text.endEdit", json!({}));
+        }
+        Key::Enter => {
+            let k = typing_key(ui, false);
+            let before = ime_stamp(app, ctx);
+            if exec(app, "text.insert", json!({"text": "\n", "merge": k})).is_some() {
+                note_ime_application_edit(app, ctx, before);
+            }
+        }
+        Key::Tab => {
+            let k = typing_key(ui, false);
+            let before = ime_stamp(app, ctx);
+            if exec(app, "text.insert", json!({"text": "\t", "merge": k})).is_some() {
+                note_ime_application_edit(app, ctx, before);
+            }
+        }
+        Key::Escape => {
+            exec(app, "text.endEdit", json!({}));
+        }
+        // The editor owns exactly Cmd/Ctrl+A (Select All) and Cmd/Ctrl+Z with or
+        // without Shift (Undo, Redo). Other modifiers on those keys aren't editing
+        // chords, so they fall through to the binding match below: Shift+A is
+        // Deselect All's default, and Alt variants are free for a rebound one.
+        Key::A if cmd && !m.shift && !m.alt => {
+            exec(app, "text.setSelection", json!({"select": "all"}));
+        }
+        Key::Z if cmd && !m.alt => {
+            let before = ime_stamp(app, ctx);
+            if crate::menus::invoke(app, ctx, if m.shift { "edit.redo" } else { "edit.undo" }, json!({})).is_ok() {
+                note_ime_application_edit(app, ctx, before);
+            }
+        }
+        Key::Comma | Key::Period if cmd && m.shift => {
+            let doc = layer_doc(&app.session, sel.layer).unwrap_or_default();
+            let r = sel.range();
+            let size = sel.pending.as_ref().map_or_else(|| doc.style_at(r.start).size, |p| p.size);
+            let ns = (size + if key == Key::Period { 2.0 } else { -2.0 }).max(1.0);
+            exec(app, "layer.setText", json!({"layer": sel.layer.0, "range": [r.start, r.end], "size": ns}));
+        }
+        // The app-wide shortcut dispatcher is skipped while this editor holds the
+        // keyboard, so the Deselect All chord (Cmd/Ctrl+Shift+A or F2 by default)
+        // must be matched here and run through the command dispatcher like undo
+        // above. Matching the command's current binding, not a fixed key, keeps
+        // rebound shortcuts working and gives every route one final state; the
+        // forwarding test also refuses chords a focused editor must never surrender
+        // (see `forwardable_during_text_edit`).
+        _ if crate::menus::chord_matches(&app.session, "edit.deselectAll", key, m) && crate::menus::forwardable_during_text_edit(m, key) => {
+            let _ = crate::menus::invoke(app, ctx, "edit.deselectAll", json!({}));
+        }
+        _ => {}
+    }
+    if !matches!(key, Key::Backspace | Key::Delete | Key::Enter | Key::Tab) {
+        typing_key(ui, true);
+    }
+}
+
 /// Delete the text an IME marks for replacement around the editor's selection.
 ///
 /// `before`/`after` count UTF-8 bytes (`DeleteSurroundingBytes`, what winit reports) or
diff --git a/crates/ui-egui/src/panels/viewer_tools.rs b/crates/ui-egui/src/panels/viewer_tools.rs
index 238a48c37..f86a7bfee 100644
--- a/crates/ui-egui/src/panels/viewer_tools.rs
+++ b/crates/ui-egui/src/panels/viewer_tools.rs
@@ -48,20 +48,21 @@ pub(crate) fn snap(
     sources: &[[f64; 2]],
     mods: egui::Modifiers,
 ) -> [f64; 2] {
-    snap_with(app, ctx, ectx, map, exclude, sources, mods, &[])
+    snap_with(app, ctx, ectx, map, &SnapQuery { exclude, sources, mods, extra: &[] })
 }
 
-/// [`snap`] with `extra` layer targets (the Pan Behind layer's own box).
-pub(crate) fn snap_with(
-    app: &EffectcraftApp,
-    ctx: &egui::Context,
-    ectx: &EvalCtx,
-    map: &ViewerMap,
-    exclude: &[LayerId],
-    sources: &[[f64; 2]],
-    mods: egui::Modifiers,
-    extra: &[vw::SnapTarget],
-) -> [f64; 2] {
+/// What [`snap_with`] snaps: the dragged features and the layers to ignore, plus `extra` layer
+/// targets (the Pan Behind layer's own box).
+pub(crate) struct SnapQuery<'a> {
+    pub exclude: &'a [LayerId],
+    pub sources: &'a [[f64; 2]],
+    pub mods: egui::Modifiers,
+    pub extra: &'a [vw::SnapTarget],
+}
+
+/// [`snap`] with extra layer targets.
+pub(crate) fn snap_with(app: &EffectcraftApp, ctx: &egui::Context, ectx: &EvalCtx, map: &ViewerMap, q: &SnapQuery) -> [f64; 2] {
+    let SnapQuery { exclude, sources, mods, extra } = *q;
     let layers = snapping_on(app, mods);
     let v = &app.ui.viewer;
     let guides = v.snap_guides && v.guides;
@@ -620,6 +621,26 @@ pub(crate) fn bottom_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui, bar: Rect,
     });
 }
 
+/// The exposure drag field (stops) at `er`; a change runs `view.exposure`.
+fn exposure_field(app: &mut EffectcraftApp, ui: &mut egui::Ui, er: Rect, stops: f64) {
+    let mut ev = stops;
+    let resp = ui.put(
+        er,
+        crate::widgets::drag_value(
+            egui::DragValue::new(&mut ev)
+                .speed(0.05)
+                .range(-40.0..=40.0)
+                .fixed_decimals(1)
+                .custom_formatter(|v, _| if v == 0.0 { "0.0".into() } else { format!("{v:+.1}") }),
+        ),
+    );
+    let resp = resp.on_hover_text(crate::i18n::tr("Adjust Exposure (stops)"));
+    app.auto.add_response("viewer.exposure", &resp, "Adjust Exposure");
+    if resp.changed() {
+        let _ = app.session.execute("view.exposure", json!({"stops": ev}));
+    }
+}
+
 /// Lay out the viewer's control row from `bar.min.x`: magnification, resolution, transparency, mask, ROI, grid/guide,
 /// channel/colour management, exposure, snapshot, fast previews, 3D renderer/view and Extended Viewer, timecode and render-time
 /// indicators, running commands or toggling viewer UI state. Returns the width the row
@@ -743,22 +764,7 @@ fn bottom_bar_contents(app: &mut EffectcraftApp, ui: &mut egui::Ui, bar: Rect, z
         let _ = app.session.execute("view.resetExposure", json!({}));
     }
     let er = Rect::from_min_size(pos2(x, cy - 10.0), vec2(42.0, 20.0));
-    let mut ev = vo.exposure as f64;
-    let resp = ui.put(
-        er,
-        crate::widgets::drag_value(
-            egui::DragValue::new(&mut ev)
-                .speed(0.05)
-                .range(-40.0..=40.0)
-                .fixed_decimals(1)
-                .custom_formatter(|v, _| if v == 0.0 { "0.0".into() } else { format!("{v:+.1}") }),
-        ),
-    );
-    let resp = resp.on_hover_text(crate::i18n::tr("Adjust Exposure (stops)"));
-    app.auto.add_response("viewer.exposure", &resp, "Adjust Exposure");
-    if resp.changed() {
-        let _ = app.session.execute("view.exposure", json!({"stops": ev}));
-    }
+    exposure_field(app, ui, er, vo.exposure as f64);
     x = er.max.x + 4.0;
     // Snapshots.
     if tog(ui, &mut app.auto, &mut x, Icon::Snapshot, false, "snapshot", "Take Snapshot").0 {
diff --git a/crates/ui-egui/src/pointer_wrap/tests.rs b/crates/ui-egui/src/pointer_wrap/tests.rs
index 3843db110..314f7ad3d 100644
--- a/crates/ui-egui/src/pointer_wrap/tests.rs
+++ b/crates/ui-egui/src/pointer_wrap/tests.rs
@@ -67,10 +67,7 @@ impl Scrub {
                     pos2(100.0, 100.0),
                     Id::unique("scrub"),
                     self.value,
-                    1.0,
-                    (-1e9, 1e9),
-                    1,
-                    "",
+                    &crate::widgets::HotSpec { speed: 1.0, range: (-1e9, 1e9), decimals: 1, suffix: "" },
                     &crate::theme::Tokens::for_kind(crate::theme::ThemeKind::Dark),
                 );
                 if let Some(v) = value {
diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs
index c870a96db..58383fe43 100644
--- a/crates/ui-egui/src/widgets.rs
+++ b/crates/ui-egui/src/widgets.rs
@@ -51,20 +51,21 @@ fn tab_stop(ui: &Ui, id: egui::Id) -> bool {
     })
 }
 
+/// How a hot-text field scrubs and prints: `speed` value units per pointer pixel, clamped to
+/// `range`, shown with `decimals` places followed by `suffix`.
+#[derive(Clone, Copy)]
+pub struct HotSpec<'a> {
+    pub speed: f64,
+    pub range: (f64, f64),
+    pub decimals: usize,
+    pub suffix: &'a str,
+}
+
 /// "Hot text": a blue number you drag horizontally to scrub, click to type. Tab / Shift+Tab
 /// while typing commits and types in the panel's next / previous one, as in After Effects.
 /// Returns (response, Some(new value) when changed, drag finished).
-pub fn hot_number_at(
-    ui: &mut Ui,
-    rect_min: egui::Pos2,
-    id: egui::Id,
-    value: f64,
-    speed: f64,
-    range: (f64, f64),
-    decimals: usize,
-    suffix: &str,
-    t: &Tokens,
-) -> (Rect, Option, bool) {
+pub fn hot_number_at(ui: &mut Ui, rect_min: egui::Pos2, id: egui::Id, value: f64, spec: &HotSpec, t: &Tokens) -> (Rect, Option, bool) {
+    let HotSpec { speed, range, decimals, suffix } = *spec;
     let editing_id = id.with("editing");
     let mut editing: Option = ui.data(|d| d.get_temp(editing_id));
     if tab_stop(ui, id) && editing.is_none() {
@@ -163,7 +164,7 @@ pub fn slider_scrub_speed(ui: &ParamUi) -> f64 {
 /// `1 / speed` pixels however slowly the pointer moves (the fractions add up between frames),
 /// and a typed value is truncated. Returns (rect, Some(new value) when changed, drag finished).
 pub fn hot_int_at(ui: &mut Ui, rect_min: egui::Pos2, id: egui::Id, value: i64, speed: f64, suffix: &str, t: &Tokens) -> (Rect, Option, bool) {
-    let (rect, nv, done) = hot_number_at(ui, rect_min, id, value as f64, speed, (-1e6, 1e6), 0, suffix, t);
+    let (rect, nv, done) = hot_number_at(ui, rect_min, id, value as f64, &HotSpec { speed, range: (-1e6, 1e6), decimals: 0, suffix }, t);
     let acc_id = id.with("acc");
     let acc = ui.data(|d| d.get_temp::(acc_id)).unwrap_or(0.0) + nv.map_or(0.0, |n| n - value as f64);
     // (Ten 0.1 steps add up to just under 1.)
diff --git a/crates/vp9enc/src/lf.rs b/crates/vp9enc/src/lf.rs
index d781144fe..f3a96c558 100644
--- a/crates/vp9enc/src/lf.rs
+++ b/crates/vp9enc/src/lf.rs
@@ -13,6 +13,14 @@ struct Limits {
     thresh: i32,
 }
 
+/// Mode-info grid and thresholds shared by every superblock of this frame.
+struct FrameFilter<'a> {
+    mi: &'a [Mi],
+    mi_rows: usize,
+    mi_cols: usize,
+    limits: Limits,
+}
+
 /// Filter the reconstructed planes (Y, U, V, or a prefix of them) in place at `level` (0 = off;
 /// sharpness 0).
 pub fn filter_frame(planes: &mut [Plane], mi: &[Mi], mi_rows: usize, mi_cols: usize, level: u8) {
@@ -22,55 +30,58 @@ pub fn filter_frame(planes: &mut [Plane], mi: &[Mi], mi_rows: usize, mi_cols: us
     let l = level as i32;
     let limit = l.max(1);
     let lim = Limits { limit, blimit: 2 * (l + 2) + limit, thresh: l >> 4 };
+    let filter = FrameFilter { mi, mi_rows, mi_cols, limits: lim };
     for sbr in 0..mi_rows.div_ceil(8) {
         for sbc in 0..mi_cols.div_ceil(8) {
             for (plane, p) in planes.iter_mut().enumerate() {
                 for pass in 0..2 {
-                    sb_plane(p, mi, mi_rows, mi_cols, plane, pass, sbr * 8, sbc * 8, lim);
+                    filter.sb_plane(p, plane, pass, sbr * 8, sbc * 8);
                 }
             }
         }
     }
 }
 
-#[allow(clippy::too_many_arguments)]
-fn sb_plane(p: &mut Plane, mi: &[Mi], mi_rows: usize, mi_cols: usize, plane: usize, pass: usize, row: usize, col: usize, lim: Limits) {
-    let (sx, sy) = if plane > 0 { (1, 1) } else { (0, 0) };
-    let (sub, edge_len) = if pass == 0 { (sx, 64 >> sy) } else { (sy, 64 >> sx) };
-    let stride = p.w;
-    for edge in 0..(16usize >> sub) {
-        let mut i = 0;
-        while i < edge_len {
-            let (x, y) = if pass == 0 { (col * 8 + edge * (4 << sx), row * 8 + (i << sy)) } else { (col * 8 + (i << sx), row * 8 + edge * (4 << sy)) };
-            if x >= 8 * mi_cols || y >= 8 * mi_rows || (pass == 0 && x == 0) || (pass == 1 && y == 0) {
-                i += 8;
-                continue;
-            }
-            let lc = ((x >> 3) >> sx) << sx;
-            let lr = ((y >> 3) >> sy) << sy;
-            let m = mi[lr * mi_cols + lc];
-            let size = if sub == 0 { m.size } else { m.size.max(BLOCK_16X16) };
-            let n8 = if size >= BLOCK_16X16 { 2 } else { 1 };
-            let is_block_edge = if pass == 0 { x & (8 * n8 - 1) == 0 } else { y & (8 * n8 - 1) == 0 };
-            let tx_ok = !m.inter || !m.skip;
-            if !is_block_edge && !tx_ok {
-                i += 8;
-                continue;
-            }
-            let wide = edge % 8 == 0;
-            let n = if pass == 0 { (8 * mi_rows - y).div_ceil(1 << sy) } else { (8 * mi_cols - x).div_ceil(1 << sx) }.min(8).min(edge_len - i);
-            // A transform edge is not filtered in the last odd chroma column.
-            let odd_last = pass == 1 && sx == 1 && mi_cols & 1 == 1 && edge & 1 == 1;
-            let (px, py) = (x >> sx, y >> sy);
-            for l in 0..n {
-                let is_tx_edge = tx_ok && !(odd_last && x + (l << sx) + 8 >= mi_cols * 8);
-                if !(is_block_edge || is_tx_edge) {
+impl FrameFilter<'_> {
+    fn sb_plane(&self, p: &mut Plane, plane: usize, pass: usize, row: usize, col: usize) {
+        let Self { mi, mi_rows, mi_cols, limits: lim } = *self;
+        let (sx, sy) = if plane > 0 { (1, 1) } else { (0, 0) };
+        let (sub, edge_len) = if pass == 0 { (sx, 64 >> sy) } else { (sy, 64 >> sx) };
+        let stride = p.w;
+        for edge in 0..(16usize >> sub) {
+            let mut i = 0;
+            while i < edge_len {
+                let (x, y) = if pass == 0 { (col * 8 + edge * (4 << sx), row * 8 + (i << sy)) } else { (col * 8 + (i << sx), row * 8 + edge * (4 << sy)) };
+                if x >= 8 * mi_cols || y >= 8 * mi_rows || (pass == 0 && x == 0) || (pass == 1 && y == 0) {
+                    i += 8;
                     continue;
                 }
-                let (pos, step) = if pass == 0 { ((py + l) * stride + px, 1) } else { (py * stride + px + l, stride) };
-                filter_lane(&mut p.rec, pos, step, wide, lim);
+                let lc = ((x >> 3) >> sx) << sx;
+                let lr = ((y >> 3) >> sy) << sy;
+                let m = mi[lr * mi_cols + lc];
+                let size = if sub == 0 { m.size } else { m.size.max(BLOCK_16X16) };
+                let n8 = if size >= BLOCK_16X16 { 2 } else { 1 };
+                let is_block_edge = if pass == 0 { x & (8 * n8 - 1) == 0 } else { y & (8 * n8 - 1) == 0 };
+                let tx_ok = !m.inter || !m.skip;
+                if !is_block_edge && !tx_ok {
+                    i += 8;
+                    continue;
+                }
+                let wide = edge % 8 == 0;
+                let n = if pass == 0 { (8 * mi_rows - y).div_ceil(1 << sy) } else { (8 * mi_cols - x).div_ceil(1 << sx) }.min(8).min(edge_len - i);
+                // A transform edge is not filtered in the last odd chroma column.
+                let odd_last = pass == 1 && sx == 1 && mi_cols & 1 == 1 && edge & 1 == 1;
+                let (px, py) = (x >> sx, y >> sy);
+                for l in 0..n {
+                    let is_tx_edge = tx_ok && !(odd_last && x + (l << sx) + 8 >= mi_cols * 8);
+                    if !(is_block_edge || is_tx_edge) {
+                        continue;
+                    }
+                    let (pos, step) = if pass == 0 { ((py + l) * stride + px, 1) } else { (py * stride + px + l, stride) };
+                    filter_lane(&mut p.rec, pos, step, wide, lim);
+                }
+                i += 8;
             }
-            i += 8;
         }
     }
 }
diff --git a/crates/vp9enc/src/lib.rs b/crates/vp9enc/src/lib.rs
index 9364dfaa1..b9b56b693 100644
--- a/crates/vp9enc/src/lib.rs
+++ b/crates/vp9enc/src/lib.rs
@@ -85,7 +85,6 @@ pub(crate) struct Plane {
 }
 
 impl Plane {
-    #[allow(clippy::too_many_arguments)]
     fn new(data: &[u8], vw: usize, vh: usize, w: usize, h: usize, max_x: usize, max_y: usize) -> Plane {
         let mut src = vec![0u8; w * h];
         for y in 0..h {
diff --git a/crates/vp9enc/src/probs.rs b/crates/vp9enc/src/probs.rs
index 8f55bbba3..57af2e29b 100644
--- a/crates/vp9enc/src/probs.rs
+++ b/crates/vp9enc/src/probs.rs
@@ -9,7 +9,11 @@
 //! entropy codes the recorded symbols with the updated probabilities.
 
 use crate::bool::BoolEncoder;
-use crate::tables::*;
+use crate::tables::{
+    DEFAULT_COEF_PROBS, DEFAULT_INTER_MODE_PROBS, DEFAULT_IS_INTER_PROB, DEFAULT_MV_BITS_PROB, DEFAULT_MV_CLASS_PROBS, DEFAULT_MV_CLASS0_BIT_PROB,
+    DEFAULT_MV_CLASS0_FR_PROBS, DEFAULT_MV_FR_PROBS, DEFAULT_MV_JOINT_PROBS, DEFAULT_MV_SIGN_PROB, DEFAULT_PARTITION_PROBS, DEFAULT_SINGLE_REF_PROB,
+    DEFAULT_SKIP_PROB, PARETO_TABLE, inv_map_table,
+};
 
 /// Coefficient probabilities `[ptype][ref][band][ctx][3]` for 4×4 transforms (band 0 uses
 /// contexts 0..3 only).
diff --git a/crates/vp9enc/src/tables.rs b/crates/vp9enc/src/tables.rs
index ffb266419..c70d7d8c2 100644
--- a/crates/vp9enc/src/tables.rs
+++ b/crates/vp9enc/src/tables.rs
@@ -2,8 +2,6 @@
 //! transcribed in FilmCraft's first-party decoder `filmcraft-vp9` (MIT OR Apache-2.0, the same
 //! licence as this crate).
 
-#![allow(clippy::unreadable_literal)]
-
 pub const DC_QLOOKUP: [i32; 256] = [
     4, 8, 8, 9, 10, 11, 12, 12, 13, 14, 15, 16, 17, 18, 19, 19, 20, 21, 22, 23, 24, 25, 26, 26, 27, 28, 29, 30, 31, 32, 32, 33, 34, 35, 36, 37, 38, 38, 39, 40,
     41, 42, 43, 43, 44, 45, 46, 47, 48, 48, 49, 50, 51, 52, 53, 53, 54, 55, 56, 57, 57, 58, 59, 60, 61, 62, 62, 63, 64, 65, 66, 66, 67, 68, 69, 70, 70, 71, 72,
diff --git a/crates/vp9enc/src/tile.rs b/crates/vp9enc/src/tile.rs
index d7f6cb64f..1f0c37d80 100644
--- a/crates/vp9enc/src/tile.rs
+++ b/crates/vp9enc/src/tile.rs
@@ -8,7 +8,10 @@
 
 use crate::mc::{Mv, PhasePlanes, RefPlane, clamp_for_plane, predict};
 use crate::probs::{self, P, Writer, cost, tree_cost};
-use crate::tables::*;
+use crate::tables::{
+    COEFBAND_4X4, COL_SCAN_4X4, COUNTER_TO_CONTEXT, DEFAULT_SCAN_4X4, ENERGY_CLASS, KF_PARTITION_PROBS, KF_UV_MODE_PROBS, KF_Y_MODE_PROBS, MODE_2_COUNTER,
+    MV_REF_8X8, MV_REF_16X16, ROW_SCAN_4X4,
+};
 use crate::transform::{ADST_ADST, ADST_DCT, DCT_ADST, DCT_DCT, Quant};
 use crate::{BLOCK_8X8, BLOCK_16X16, Mi, Plane};
 
@@ -22,6 +25,13 @@ pub const NEARMV: u8 = 11;
 pub const ZEROMV: u8 = 12;
 pub const NEWMV: u8 = 13;
 
+/// Which reconstructed neighbours are available for intra prediction.
+#[derive(Clone, Copy)]
+struct IntraEdges {
+    left: bool,
+    above: bool,
+}
+
 const PARTITION_TREE: [i8; 6] = [0, 2, -1, 4, -2, -3];
 const INTRA_MODE_TREE: [i8; 18] = [0, 2, -9, 4, -1, 6, 8, 12, -2, 10, -4, -5, -3, 14, -8, 16, -6, -7];
 /// Leaves are `mode - NEARESTMV`.
@@ -104,7 +114,8 @@ fn levels_cost(levels: &[i32; 16], eob: usize) -> u32 {
 }
 
 /// Intra prediction (8.5.1) of a square block for DC / V / H / TM.
-fn predict_intra(p: &Plane, x: usize, y: usize, log2: usize, mode: u8, have_left: bool, have_above: bool) -> [i32; 64] {
+fn predict_intra(p: &Plane, x: usize, y: usize, log2: usize, mode: u8, edges: IntraEdges) -> [i32; 64] {
+    let IntraEdges { left: have_left, above: have_above } = edges;
     let size = 1usize << log2;
     let base = 128i32;
     let buf = &p.rec;
@@ -356,11 +367,11 @@ impl<'a> Tile<'a> {
     fn intra_block(&mut self, r: usize, c: usize) {
         let avail_u = r > 0;
         let avail_l = c > self.col_start;
-        let y_mode = self.choose_intra(0, c * 8, r * 8, 3, avail_l, avail_u);
+        let y_mode = self.choose_intra(0, c * 8, r * 8, 3, IntraEdges { left: avail_l, above: avail_u });
         let uv_mode = {
             let mut best = (i64::MAX, DC_PRED);
             for m in MODES {
-                let cost: i64 = (1..3).map(|p| self.intra_cost(p, c * 4, r * 4, 2, m, avail_l, avail_u)).sum();
+                let cost: i64 = (1..3).map(|p| self.intra_cost(p, c * 4, r * 4, 2, m, IntraEdges { left: avail_l, above: avail_u })).sum();
                 if cost < best.0 {
                     best = (cost, m);
                 }
@@ -379,17 +390,16 @@ impl<'a> Tile<'a> {
         // Residual: luma 2×2 transform blocks, then one 4×4 block per chroma plane.
         for (dy, dx) in [(0, 0), (0, 1), (1, 0), (1, 1)] {
             let tx = if self.f.quant.lossless { DCT_DCT } else { mode_tx_type(y_mode) };
-            self.intra_tx(0, c * 8 + dx * 4, r * 8 + dy * 4, y_mode, tx, avail_l || dx > 0, avail_u || dy > 0);
+            self.intra_tx(0, c * 8 + dx * 4, r * 8 + dy * 4, y_mode, tx, IntraEdges { left: avail_l || dx > 0, above: avail_u || dy > 0 });
         }
         for p in 1..3 {
-            self.intra_tx(p, c * 4, r * 4, uv_mode, DCT_DCT, avail_l, avail_u);
+            self.intra_tx(p, c * 4, r * 4, uv_mode, DCT_DCT, IntraEdges { left: avail_l, above: avail_u });
         }
     }
 
-    #[allow(clippy::too_many_arguments)]
-    fn intra_cost(&self, plane: usize, x: usize, y: usize, log2: usize, mode: u8, have_left: bool, have_above: bool) -> i64 {
+    fn intra_cost(&self, plane: usize, x: usize, y: usize, log2: usize, mode: u8, edges: IntraEdges) -> i64 {
         let p = &self.planes[plane];
-        let pred = predict_intra(p, x, y, log2, mode, have_left, have_above);
+        let pred = predict_intra(p, x, y, log2, mode, edges);
         let size = 1 << log2;
         let mut s = 0i64;
         for i in 0..size {
@@ -400,10 +410,10 @@ impl<'a> Tile<'a> {
         s
     }
 
-    fn choose_intra(&self, plane: usize, x: usize, y: usize, log2: usize, have_left: bool, have_above: bool) -> u8 {
+    fn choose_intra(&self, plane: usize, x: usize, y: usize, log2: usize, edges: IntraEdges) -> u8 {
         let mut best = (i64::MAX, DC_PRED);
         for m in MODES {
-            let cost = self.intra_cost(plane, x, y, log2, m, have_left, have_above);
+            let cost = self.intra_cost(plane, x, y, log2, m, edges);
             if cost < best.0 {
                 best = (cost, m);
             }
@@ -411,8 +421,7 @@ impl<'a> Tile<'a> {
         best.1
     }
 
-    #[allow(clippy::too_many_arguments)]
-    fn intra_tx(&mut self, plane: usize, x: usize, y: usize, mode: u8, tx_type: u8, have_left: bool, have_above: bool) {
+    fn intra_tx(&mut self, plane: usize, x: usize, y: usize, mode: u8, tx_type: u8, edges: IntraEdges) {
         let ss = (plane > 0) as usize;
         let max_x = (self.f.mi_cols * 8) >> ss;
         let max_y = (self.f.mi_rows * 8) >> ss;
@@ -424,7 +433,7 @@ impl<'a> Tile<'a> {
             return;
         }
         let p = &self.planes[plane];
-        let pred = predict_intra(p, x, y, 2, mode, have_left, have_above);
+        let pred = predict_intra(p, x, y, 2, mode, edges);
         let mut res = [0i32; 16];
         for i in 0..4 {
             for j in 0..4 {
diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml
index b9c781bce..afc767de2 100644
--- a/xtask/Cargo.toml
+++ b/xtask/Cargo.toml
@@ -5,6 +5,9 @@ edition.workspace = true
 license.workspace = true
 publish = false
 
+[lints]
+workspace = true
+
 [dependencies]
 serde_json = { workspace = true }
 
diff --git a/xtask/src/bench.rs b/xtask/src/bench.rs
index 55bf4ee40..96d171d2f 100644
--- a/xtask/src/bench.rs
+++ b/xtask/src/bench.rs
@@ -155,7 +155,7 @@ pub fn run(root: &Path, args: &[String]) -> Result<(), String> {
                 } else {
                     eprintln!("bench: {} {kind}", sc.name);
                 }
-                let row = measure(&bin, &dir, sc, kind, frames, &o.format, timer, &ctx)?;
+                let row = measure(&bin, &dir, sc, &Measurement { kind, frames, format: &o.format }, timer, &ctx)?;
                 append_line(&results_path, &row)?;
                 rows.push(row);
             }
@@ -679,8 +679,14 @@ fn utc(t: u64) -> String {
     format!("{y:04}-{}", dt.format("%m-%d %H:%M UTC"))
 }
 
-#[allow(clippy::too_many_arguments)]
-fn measure(bin: &Path, dir: &Path, sc: &Scenario, kind: &str, frames: u32, format: &str, timer: TimeTool, ctx: &RunContext) -> Result {
+struct Measurement<'a> {
+    kind: &'a str,
+    frames: u32,
+    format: &'a str,
+}
+
+fn measure(bin: &Path, dir: &Path, sc: &Scenario, measurement: &Measurement<'_>, timer: TimeTool, ctx: &RunContext) -> Result {
+    let Measurement { kind, frames, format } = *measurement;
     let out_dir = dir.join("out").join(format!("{}-{kind}", sc.name.replace(['/', '\\', ' '], "_")));
     let _ = std::fs::remove_dir_all(&out_dir);
     std::fs::create_dir_all(&out_dir).map_err(|e| format!("{}: {e}", out_dir.display()))?;