diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml index e78fe1ea3..8f1aa5ad5 100644 --- a/.github/workflows/sonarcloud.yml +++ b/.github/workflows/sonarcloud.yml @@ -16,6 +16,9 @@ concurrency: jobs: sonarcloud: - # Tracked at @main so org-wide CI improvements propagate without PR churn. + # Track shared CI improvements on main. uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main + with: + timeout-minutes: 120 + fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83 secrets: inherit diff --git a/apps/effectcraft-cli/tests/it/cli.rs b/apps/effectcraft-cli/tests/it/cli.rs index 538887d9b..c7a905e58 100644 --- a/apps/effectcraft-cli/tests/it/cli.rs +++ b/apps/effectcraft-cli/tests/it/cli.rs @@ -7,7 +7,7 @@ use std::process::{Command, Stdio}; use serde_json::{Value, json}; fn bin() -> Command { - Command::new(env!("CARGO_BIN_EXE_effectcraft-cli")) + Command::new(crate::binary()) } /// Run with `--json`; returns (exit code, parsed stdout). diff --git a/apps/effectcraft-cli/tests/it/main.rs b/apps/effectcraft-cli/tests/it/main.rs index 17149f545..bca36ec78 100644 --- a/apps/effectcraft-cli/tests/it/main.rs +++ b/apps/effectcraft-cli/tests/it/main.rs @@ -2,3 +2,8 @@ mod cli; mod qa_template; + +/// Nextest archives relocate binaries; plain Cargo keeps the compile-time path valid. +fn binary() -> std::ffi::OsString { + std::env::var_os("NEXTEST_BIN_EXE_effectcraft_cli").unwrap_or_else(|| env!("CARGO_BIN_EXE_effectcraft-cli").into()) +} diff --git a/apps/effectcraft-cli/tests/it/qa_template.rs b/apps/effectcraft-cli/tests/it/qa_template.rs index 0618c0d63..8cb533fda 100644 --- a/apps/effectcraft-cli/tests/it/qa_template.rs +++ b/apps/effectcraft-cli/tests/it/qa_template.rs @@ -9,7 +9,7 @@ use std::process::Command; use serde_json::Value; fn cli(dir: &PathBuf, args: &[&str]) -> (i32, Value) { - let out = Command::new(env!("CARGO_BIN_EXE_effectcraft-cli")).current_dir(dir).args(args).arg("--json").output().unwrap(); + let out = Command::new(crate::binary()).current_dir(dir).args(args).arg("--json").output().unwrap(); let stdout = String::from_utf8_lossy(&out.stdout).to_string(); let v = serde_json::from_str(stdout.trim()).unwrap_or_else(|e| panic!("{args:?}: {e}: {stdout} {}", String::from_utf8_lossy(&out.stderr))); (out.status.code().unwrap_or(-1), v) @@ -191,7 +191,7 @@ 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(env!("CARGO_BIN_EXE_effectcraft-cli")).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)); } @@ -251,7 +251,7 @@ fn assert_template_output_preserves_inputs(dir: &PathBuf, out: &str) -> Value { let template_before = std::fs::read(dir.join("lt.ectemplate")).unwrap(); let values_before = std::fs::read(dir.join("values.json")).unwrap(); // Read the inputs before interpreting the subprocess status or error text. - let result = Command::new(env!("CARGO_BIN_EXE_effectcraft-cli")) + let result = Command::new(crate::binary()) .current_dir(dir) .args(["render-frame", "--template", "lt.ectemplate", "--values", "values.json", "--time", "1", "--out", out, "--json"]) .output() @@ -527,7 +527,7 @@ fn template_unknown_control_does_not_replace_extracted_media() { let invalid_values_before = std::fs::read(dir.join("bad-values.json")).unwrap(); // Capture every file before interpreting status/JSON, so a failed command cannot hide a write. - let result = Command::new(env!("CARGO_BIN_EXE_effectcraft-cli")) + let result = Command::new(crate::binary()) .current_dir(&dir) .args(["render-frame", "--template", "lt.ectemplate", "--values", "bad-values.json", "--time", "1", "--out", "failed.png", "--json"]) .output() @@ -623,11 +623,8 @@ fn template_missing_output_does_not_replace_extracted_media() { assert_ne!(sentinel_before, source_before); // Observe writes before interpreting the actual CLI usage exit. - let result = Command::new(env!("CARGO_BIN_EXE_effectcraft-cli")) - .current_dir(&dir) - .args(["render", "--template", "lt.ectemplate", "--values", "values.json", "--json"]) - .output() - .unwrap(); + let result = + Command::new(crate::binary()).current_dir(&dir).args(["render", "--template", "lt.ectemplate", "--values", "values.json", "--json"]).output().unwrap(); let template_after = std::fs::read(dir.join("lt.ectemplate")).unwrap(); let values_after = std::fs::read(dir.join("values.json")).unwrap(); let source_after = std::fs::read(dir.join("source.png")).unwrap(); diff --git a/apps/effectcraft-web/src/app.rs b/apps/effectcraft-web/src/app.rs index f09bf28a1..169529e5d 100644 --- a/apps/effectcraft-web/src/app.rs +++ b/apps/effectcraft-web/src/app.rs @@ -86,6 +86,8 @@ pub struct WebApp { } impl eframe::App for WebApp { + /// Take dropped files out of `raw_input` before eframe can replay them on a hidden tab, read them asynchronously + /// through `open_or_import`, then run the shared app's input hook. fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) { // Files dropped on the page are input: read them here, where the browser's drop event is // still the current pass's, not from a later (possibly logic-only) tick. @@ -101,6 +103,9 @@ impl eframe::App for WebApp { self.app.raw_input_hook(ctx, raw_input); } + /// The browser host's logic tick: run queued UI actions, tick the shared app, poll API replies, flush Render + /// Queue downloads, persist the session snapshot when enabled, publish GPU/frame-worker/disk-cache info and close + /// the IME pass. fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) { let actions = ACTIONS.with(|a| std::mem::take(&mut *a.borrow_mut())); for f in actions { diff --git a/apps/effectcraft-web/src/lib.rs b/apps/effectcraft-web/src/lib.rs index 0d59906f2..97d2e887f 100644 --- a/apps/effectcraft-web/src/lib.rs +++ b/apps/effectcraft-web/src/lib.rs @@ -6,17 +6,17 @@ //! //! | Desktop | Web | //! |---|---| -//! | file system (`FsServices`, media reads, export writes) | a virtual file table ([`files`]) persisted to the Origin Private File System / IndexedDB ([`persist`], [`store`]) | +//! | file system (`FsServices`, media reads, export writes) | a virtual file table (`files`) persisted to the Origin Private File System / IndexedDB (`persist`, [`store`]) | //! | config directory (settings, shortcuts, recent projects, auto-saves) | the same store ([`store::WebConfig`]) | //! | rfd file dialogs | `` pickers; drop files on the page | -//! | Media Browser on the file system | browser storage and folders opened with the File System Access API ([`browse`]) | +//! | Media Browser on the file system | browser storage and folders opened with the File System Access API (`browse`) | //! | written files (Save, Render Queue) | browser downloads (several render files as one `.zip`) | -//! | frame render threads | frame workers, each with a project replica fed by diffs and its own WebGPU device ([`frames`]); `Frames::pump` on the UI thread without workers | -//! | disk cache folder | frames in the Origin Private File System, written by the frame workers ([`diskcache`]) | -//! | (none) | the storage manager: usage, quota, persistence, clearing ([`storage`]) | -//! | Render Queue / analysis threads | Web Workers running their own engine instance ([`worker`]) | -//! | cpal audio output | Web Audio: an AudioWorklet fed from the preview mixdown ([`audio`]) | -//! | TCP control channel / MCP | `window.effectcraft` JavaScript API ([`api`]) | +//! | frame render threads | frame workers, each with a project replica fed by diffs and its own WebGPU device (`frames`); `Frames::pump` on the UI thread without workers | +//! | disk cache folder | frames in the Origin Private File System, written by the frame workers (`diskcache`) | +//! | (none) | the storage manager: usage, quota, persistence, clearing (`storage`) | +//! | Render Queue / analysis threads | Web Workers running their own engine instance (`worker`) | +//! | cpal audio output | Web Audio: an AudioWorklet fed from the preview mixdown (`audio`) | +//! | TCP control channel / MCP | `window.effectcraft` JavaScript API (`api`) | //! //! [`store`] is portable (unit-tested natively); everything else is `wasm32`-only. diff --git a/apps/effectcraft/src/main.rs b/apps/effectcraft/src/main.rs index 5e3e14c65..6df015290 100644 --- a/apps/effectcraft/src/main.rs +++ b/apps/effectcraft/src/main.rs @@ -148,6 +148,10 @@ fn install_file_dialogs(app: &mut EffectcraftApp) -> Option eframe::Result { // Windows: the update helper is a copy of this program started with `--finish-update`. #[cfg(windows)] @@ -451,6 +455,7 @@ struct Desktop { } impl eframe::App for Desktop { + /// The desktop host's logic tick: update the macOS menu bar, run the shared app tick and close the IME pass. fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) { #[cfg(target_os = "macos")] self.menu.update(&mut self.app, ctx); diff --git a/apps/effectcraft/tests/args.rs b/apps/effectcraft/tests/args.rs index 8367d2a99..41281eb23 100644 --- a/apps/effectcraft/tests/args.rs +++ b/apps/effectcraft/tests/args.rs @@ -6,6 +6,11 @@ use std::path::PathBuf; use std::process::{Command, Stdio}; use std::time::{Duration, Instant}; +/// Nextest archives relocate binaries; plain Cargo keeps the compile-time path valid. +fn binary() -> std::ffi::OsString { + std::env::var_os("NEXTEST_BIN_EXE_effectcraft").unwrap_or_else(|| env!("CARGO_BIN_EXE_effectcraft").into()) +} + /// A settings folder of its own, so a launch that got past the arguments would show up as /// preferences (Recent Footage) written there instead of in the user's settings. fn config_dir(name: &str) -> PathBuf { @@ -19,7 +24,7 @@ fn config_dir(name: &str) -> PathBuf { /// regression fails instead of leaving a window open: the process is killed after 5 s. fn rejects(name: &str, args: &[&str], env: &[(&str, &str)], message: &str) { let config = config_dir(name); - let mut cmd = Command::new(env!("CARGO_BIN_EXE_effectcraft")); + let mut cmd = Command::new(binary()); cmd.args(args).env_remove("EFFECTCRAFT_CONTROL_PORT").env_remove("RUST_LOG").env("EFFECTCRAFT_CONFIG_DIR", &config); for (k, v) in env { cmd.env(k, v); @@ -67,7 +72,7 @@ fn a_bad_control_port_stops_the_launch() { #[test] fn version_still_prints_with_a_bad_port_variable() { - let out = Command::new(env!("CARGO_BIN_EXE_effectcraft")).arg("--version").env("EFFECTCRAFT_CONTROL_PORT", "abc").output().unwrap(); + let out = Command::new(binary()).arg("--version").env("EFFECTCRAFT_CONTROL_PORT", "abc").output().unwrap(); assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); assert_eq!(String::from_utf8(out.stdout).unwrap(), format!("effectcraft {}\n", env!("CARGO_PKG_VERSION"))); } diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index 0be2b04f5..80a937b22 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -92,7 +92,7 @@ const CPU_ONLY_CONTROLS: &[(&str, &[&str])] = &[ ]; /// Whether the GPU kernel of effect `id` covers the instance's current settings (see -/// [`CPU_ONLY_CONTROLS`]; plus Cell Pattern's HQ variants and Turbulent Displace's locked +/// `CPU_ONLY_CONTROLS`; plus Cell Pattern's HQ variants and Turbulent Displace's locked /// pinning). pub fn gpu_supported(id: &str, ctx: &crate::EffectCtx) -> bool { match id { diff --git a/crates/effects/src/channel3d.rs b/crates/effects/src/channel3d.rs index 39a1c49b1..ea194f6da 100644 --- a/crates/effects/src/channel3d.rs +++ b/crates/effects/src/channel3d.rs @@ -359,7 +359,7 @@ pub(crate) fn selection_names(sel: &str) -> Vec { out } -/// Parse a selection: comma-separated names (optionally quoted, see [`selection_names`]) or +/// Parse a selection: comma-separated names (optionally quoted, see `selection_names`) or /// `` hashes / wildcards. pub fn selection_hashes(sel: &str, manifest: Option<&[(String, u32)]>) -> Vec { let mut out = vec![]; diff --git a/crates/effects/src/color_fx.rs b/crates/effects/src/color_fx.rs index 402d0ebe2..06884017c 100644 --- a/crates/effects/src/color_fx.rs +++ b/crates/effects/src/color_fx.rs @@ -25,7 +25,7 @@ pub(crate) fn luma_601(c: [f32; 3]) -> f32 { LUMA_601[0] * c[0] + LUMA_601[1] * c[1] + LUMA_601[2] * c[2] } -/// The Rec. 601 luma weights of [`luma_601`]. The WGSL kernels repeat them as literals; the GPU +/// The Rec. 601 luma weights of `luma_601`. The WGSL kernels repeat them as literals; the GPU /// test `shader_constants_match_the_cpu` checks that they are these. pub const LUMA_601: [f32; 3] = [0.299, 0.587, 0.114]; @@ -384,7 +384,7 @@ pub fn mirror_pow(v: f32, p: f32) -> f32 { /// back toward the gray). After Effects 2026 with Tint Color [1, 0.5, 0]: gray 0.1 (a red solid /// at Reds 10, case 717) → (43, 21, 0), gray 0.6 (yellow at defaults, case 825) → (255, 129, 2), /// white → white; the old hue / saturation / lightness mix gave (51, 26, 0) for the first. -/// The luminosity weights of [`bw_tint`] (a Color blend's). `fx_color.wgsl` repeats them; the GPU +/// The luminosity weights of `bw_tint` (a Color blend's). `fx_color.wgsl` repeats them; the GPU /// test `shader_constants_match_the_cpu` checks that they are these. pub const BW_TINT_LUMA: [f32; 3] = [0.3, 0.59, 0.11]; @@ -755,7 +755,7 @@ impl ColoramaPalette { } } - /// Parse "pos:r,g,b[,a] pos:r,g,b …" (positions 0..1, channels 0..1). `None` without stops. + /// Parse "pos:r,g,b\[,a\] pos:r,g,b …" (positions 0..1, channels 0..1). `None` without stops. pub fn parse(s: &str) -> Option { let mut stops: Vec<(f32, [f32; 4])> = s .split_whitespace() diff --git a/crates/effects/src/generate2.rs b/crates/effects/src/generate2.rs index 80d32ce0e..fcb05d6e2 100644 --- a/crates/effects/src/generate2.rs +++ b/crates/effects/src/generate2.rs @@ -61,7 +61,7 @@ const CELL_PATTERNS: [&str; 12] = [ "Tubular", ]; -/// Shaping used for Cell Pattern option `i` (see [`cell_value`]). +/// Shaping used for Cell Pattern option `i` (see `cell_value`). pub fn pattern_kind(i: u32) -> u32 { match i { 0..=5 => i, diff --git a/crates/effects/src/generate3.rs b/crates/effects/src/generate3.rs index 6beec4cb7..9e22ac8ae 100644 --- a/crates/effects/src/generate3.rs +++ b/crates/effects/src/generate3.rs @@ -147,8 +147,8 @@ pub(crate) fn paint(img: &mut Image, cov: &Plane, color: [f32; 4], opacity: f32, }); } -/// Coverage of a plan-drawn generator: round-capped segments rasterised by [`raster_segs`] -/// with `hardness`, or the union of convex polygons ([`raster_convex`]). +/// Coverage of a plan-drawn generator: round-capped segments rasterised by `raster_segs` +/// with `hardness`, or the union of convex polygons (`raster_convex`). #[derive(Clone, Debug)] pub enum Coverage { Segs { segs: Vec, hardness: f64 }, @@ -165,7 +165,7 @@ impl Coverage { } /// Stroke, Scribble, Vegas and Write-on, shared with the GPU compositor (effectcraft-gpu -/// `fx_gen2`): the coverage and how it is painted. `style` 0–2 as [`paint`] (On Original +/// `fx_gen2`): the coverage and how it is painted. `style` 0–2 as `paint` (On Original /// Image, On Transparent, Reveal Original Image), 3 = under the layer (Vegas' Composite Under). #[derive(Clone, Debug)] pub struct PaintPlan { @@ -213,7 +213,7 @@ pub fn paint_plan(id: &str, ctx: &EffectCtx, b: &Buf) -> Option { } } -/// Scribble's Composite as a [`paint`] style. +/// Scribble's Composite as a `paint` style. pub fn scribble_style(ctx: &EffectCtx) -> u32 { match ctx.params.e("composite") { 0 => 0, @@ -1596,7 +1596,7 @@ fn draw_marks(b: &mut Buf, segs_core: &[Seg], thickness: f64, softness: f64, ins } /// Audio Spectrum / Audio Waveform, shared with the GPU compositor (effectcraft-gpu `fx_gen2`): -/// the marks (the audio analysis runs on the CPU) and how [`draw_marks`] colours them. +/// the marks (the audio analysis runs on the CPU) and how `draw_marks` colours them. #[derive(Clone, Debug)] pub struct MarksPlan { pub segs: Vec, diff --git a/crates/effects/src/noise3.rs b/crates/effects/src/noise3.rs index baa5fb50a..bf1f05ed6 100644 --- a/crates/effects/src/noise3.rs +++ b/crates/effects/src/noise3.rs @@ -333,7 +333,7 @@ impl Fractal { } /// Fractal / Turbulent Noise resolved for the GPU kernel (effectcraft-gpu `fx_noise`): the -/// per-pixel constants and, per octave, the terms [`Fractal::raw`] computes before touching the +/// per-pixel constants and, per octave, the terms `Fractal::raw` computes before touching the /// pixel, in the same f32 order. pub struct FractalGpu { pub kind: u32, diff --git a/crates/effects/src/ocio.rs b/crates/effects/src/ocio.rs index e84720a32..df579964f 100644 --- a/crates/effects/src/ocio.rs +++ b/crates/effects/src/ocio.rs @@ -1055,7 +1055,7 @@ pub enum ColorOp { /// Negative channels to 0 (the saturation intent). Max0, /// A LUT (0 nearest, 1 trilinear, 2 tetrahedral) with an optional per-channel shaper, forward - /// or inverse (as [`FileXform::apply`]). + /// or inverse (as `FileXform::apply`). Lut { lut: Arc, shaper: Option; 3]>>, interp: u32, inverse: bool }, /// `m × (c + pre) + post` (a custom config's MatrixTransform and RangeTransform). Affine { m: [[f32; 3]; 3], pre: [f32; 3], post: [f32; 3] }, @@ -1322,7 +1322,7 @@ fn curve3(xs: [f64; 3], ys: [f64; 3], v: f64) -> f64 { } /// Color Stabilizer's correction of buffer `b` as one piecewise-linear map per channel (points -/// with increasing x; the end segments extend), the same maps [`color_stabilizer`] applies. +/// with increasing x; the end segments extend), the same maps `color_stabilizer` applies. /// `None` without a host or reference frame (the layer passes through). (The GPU effect.) pub fn color_stabilizer_maps(ctx: &EffectCtx, b: &Buf) -> Option<[Vec<(f64, f64)>; 3]> { let reference = ctx.env.host?.self_at(ctx.params.f("referenceFrame"), ctx.env.effect_index)?; diff --git a/crates/effects/src/ocio_config.rs b/crates/effects/src/ocio_config.rs index 5ea844414..41c74b4e4 100644 --- a/crates/effects/src/ocio_config.rs +++ b/crates/effects/src/ocio_config.rs @@ -20,7 +20,7 @@ //! * `looks` (`name`, `process_space`, `transform`, `inverse_transform`) and look strings //! (`+A, -B`, `A | B` = the first one the config has); //! * transforms: `MatrixTransform` (`matrix` 4×4 row-major, `offset`), `FileTransform` (the LUT, -//! CDL and matrix files [`super::ocio::parse_file`] reads; `interpolation`, `ccc_id`), +//! CDL and matrix files `super::ocio::parse_file` reads; `interpolation`, `ccc_id`), //! `ExponentTransform` (`value`), `ExponentWithLinearTransform` (`gamma`, `offset`, `style: //! mirror`; the power curve with a linear toe that the Academy CLF specification calls //! "moncurve"), `LogTransform` (`base`), `LogAffineTransform` (`base`, `logSideSlope`, diff --git a/crates/effects/src/sim.rs b/crates/effects/src/sim.rs index 14146a9f4..1bb82f46e 100644 --- a/crates/effects/src/sim.rs +++ b/crates/effects/src/sim.rs @@ -186,7 +186,7 @@ pub enum Tint { /// How a sprite layer meets the original layer. #[derive(Clone, Copy, Debug, PartialEq)] pub enum Post { - /// [`combine`] with this mode (0 Composite … 5 sprites only). + /// `combine` with this mode (0 Composite … 5 sprites only). Combine(u32), /// The sprites alone. Replace, diff --git a/crates/effects/src/sim2.rs b/crates/effects/src/sim2.rs index cf7322bda..939c36757 100644 --- a/crates/effects/src/sim2.rs +++ b/crates/effects/src/sim2.rs @@ -1586,7 +1586,7 @@ pub struct FoamEnv { /// What Foam draws this frame over a transparent frame of the layer's size: the bubble sprites, /// then the User Defined texture's discs, the Environment Map's reflections and (Draft + Flow -/// Map) the flow map under it all, each [`foam_disc`]-composited in bubble order. +/// Map) the flow map under it all, each `foam_disc`-composited in bubble order. pub struct FoamPlan { pub sprites: crate::sim::SpritePlan, pub user: Option<(crate::LayerPixels, Vec)>, diff --git a/crates/engine/src/autosave.rs b/crates/engine/src/autosave.rs index 37127ac56..e70e819fd 100644 --- a/crates/engine/src/autosave.rs +++ b/crates/engine/src/autosave.rs @@ -143,7 +143,7 @@ pub fn write(prefs: &Prefs, project: Option<&str>, fallback: Option<&Path>, last write_in(&StdFiles, prefs, project, fallback, last_slot, json) } -/// [`write`] through `fs`. +/// [`write()`] through `fs`. pub fn write_in( fs: &dyn FileOps, prefs: &Prefs, diff --git a/crates/engine/src/camera_track.rs b/crates/engine/src/camera_track.rs index adc5aee69..29961f533 100644 --- a/crates/engine/src/camera_track.rs +++ b/crates/engine/src/camera_track.rs @@ -7,7 +7,7 @@ //! camera"). When only the solve settings changed (Shot Type, Angle of View, Solve Method, //! deleted points) the stored tracks are re-solved without re-tracking. Results are written into //! the effect's hidden parameters as one undo step; edits that change the layer's frames clear -//! them and queue the effect again ([`invalidate`], run after every edit), like After Effects. +//! them and queue the effect again (`invalidate`, run after every edit), like After Effects. //! //! Composition space: the solve's canonical frame maps to comp world by //! [`CameraSolve::world`] with the zoom of the first solved frame (focal length × the layer's diff --git a/crates/engine/src/commands/app_more.rs b/crates/engine/src/commands/app_more.rs index b95dd4375..f607c8288 100644 --- a/crates/engine/src/commands/app_more.rs +++ b/crates/engine/src/commands/app_more.rs @@ -354,7 +354,7 @@ fn assign_view_shortcut(s: &mut Session, p: &Value) -> Result { reassign(s, view_command(cur), &slot, "view.assign3dShortcut") } -/// Window ▸ Assign Shortcut to "" Workspace: Shift+F10, Shift+F11 or Shift+F12. +/// Window ▸ Assign Shortcut to `""` Workspace: Shift+F10, Shift+F11 or Shift+F12. /// The frontend fills in the current workspace. fn assign_workspace_shortcut(s: &mut Session, p: &Value) -> Result { let slot = slot_p(p, "window.assignWorkspaceShortcut", true)?; @@ -668,6 +668,9 @@ fn extract_cubemap(s: &mut Session, p: &Value) -> Result { Ok(r) } +/// The specs for the commands in this module: history steps, recent footage and presets, Save +/// a Copy As XML, footage replacement and Execute File, 3D-view and workspace shortcuts, +/// logging and the system report, VR environment/cubemap creation and System Information. pub fn specs() -> Vec { vec![ cmd!("edit.history", "History", [], None, "{steps?: n (undo n steps), redo?: n}", has_history, history), diff --git a/crates/engine/src/commands/batch.rs b/crates/engine/src/commands/batch.rs index e1fc37140..f6e6901cc 100644 --- a/crates/engine/src/commands/batch.rs +++ b/crates/engine/src/commands/batch.rs @@ -51,6 +51,17 @@ fn substitute(v: &Value, results: &[Value]) -> Result { }) } +/// `engine.batch`: runs `steps` (`[{command|id, params?}]`) as engine commands, substituting +/// `$N` / `$N.key.0` strings in each step's params with earlier results, and returns +/// `{steps, results}`. +/// +/// `engine.batch`, `file.runScript` and `script.run` are refused inside a batch. Successful +/// steps are folded into one undo step named `label` (default "Batch"). With the default +/// `atomic: true`, a failing step restores the project, history and editor state, drops the +/// events the batch published, sanitizes the editor against the failing intermediate state (so +/// no pre-batch IME token is revived) and keeps the higher process IME revision; with +/// `atomic: false` the steps before the failure stay applied. Either way the error names the +/// failing step and command. fn batch(s: &mut Session, p: &Value) -> Result { let steps = p.get("steps").and_then(Value::as_array).ok_or_else(|| bad("engine.batch", "missing `steps`: [{command, params?}, …]"))?.clone(); let label = str_p(p, "label").unwrap_or("Batch").to_string(); @@ -138,6 +149,32 @@ mod tests { use crate::Session; + /// Failed atomic steps discard their UI events while preserving events queued beforehand. + #[test] + fn atomic_batch_event_rollback_preserves_prior_events() { + for atomic in [true, false] { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "First", "width": 100, "height": 100})).unwrap(); + let first = s.active_comp_id().unwrap(); + s.execute("comp.new", json!({"name": "Second", "width": 100, "height": 100})).unwrap(); + let second = s.active_comp_id().unwrap(); + s.drain_events(); + s.execute("comp.open", json!({"comp": first.0})).unwrap(); + let result = s.execute_checked( + "engine.batch", + json!({"atomic": atomic, "steps": [ + {"command": "comp.open", "params": {"comp": second.0}}, + {"command": "no.suchCommand", "params": {}} + ]}), + ); + assert!(result.is_err()); + assert_eq!(s.active_comp_id(), Some(if atomic { first } else { second })); + let opened: Vec<_> = + s.drain_events().into_iter().filter_map(|event| if let crate::Event::OpenComp(id) = event { Some(id) } else { None }).collect(); + assert_eq!(opened, if atomic { vec![first] } else { vec![first, second] }); + } + } + #[test] fn batch_refs_one_undo_step_and_rollback() { let mut s = Session::default(); diff --git a/crates/engine/src/commands/edit.rs b/crates/engine/src/commands/edit.rs index 6a8194395..4f79e5271 100644 --- a/crates/engine/src/commands/edit.rs +++ b/crates/engine/src/commands/edit.rs @@ -149,6 +149,16 @@ fn duplicate(s: &mut Session, p: &Value) -> Result { Ok(json!(new.iter().map(|l| l.0).collect::>())) } +/// `edit.clear`: delegates to `text.delete` while editing text (deleting the selection or +/// preceding character/word), then handles selected Time Remap (turning remapping off), keyframes, loose mask +/// vertices, puppet pins, effects, masks/shape items/text animators/trackers, and finally the +/// selected (or given) unlocked layers. Layer and selected-property-group paths return the number +/// removed; text, Time Remap, keyframe, loose-vertex, puppet-pin and effect paths return their +/// delegated command's result. +/// +/// Layer removal also unparents surviving children in place, clears track mattes that pointed +/// at a removed layer and rewrites layer-reference values and keys to `None` in the same undo +/// step; the text-edit session is then sanitized so a removed layer's editor is not reused. fn delete(s: &mut Session, p: &Value) -> Result { if s.state.text_edit.is_some() && p.get("layers").is_none() { return s.execute("text.delete", json!({})); diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index 8ff43f32e..ba4bdfb4a 100644 --- a/crates/engine/src/commands/layer.rs +++ b/crates/engine/src/commands/layer.rs @@ -162,6 +162,15 @@ fn text_range_p(p: &Value) -> Option> { Some(x.min(y)..x.max(y)) } +/// `layer.newText`: adds a Text layer to the comp at the CTI and returns `{layer}`. +/// +/// The document starts as centre-justified "Text", with the optional text parameters applied. +/// With `box` `[x, y, w, h]`, `(x, y)` is the paragraph box's top-left in comp space. Its width +/// and height are `w.abs().max(1.0)` and `h.abs().max(1.0)`; the layer position is the box centre. +/// It is left justified unless `justify` is given. The layer is named by `name`, else the first 40 +/// characters of the first text line, else "Text". `edit: true` also opens a new editing +/// session with anchor `0` and the caret after the text, selecting all initial text so the next +/// insertion replaces it. The session has `created: true`, so leaving it empty removes the layer. fn new_text(s: &mut Session, p: &Value) -> Result { let cid = comp_id(s, p)?; let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone(); @@ -1454,6 +1463,15 @@ fn shape_item_target(proj: &mut Project, cid: ItemId, lid: LayerId, group_uid: O }) } +/// `layer.setText`: sets a text layer's Source Text and/or character attributes at the CTI. +/// +/// A `font` name is also added to the recent fonts and the preferences saved. With an empty +/// `range` and no `text`, while that layer is being edited and its document is non-empty, +/// recognised character attributes are also staged in the pending insertion style without +/// changing existing characters. Paragraph/document attributes still apply through the edit; +/// the session is bumped whenever this branch runs. Document changes use [`Session::edit`], +/// merged by `merge_p`. With `text` given, a layer whose name is empty or starts with `Text` +/// takes the new first line (up to 40 characters) when that line is non-empty. Replies `null`. fn set_text(s: &mut Session, p: &Value) -> Result { let (cid, lid) = layer_p(s, p, "layer.setText")?; // The Character panel's recent fonts (Settings ▸ Type ▸ Number of Recent Fonts to Display). diff --git a/crates/engine/src/commands/scripts.rs b/crates/engine/src/commands/scripts.rs index 24049459c..d352e636c 100644 --- a/crates/engine/src/commands/scripts.rs +++ b/crates/engine/src/commands/scripts.rs @@ -234,7 +234,7 @@ fn uninstall(s: &mut Session, p: &Value) -> Result { if removed { Ok(json!({"removed": name})) } else { Err(bad(c, format!("`{name}` is not installed"))) } } -/// Run an installed or bundled script by name (File ▸ Scripts ▸ ). A ScriptUI panel run +/// Run an installed or bundled script by name (File ▸ Scripts ▸ ``). A ScriptUI panel run /// this way gets `this` = a floating palette-like panel window. pub(crate) fn run_named(s: &mut Session, name: &str) -> Result { let (panel, code) = script_code(s, name, None).ok_or_else(|| bad("file.runScript", format!("no installed or bundled script `{name}`")))?; @@ -308,7 +308,7 @@ fn restore_panels(s: &mut Session, _: &Value) -> Result { Ok(json!({"opened": opened, "failed": failed})) } -/// Window ▸ : run the panel script (or bring its panel forward). +/// Window ▸ ``: run the panel script (or bring its panel forward). fn open_panel(s: &mut Session, p: &Value) -> Result { let c = "window.scriptPanel"; let name = str_p(p, "name").ok_or_else(|| bad(c, "missing `name` (a script in the ScriptUI Panels folder)"))?.to_string(); diff --git a/crates/engine/src/commands/text_edit.rs b/crates/engine/src/commands/text_edit.rs index 9de40c588..0d9b25aec 100644 --- a/crates/engine/src/commands/text_edit.rs +++ b/crates/engine/src/commands/text_edit.rs @@ -14,6 +14,8 @@ use serde_json::{Value, json}; /// never reuses a live IME context). Exhaustion wraps; it cannot panic. static NEXT_EDIT_SESSION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); +/// Takes the next session token: returns the counter's current value and increments it, so +/// successive tokens differ until the process-wide `u64` counter wraps. pub(super) fn next_edit_session() -> u64 { NEXT_EDIT_SESSION.fetch_add(1, std::sync::atomic::Ordering::Relaxed) } @@ -27,10 +29,14 @@ pub(super) fn next_edit_session() -> u64 { /// receipt rather than claiming a mutation with a reused revision. static NEXT_IME_REVISION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); +/// Allocates a fresh process-wide IME revision, or `None` once the counter has nowhere left +/// to advance; callers keep their existing stamp rather than reuse one. pub(crate) fn next_ime_revision() -> Option { allocate_ime_revision(&NEXT_IME_REVISION) } +/// Takes the next value of `counter` with a compare-exchange loop: `Some(current + 1)` after +/// storing it, or `None` without storing when the counter is already `u64::MAX`. fn allocate_ime_revision(counter: &std::sync::atomic::AtomicU64) -> Option { use std::sync::atomic::Ordering::Relaxed; let mut current = counter.load(Relaxed); @@ -85,6 +91,8 @@ pub struct TextEdit { } impl Default for TextEdit { + /// An empty, uncreated editing session on layer `0` with no selection, pending style or goal + /// column, and a fresh process-wide session token. fn default() -> Self { Self { layer: LayerId(0), anchor: 0, caret: 0, pending: None, created: false, goal_x: None, session: next_edit_session() } } @@ -909,6 +917,8 @@ fn editing(s: &Session) -> std::result::Result<(), String> { if s.state.text_edit.is_some() { Ok(()) } else { Err("not editing text".into()) } } +/// The command specs of this module: the `text.*` editing, selection and caret commands and +/// the two `edit.pasteText*` commands, each with its parameter shape and enablement predicate. pub fn specs() -> Vec { vec![ cmd!( diff --git a/crates/engine/src/doc_path.rs b/crates/engine/src/doc_path.rs index 00d6c33f1..7161b6177 100644 --- a/crates/engine/src/doc_path.rs +++ b/crates/engine/src/doc_path.rs @@ -3,7 +3,7 @@ //! document's folder, and only local files, or files on the network share the document itself is //! on, are followed (see `craft_docpath`). //! -//! Loading a document resolves its footage paths once ([`resolve_footage`]): followed paths are +//! Loading a document resolves its footage paths once (`resolve_footage`): followed paths are //! replaced by the resolved path, and an item with a refused path is marked missing, so the media //! layer never reads it. The session then remembers which paths came from which document //! ([`StoredPaths`]); the footage check, the changed-on-disk scan, Reload Footage, Relink, Edit diff --git a/crates/engine/src/history.rs b/crates/engine/src/history.rs index d9577285b..6e3d928c5 100644 --- a/crates/engine/src/history.rs +++ b/crates/engine/src/history.rs @@ -314,6 +314,9 @@ impl Session { } // Every state off the new line goes into branches: a chain per first child, the other // children starting branches of their own. + /// Adds to `out` one [`Branch`] for the off-line subtree at `start`: it follows the first + /// child that is not on the new history line, and records every other such child as a further + /// branch from the state it forked at (`parent`). fn chain(a: &Arena, start: usize, parent: usize, in_new: &[bool], out: &mut Vec) { let mut steps = vec![]; let mut n = start; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 83024dd81..3c3cb868d 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -564,6 +564,10 @@ pub type ScriptRunner = fn(&mut Session, &ScriptRequest) -> Value; pub type PluginLoader = fn(&[u8], &str) -> std::result::Result; impl Default for Session { + /// A new unsaved session: a default project that is both current and saved, no path, no + /// active jobs, the plain `FsServices`/`NoFootage` defaults, default preferences and keymaps, + /// empty history, events and journal, and a fresh process-wide IME stamp (`u64::MAX` when the + /// allocator is exhausted). fn default() -> Self { let project = Arc::new(Project::default()); Session { @@ -692,6 +696,8 @@ impl Session { self.script_calls.0 > 0 } + /// Whether `id` names a known command whose `enabled` predicate returns `Ok` for this session; + /// an unknown id or a predicate error reads as disabled. pub fn is_enabled(&self, id: &str) -> bool { commands::find(id).is_some_and(|c| (c.enabled)(self).is_ok()) } @@ -715,6 +721,15 @@ impl Session { self.edit_inner(label, merge, f) } + /// The shared body of [`Session::edit`] and [`Session::edit_text_doc`]. + /// + /// Runs `f` on a clone of the project and editor state, then synchronizes derived data (global + /// light and Essential Graphics), queues invalidated Warp Stabilizer/camera-tracker work and + /// Roto Brush instances needing re-propagation in the `*_pending` lists. If the clone has the same + /// content the project and history are left as they were (only editor-state changes from `f` + /// are kept) and no revision is bumped; otherwise the previous project is recorded as an + /// undo step under `label`, unless it merges with the current merge key (then the redo branch + /// is dropped). An `Err` from `f` propagates with the session unchanged. fn edit_inner(&mut self, label: &str, merge: Option<&str>, f: impl FnOnce(&mut Project, &mut EditorState) -> Result) -> Result { let before = self.project.clone(); let mut p = (*self.project).clone(); @@ -845,6 +860,10 @@ impl Session { } } + /// Undoes one step: the current project goes onto the redo stack, per-comp Resolution is kept + /// as it was, the merge key is cleared, selections are sanitized for the replaced state (the + /// on-canvas editor gets a new IME epoch only when its own document changed) and the session is + /// bumped. Returns `false`, changing nothing, when there is nothing to undo. pub fn undo(&mut self) -> bool { let Some((label, p)) = self.history.undo.pop() else { return false }; let edited = commands::text_edit::active_editor_snapshot(self); @@ -857,6 +876,10 @@ impl Session { true } + /// Redoes one step: the current project goes back onto the undo stack, per-comp Resolution is + /// kept, the merge key is cleared, selections are sanitized for the replaced state (a changed + /// text document starts a new IME epoch) and the session is bumped. Returns `false`, changing + /// nothing, when there is nothing to redo. pub fn redo(&mut self) -> bool { let Some((label, p)) = self.history.redo.pop() else { return false }; let edited = commands::text_edit::active_editor_snapshot(self); @@ -996,6 +1019,10 @@ impl Session { commands::text_edit::after_external_change(self, edited.as_ref()); } + /// Sets the time of every comp related to `comp` through precomp layers: down into the comps + /// it nests (through each layer's timing) and up into the comps nesting it. A seen-set keeps + /// cycles and diamonds finite; each found time is clamped to its comp's last frame, snapped to + /// the nearest frame and stored in `state.times`. The caller owns the text-editor boundary. fn sync_related_times_inner(&mut self, comp: ItemId, t: Tick) { let mut seen = std::collections::BTreeSet::from([comp]); let mut todo = vec![(comp, t)]; diff --git a/crates/engine/src/remote.rs b/crates/engine/src/remote.rs index 7d592abe2..66417ccee 100644 --- a/crates/engine/src/remote.rs +++ b/crates/engine/src/remote.rs @@ -9,7 +9,7 @@ //! added, changed or removed; arrays element-wise when their length is unchanged, else //! replaced). Lossless (unlike JSON Merge Patch, `null` values survive). //! - [`Mirror`] (page side): what the worker holds; turns the next project revision into a -//! [`FrameMsg::Sync`] (a full project the first time, a diff afterwards). +//! [`FrameMsg::Project`] the first time and [`FrameMsg::Patch`] for subsequent diffs. //! - [`FrameServer`] (worker side): applies syncs, relayed Roto Brush segmentations and render //! requests, and renders frames as premultiplied RGBA8 ([`rgba8_premultiplied`]). //! @@ -301,7 +301,7 @@ impl FrameServer { } /// Identity of virtual footage files, used when native metadata cannot describe the path. - /// Called from [`FrameServer::load`], not from the UI thread's cache lookup. + /// Called from `FrameServer::load`, not from the UI thread's cache lookup. pub fn set_virtual_id(&mut self, id: Option Option<(u64, u64)> + Send + Sync>>) { self.virtual_id = id; } diff --git a/crates/engine/src/roto.rs b/crates/engine/src/roto.rs index 49f53d39b..d9ce6e5af 100644 --- a/crates/engine/src/roto.rs +++ b/crates/engine/src/roto.rs @@ -6,7 +6,7 @@ //! `wait`; in the browser the background is a Web Worker whose segmentations stream back, //! [`crate::offload`]), frame by frame outward from the base frame, rendering the effect's input //! (`Renderer::layer_input`, source → masks → the effects above it). `roto.freeze` runs the same -//! pass and stores the final mattes in the effect (one undo step). [`sync`], run after every +//! pass and stores the final mattes in the effect (one undo step). `sync`, run after every //! edit, keeps each instance's hidden Input Key equal to a signature of what its frames are made //! of (the layer's source and timing, its masks and the effects above it) and drops frozen //! mattes made from other frames. diff --git a/crates/engine/src/sequence.rs b/crates/engine/src/sequence.rs index c98aba1d3..46dca154c 100644 --- a/crates/engine/src/sequence.rs +++ b/crates/engine/src/sequence.rs @@ -20,7 +20,7 @@ use crate::{Importer, Services}; /// How an import treats numbered stills. #[derive(Clone, Copy, Debug)] pub struct SequenceOptions { - /// The " Sequence" checkbox: picked numbered stills of one run import as a sequence. + /// The `" Sequence"` checkbox: picked numbered stills of one run import as a sequence. pub sequence: bool, /// The numbered stills inside a picked or dropped folder import as sequences. pub folders: bool, @@ -44,7 +44,7 @@ impl SequenceOptions { /// `file.import`'s options. Dropped files (`drag: true`) import as stills, as in After /// Effects, while a folder's numbered stills import as sequences unless `sequence: false` - /// (Alt-drag of a folder). The Import dialog passes its " Sequence" checkbox (off + /// (Alt-drag of a folder). The Import dialog passes its `" Sequence"` checkbox (off /// unless ticked) as `sequence`. Without `sequence` or `drag` (scripts, agents) a numbered /// still brings its run, as it always has. pub fn for_import(p: &serde_json::Value) -> SequenceOptions { diff --git a/crates/engine/src/vector.rs b/crates/engine/src/vector.rs index 3c349a7df..324d3a82a 100644 --- a/crates/engine/src/vector.rs +++ b/crates/engine/src/vector.rs @@ -160,7 +160,7 @@ fn clipped_items(ids: &mut Ids, s: &effectcraft_svg::Shape, m: Affine, clips: &[ /// Blend Mode). Top of the paint order first. /// /// Clipping groups (PDF / Illustrator clipping masks): shapes inside them are intersected with -/// the clip paths through Merge Paths ▸ Intersect (see [`clipped_items`]). Clips enclosing the +/// the clip paths through Merge Paths ▸ Intersect (see `clipped_items`). Clips enclosing the /// whole document are layer masks instead ([`vector_clip_masks`], used by /// [`shapes_from_vector`]). Images are skipped (Layer ▸ Create makes them footage layers, see /// [`split_at_images`]); soft masks are not converted. diff --git a/crates/engine/src/warp.rs b/crates/engine/src/warp.rs index 7928a5cab..5472d4e8c 100644 --- a/crates/engine/src/warp.rs +++ b/crates/engine/src/warp.rs @@ -7,7 +7,7 @@ //! stabilization plan once to validate the result. When the job finishes, the analysis is //! written into the effect's hidden Analysis parameter as one undo step, together with a key of //! what it was made from (the layer's source, In/Out, start, stretch and Time Remap). Edits that -//! change those clear the analysis ([`invalidate`], run after every edit) and queue the effect +//! change those clear the analysis (`invalidate`, run after every edit) and queue the effect //! for re-analysis, which frontends start with [`Session::poll_warp`] (After Effects re-analyses //! automatically too). diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index eda979958..9af195204 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -13,7 +13,7 @@ //! | AV1 | MP4 (`av01`) | `effectcraft-av1enc` Main 8/10-bit 4:2:0, key + inter frames | no | AAC-LC (FilmCraft) | //! | WAV / AIFF | RIFF WAVE / AIFF (AIFF-C for float) | — | — | PCM 16/24-bit or 32-bit float, mono/stereo (audio only) | //! -//! Every frame goes through [`pipeline`]: the Render Settings overrides (Effects, Solo Switches, +//! Every frame goes through the internal `pipeline` module: the Render Settings overrides (Effects, Solo Switches, //! Guide Layers, Frame Blending, Motion Blur, Color Depth), field rendering (two fields half a //! frame apart woven line by line, with optional 3:2 pulldown from film frames at 4/5 of the //! rate), then the Output Module's Crop (edges or the region of interest) and Resize. Channels diff --git a/crates/expr/src/rewrite.rs b/crates/expr/src/rewrite.rs index 6b0ea539f..35c373bb4 100644 --- a/crates/expr/src/rewrite.rs +++ b/crates/expr/src/rewrite.rs @@ -581,7 +581,7 @@ fn push_piece(out: &mut String, p: Piece) { /// rewrite (which nests a call per arithmetic operator). The JavaScript parser and compiler /// recurse per level and a 2 MiB thread overflows its stack (aborting the app) at about 75 /// nested brackets, so deeper expressions are rejected with an error instead. Constructs that -/// nest without brackets count as part of a level ([`too_nested`]). +/// nest without brackets count as part of a level (`too_nested`). pub const MAX_NESTING: usize = 32; /// Hard limit on bytes emitted by the array-math source rewrite. pub const MAX_REWRITTEN_BYTES: usize = 4 * 1024 * 1024; @@ -608,7 +608,7 @@ const HEAVY: usize = 32; /// about 3 800 links). const LIGHT: usize = 6; -/// One bracket level of [`too_nested`]'s walk. +/// One bracket level of `too_nested`'s walk. #[derive(Default)] struct Level { /// `[`: commas separate elements (a loop in the parser), not operands. @@ -710,7 +710,7 @@ fn too_nested(toks: &[Tok]) -> bool { too_nested_from(toks, 0) } -/// [`too_nested`] for code that already sits `base` deep (a template substitution). +/// `too_nested` for code that already sits `base` deep (a template substitution). fn too_nested_from(toks: &[Tok], base: usize) -> bool { let mut levels = vec![Level::default()]; let mut prefix = 0usize; diff --git a/crates/gpu/src/context.rs b/crates/gpu/src/context.rs index 52d27afe3..122bd53f4 100644 --- a/crates/gpu/src/context.rs +++ b/crates/gpu/src/context.rs @@ -351,6 +351,7 @@ pub struct GpuContext { pub queue: wgpu::Queue, pub(crate) name: String, pub(crate) max_dim: u32, + pub(crate) median_kernel: crate::fx_noise::MedianKernel, bgl: wgpu::BindGroupLayout, bgl_ext: wgpu::BindGroupLayout, pipelines: HashMap<&'static str, wgpu::ComputePipeline>, @@ -481,6 +482,9 @@ impl GpuContext { /// [`Self::device_failure_notifier`] for immediate readback cancellation. pub fn new(adapter: &wgpu::Adapter, device: wgpu::Device, queue: wgpu::Queue) -> Result { let info = adapter.get_info(); + let median_kernel = crate::fx_noise::MedianKernel::for_adapter(info.backend, info.device_type); + #[cfg(test)] + let median_kernel = median_kernel.test_override()?; if !adapter.get_downlevel_capabilities().flags.contains(wgpu::DownlevelFlags::COMPUTE_SHADERS) { return Err(format!("{} ({:?}): no compute shaders", info.name, info.backend)); } @@ -500,6 +504,7 @@ impl GpuContext { ("max_compute_workgroup_size_x", l.max_compute_workgroup_size_x, 64), ("max_compute_workgroup_size_y", l.max_compute_workgroup_size_y, 16), ("max_compute_invocations_per_workgroup", l.max_compute_invocations_per_workgroup, 256), + ("max_compute_workgroup_storage_size", l.max_compute_workgroup_storage_size, median_kernel.workgroup_storage()), ]; if let Some((limit, have, need)) = needs.iter().find(|(_, have, need)| have < need) { return Err(format!("{} ({:?}): device limit {limit} is {have}, needs {need}", info.name, info.backend)); @@ -525,6 +530,7 @@ impl GpuContext { include_str!("shaders/fx_key.wgsl"), include_str!("shaders/fx_stylize.wgsl"), include_str!("shaders/fx_noise.wgsl"), + median_kernel.source(), include_str!("shaders/fx_tone.wgsl"), include_str!("shaders/fx_warp.wgsl"), include_str!("shaders/fx_extra.wgsl"), @@ -706,6 +712,7 @@ impl GpuContext { queue, name, max_dim, + median_kernel, bgl, bgl_ext, pipelines, diff --git a/crates/gpu/src/fx_noise.rs b/crates/gpu/src/fx_noise.rs index 1a20aa453..b954f8cf3 100644 --- a/crates/gpu/src/fx_noise.rs +++ b/crates/gpu/src/fx_noise.rs @@ -73,6 +73,53 @@ pub(crate) const IDS: &[&str] = &[ /// not its square. pub(crate) const MEDIAN_BISECT_R: usize = 4; +/// Physical GPUs retain the original parallel private histogram. Software Vulkan +/// uses bounded workgroup storage to avoid its expensive first-use private-array +/// lowering. This trades some warmed software throughput for bounded first use. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum MedianKernel { + Private64, + Software2, +} + +impl MedianKernel { + pub(crate) fn for_adapter(backend: wgpu::Backend, device_type: wgpu::DeviceType) -> Self { + if backend == wgpu::Backend::Vulkan && device_type == wgpu::DeviceType::Cpu { Self::Software2 } else { Self::Private64 } + } + + pub(crate) fn rows_per_workgroup(self) -> u32 { + match self { + Self::Private64 => 64, + Self::Software2 => 2, + } + } + + pub(crate) fn workgroup_storage(self) -> u32 { + match self { + Self::Private64 => 0, + Self::Software2 => 16_384, + } + } + + pub(crate) fn source(self) -> &'static str { + match self { + Self::Private64 => include_str!("shaders/median_private.wgsl"), + Self::Software2 => include_str!("shaders/median_software.wgsl"), + } + } + + /// Separate test processes can qualify both implementations against existing oracles. + #[cfg(test)] + pub(crate) fn test_override(self) -> Result { + match std::env::var("EFFECTCRAFT_TEST_MEDIAN_KERNEL") { + Err(std::env::VarError::NotPresent) => Ok(self), + Ok(value) if value == "private64" => Ok(Self::Private64), + Ok(value) if value == "software2" => Ok(Self::Software2), + value => Err(format!("invalid EFFECTCRAFT_TEST_MEDIAN_KERNEL {value:?}; expected private64 or software2")), + } + } +} + /// Run effect `id` (one of [`IDS`]); `None` = this parameter combination runs on the CPU. pub(crate) fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option { match id { @@ -166,7 +213,8 @@ pub(crate) fn median_image(e: &mut Enc, img: &GpuImage, r: usize, straight: bool // sliding along the rest. let seg = (4 * r as u32).clamp(32, 512).min(q.width); let out = e.scratch(q.width, q.height); - e.dispatch("fxn_median_huang", &with_u0([r as u32, seg, 0, 0]), &q, None, &out, Some(&levels), (q.height.div_ceil(64), q.width.div_ceil(seg))); + 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); out } diff --git a/crates/gpu/src/shaders/fx_noise.wgsl b/crates/gpu/src/shaders/fx_noise.wgsl index 5e68e10c1..d2f2977ad 100644 --- a/crates/gpu/src/shaders/fx_noise.wgsl +++ b/crates/gpu/src/shaders/fx_noise.wgsl @@ -82,69 +82,6 @@ fn fxn_median(@builtin(global_invocation_id) gid: vec3) { textureStore(out, p, vec4(data[level.x], data[level.y], data[level.z], data[level.w])); } -// The same median for large radii, as the CPU computes it: a sliding 512-bin histogram per -// channel (Huang). One invocation per row segment of u[0].y pixels: it fills the histogram for -// the segment's first window, then slides right one column at a time (remove the column -// leaving, add the one entering) and moves each channel's median by the counts below it. -// u[0] = (r, segment length); dispatched over (rows, segments) with 64 × 1 workgroups. -// data = the 512 CPU-rounded level / 511 values. -@compute @workgroup_size(64, 1) -fn fxn_median_huang(@builtin(global_invocation_id) gid: vec3) { - let dims = out_dims(); - let y = i32(gid.x); - let seg = i32(P.u[0].y); - let x0 = i32(gid.y) * seg; - if (y >= dims.y || x0 >= dims.x) { - return; - } - let x1 = min(x0 + seg, dims.x); - let r = i32(P.u[0].x); - let half = u32((2 * r + 1) * (2 * r + 1) / 2); - var hist: array; - for (var dy = -r; dy <= r; dy++) { - for (var dx = -r; dx <= r; dx++) { - let v = vec4(tex_get_clamped(src, x0 + dx, y + dy)); - hist[v.x] += 1u; - hist[512u + v.y] += 1u; - hist[1024u + v.z] += 1u; - hist[1536u + v.w] += 1u; - } - } - // Per channel: the median level and the count of values below it. - var med = vec4(0u); - var lt = vec4(0u); - for (var x = x0; x < x1; x++) { - if (x > x0) { - for (var dy = -r; dy <= r; dy++) { - let o = vec4(tex_get_clamped(src, x - 1 - r, y + dy)); - let n = vec4(tex_get_clamped(src, x + r, y + dy)); - for (var c = 0u; c < 4u; c++) { - hist[c * 512u + o[c]] -= 1u; - if (o[c] < med[c]) { - lt[c] -= 1u; - } - hist[c * 512u + n[c]] += 1u; - if (n[c] < med[c]) { - lt[c] += 1u; - } - } - } - } - for (var c = 0u; c < 4u; c++) { - let base = c * 512u; - while (lt[c] > half) { - med[c] -= 1u; - lt[c] -= hist[base + med[c]]; - } - while (lt[c] + hist[base + med[c]] <= half) { - lt[c] += hist[base + med[c]]; - med[c] += 1u; - } - } - textureStore(out, vec2(x, y), vec4(data[med.x], data[med.y], data[med.z], data[med.w])); - } -} - // noise::median_px. fn fxn_median_px(o: vec4, m: vec4, on_alpha: bool) -> vec4 { if (on_alpha) { diff --git a/crates/gpu/src/shaders/median_private.wgsl b/crates/gpu/src/shaders/median_private.wgsl new file mode 100644 index 000000000..5b3afefe4 --- /dev/null +++ b/crates/gpu/src/shaders/median_private.wgsl @@ -0,0 +1,63 @@ +// The same median for large radii, as the CPU computes it: a sliding 512-bin histogram per +// channel (Huang). One invocation per row segment of u[0].y pixels: it fills the histogram for +// the segment's first window, then slides right one column at a time (remove the column +// leaving, add the one entering) and moves each channel's median by the counts below it. +// u[0] = (r, segment length); dispatched over (rows, segments) with 64 × 1 workgroups. +// data = the 512 CPU-rounded level / 511 values. +@compute @workgroup_size(64, 1) +fn fxn_median_huang(@builtin(global_invocation_id) gid: vec3) { + let dims = out_dims(); + let y = i32(gid.x); + let seg = i32(P.u[0].y); + let x0 = i32(gid.y) * seg; + if (y >= dims.y || x0 >= dims.x) { + return; + } + let x1 = min(x0 + seg, dims.x); + let r = i32(P.u[0].x); + let half = u32((2 * r + 1) * (2 * r + 1) / 2); + var hist: array; + for (var dy = -r; dy <= r; dy++) { + for (var dx = -r; dx <= r; dx++) { + let v = vec4(tex_get_clamped(src, x0 + dx, y + dy)); + hist[v.x] += 1u; + hist[512u + v.y] += 1u; + hist[1024u + v.z] += 1u; + hist[1536u + v.w] += 1u; + } + } + // Per channel: the median level and the count of values below it. + var med = vec4(0u); + var lt = vec4(0u); + for (var x = x0; x < x1; x++) { + if (x > x0) { + for (var dy = -r; dy <= r; dy++) { + let o = vec4(tex_get_clamped(src, x - 1 - r, y + dy)); + let n = vec4(tex_get_clamped(src, x + r, y + dy)); + for (var c = 0u; c < 4u; c++) { + hist[c * 512u + o[c]] -= 1u; + if (o[c] < med[c]) { + lt[c] -= 1u; + } + hist[c * 512u + n[c]] += 1u; + if (n[c] < med[c]) { + lt[c] += 1u; + } + } + } + } + for (var c = 0u; c < 4u; c++) { + let base = c * 512u; + while (lt[c] > half) { + med[c] -= 1u; + lt[c] -= hist[base + med[c]]; + } + while (lt[c] + hist[base + med[c]] <= half) { + lt[c] += hist[base + med[c]]; + med[c] += 1u; + } + } + textureStore(out, vec2(x, y), vec4(data[med.x], data[med.y], data[med.z], data[med.w])); + } +} + diff --git a/crates/gpu/src/shaders/median_software.wgsl b/crates/gpu/src/shaders/median_software.wgsl new file mode 100644 index 000000000..606a5e4b4 --- /dev/null +++ b/crates/gpu/src/shaders/median_software.wgsl @@ -0,0 +1,65 @@ +// The same median for large radii, as the CPU computes it: a sliding 512-bin histogram per +// channel (Huang). One invocation per row segment of u[0].y pixels: it fills the histogram for +// the segment's first window, then slides right one column at a time (remove the column +// leaving, add the one entering) and moves each channel's median by the counts below it. +// Each invocation owns one 8 KiB slice of the workgroup's zero-initialized histogram. +// Two slices fit the guaranteed 16 KiB workgroup storage limit. No bins are shared. +var median_hist: array, 2>; +// u[0] = (r, segment length); dispatched over (rows, segments) with 2 × 1 workgroups. +// data = the 512 CPU-rounded level / 511 values. +@compute @workgroup_size(2, 1) +fn fxn_median_huang(@builtin(global_invocation_id) gid: vec3, @builtin(local_invocation_index) lane: u32) { + let dims = out_dims(); + let y = i32(gid.x); + let seg = i32(P.u[0].y); + let x0 = i32(gid.y) * seg; + if (y >= dims.y || x0 >= dims.x) { + return; + } + let x1 = min(x0 + seg, dims.x); + let r = i32(P.u[0].x); + let half = u32((2 * r + 1) * (2 * r + 1) / 2); + for (var dy = -r; dy <= r; dy++) { + for (var dx = -r; dx <= r; dx++) { + let v = vec4(tex_get_clamped(src, x0 + dx, y + dy)); + median_hist[lane][v.x] += 1u; + median_hist[lane][512u + v.y] += 1u; + median_hist[lane][1024u + v.z] += 1u; + median_hist[lane][1536u + v.w] += 1u; + } + } + // Per channel: the median level and the count of values below it. + var med = vec4(0u); + var lt = vec4(0u); + for (var x = x0; x < x1; x++) { + if (x > x0) { + for (var dy = -r; dy <= r; dy++) { + let o = vec4(tex_get_clamped(src, x - 1 - r, y + dy)); + let n = vec4(tex_get_clamped(src, x + r, y + dy)); + for (var c = 0u; c < 4u; c++) { + median_hist[lane][c * 512u + o[c]] -= 1u; + if (o[c] < med[c]) { + lt[c] -= 1u; + } + median_hist[lane][c * 512u + n[c]] += 1u; + if (n[c] < med[c]) { + lt[c] += 1u; + } + } + } + } + for (var c = 0u; c < 4u; c++) { + let base = c * 512u; + while (lt[c] > half) { + med[c] -= 1u; + lt[c] -= median_hist[lane][base + med[c]]; + } + while (lt[c] + median_hist[lane][base + med[c]] <= half) { + lt[c] += median_hist[lane][base + med[c]]; + med[c] += 1u; + } + } + textureStore(out, vec2(x, y), vec4(data[med.x], data[med.y], data[med.z], data[med.w])); + } +} + diff --git a/crates/gpu/src/tests.rs b/crates/gpu/src/tests.rs index b2e1113e2..6c7b3ffb2 100644 --- a/crates/gpu/src/tests.rs +++ b/crates/gpu/src/tests.rs @@ -52,9 +52,29 @@ pub(crate) fn gpu() -> Option<&'static Gpu> { static G: OnceLock> = OnceLock::new(); hold_gpu_lock(); let g = G.get_or_init(|| { + // Readback mapping/allocation errors become None without necessarily retiring the + // context. Preserve their original messages alongside the failure's health snapshot. + struct GpuTestLog; + impl log::Log for GpuTestLog { + fn enabled(&self, metadata: &log::Metadata<'_>) -> bool { + metadata.level() <= log::Level::Warn && metadata.target().starts_with("effectcraft_gpu") + } + + fn log(&self, record: &log::Record<'_>) { + if self.enabled(record.metadata()) { + eprintln!("{} {}: {}", record.level(), record.target(), record.args()); + } + } + + fn flush(&self) {} + } + static LOGGER: GpuTestLog = GpuTestLog; + if log::set_logger(&LOGGER).is_ok() { + log::set_max_level(log::LevelFilter::Warn); + } let g = Gpu::try_headless(); match &g { - Ok(g) => eprintln!("effectcraft-gpu tests: adapter {}", g.ctx.name), + Ok(g) => eprintln!("effectcraft-gpu tests: adapter {}, median {:?}", g.ctx.name, g.ctx.median_kernel), Err(e) => eprintln!("effectcraft-gpu tests: no GPU ({e}), skipping GPU comparisons"), } g @@ -481,7 +501,16 @@ pub(crate) fn effect_direct(id: &str, vals: &[(&str, Value)], adjustment: bool) let buf = effectcraft_effects::Buf { img, offset: [0.0, 0.0], scale }; let ctx = || effectcraft_effects::EffectCtx { params: ¶ms, time: 0.25, layer_size: size, seed: 11, adjustment, env: Default::default() }; let cpu = (spec.render)(&ctx(), buf.clone()); - let out = effectcraft_render::Accelerator::effects(g, &[effectcraft_render::FxStep { spec, ctx: ctx() }], &buf, None).expect("the GPU runs the effect"); + let started = std::time::Instant::now(); + let out = effectcraft_render::Accelerator::effects(g, &[effectcraft_render::FxStep { spec, ctx: ctx() }], &buf, None).unwrap_or_else(|| { + panic!( + "the GPU runs {id} {vals:?} adj {adjustment} scale {scale}; elapsed={:?}, health={:?}, unavailable={:?}, transfers={:?}", + started.elapsed(), + g.ctx.check_health(), + g.ctx.unavailable_kernels(), + g.ctx.transfer_stats() + ) + }); if expect_empty { assert!(out.img.data.iter().all(|p| p[3] == 0.0), "{id} {vals:?} adj {adjustment} scale {scale}: Cut Away 100 produced non-zero alpha"); } diff --git a/crates/gpu/src/tests_fx_noise.rs b/crates/gpu/src/tests_fx_noise.rs index 476f08642..38a30aca5 100644 --- a/crates/gpu/src/tests_fx_noise.rs +++ b/crates/gpu/src/tests_fx_noise.rs @@ -22,6 +22,24 @@ fn pt(x: f64, y: f64) -> Value { Value::Vec2([x, y]) } +#[test] +fn median_kernel_selection_preserves_physical_gpu_path() { + use crate::fx_noise::MedianKernel; + for backend in [wgpu::Backend::Vulkan, wgpu::Backend::Metal, wgpu::Backend::Dx12, wgpu::Backend::Gl, wgpu::Backend::BrowserWebGpu] { + for device_type in + [wgpu::DeviceType::DiscreteGpu, wgpu::DeviceType::IntegratedGpu, wgpu::DeviceType::VirtualGpu, wgpu::DeviceType::Cpu, wgpu::DeviceType::Other] + { + let selected = MedianKernel::for_adapter(backend, device_type); + let expected = + if backend == wgpu::Backend::Vulkan && device_type == wgpu::DeviceType::Cpu { MedianKernel::Software2 } else { MedianKernel::Private64 }; + assert_eq!(selected, expected, "{backend:?} {device_type:?}"); + } + } + assert_eq!(MedianKernel::Private64.rows_per_workgroup(), 64); + assert_eq!(MedianKernel::Software2.rows_per_workgroup(), 2); + assert_eq!(MedianKernel::Software2.workgroup_storage(), 16_384); +} + #[test] fn medians() { effect_case("ec.noise.median", &[("radius", n(2.0))]); @@ -237,8 +255,17 @@ fn median_levels_match_cpu_division() { // A monotone row's median is its centre, including repeated edges. Exercise both // the bisection and sliding-histogram kernels at every quantised level. let mut enc = crate::context::Enc::new(&g.ctx); + let started = std::time::Instant::now(); let median = crate::fx_noise::median_image(&mut enc, &input, radius, false); - let actual = enc.download(&median).unwrap(); + let actual = enc.download(&median).unwrap_or_else(|| { + panic!( + "median download radius {radius}; elapsed={:?}, health={:?}, unavailable={:?}, transfers={:?}", + started.elapsed(), + g.ctx.check_health(), + g.ctx.unavailable_kernels(), + g.ctx.transfer_stats() + ) + }); for (level, (want, got)) in image.data.iter().zip(&actual.data).enumerate() { assert_eq!(got, want, "radius {radius}, level {level}"); } @@ -273,7 +300,16 @@ fn dust_strict_threshold_boundaries() { let buf = effectcraft_effects::Buf { img, offset: [0.0; 2], scale: 1.0 }; let cpu = (spec.render)(&ctx(), buf.clone()); assert_eq!(cpu.img.data[24] != original, replaced, "CPU radius {radius}, threshold {threshold}"); - let gpu = effectcraft_render::Accelerator::effects(g, &[effectcraft_render::FxStep { spec, ctx: ctx() }], &buf, None).unwrap(); + let started = std::time::Instant::now(); + let gpu = effectcraft_render::Accelerator::effects(g, &[effectcraft_render::FxStep { spec, ctx: ctx() }], &buf, None).unwrap_or_else(|| { + panic!( + "dust radius {radius}, threshold {threshold}; elapsed={:?}, health={:?}, unavailable={:?}, transfers={:?}", + started.elapsed(), + g.ctx.check_health(), + g.ctx.unavailable_kernels(), + g.ctx.transfer_stats() + ) + }); assert_eq!(gpu.img.data[24], cpu.img.data[24], "radius {radius}, threshold {threshold}"); } } diff --git a/crates/media/src/exr_channels.rs b/crates/media/src/exr_channels.rs index 8c4cb1339..795da99e5 100644 --- a/crates/media/src/exr_channels.rs +++ b/crates/media/src/exr_channels.rs @@ -183,7 +183,7 @@ fn picture_layer<'a>(aux: &AuxChannels, layers: &[&'a str]) -> Option<&'a str> { /// The picture of a multi-layer OpenEXR file without an unnamed RGB layer (Blender's /// `ViewLayer.Combined.R`, the compositor's `Image.R`, Nuke's `beauty.R`, …), which the image /// decoder rejects. After Effects shows a file's main RGBA channels and leaves the other layers -/// to EXtractoR; here that is the beauty pass by name ([`BEAUTY`]), else the first colour layer +/// to EXtractoR; here that is the beauty pass by name (`BEAUTY`), else the first colour layer /// that is not a data pass (never Cryptomatte, depth or normals, #412), else the first channel /// as grey. Linear, straight RGBA as the file stores it. `None` when it is not a readable EXR. pub fn layered_image(bytes: &[u8]) -> Option { diff --git a/crates/media/src/pool.rs b/crates/media/src/pool.rs index 7f602465d..4820efa35 100644 --- a/crates/media/src/pool.rs +++ b/crates/media/src/pool.rs @@ -388,7 +388,7 @@ impl MediaPool { } /// The conformed-audio file of `path` at `rate` (named by a hash of the path, size and - /// modification time, so an edited file conforms again, and of [`CONFORM_VERSION`]). + /// modification time, so an edited file conforms again, and of `CONFORM_VERSION`). pub fn conformed_path(&self, path: &str, rate: u32) -> Option { use std::hash::{Hash, Hasher}; let folder = lock(&self.inner.conform).clone()?; diff --git a/crates/path/src/boolean.rs b/crates/path/src/boolean.rs index 5a81b3e19..bcd395566 100644 --- a/crates/path/src/boolean.rs +++ b/crates/path/src/boolean.rs @@ -5,7 +5,7 @@ //! MIT OR Apache-2.0), reworked to take and return kurbo paths directly. //! //! Inputs are closed implicitly (a filled open path is painted as if closed). The sweep splits -//! curves at intersections and y-extrema; [`tidy_segments`] re-joins pieces that came from one +//! curves at intersections and y-extrema; `tidy_segments` re-joins pieces that came from one //! input curve (exactly) or one smooth run (by least-squares refit), so results carry roughly as //! few vertices as the inputs. Output contours are simple and consistently oriented (holes wind //! opposite to their outer contour), so they fill the same under either fill rule. diff --git a/crates/path/src/trace.rs b/crates/path/src/trace.rs index 429738032..01508bcaf 100644 --- a/crates/path/src/trace.rs +++ b/crates/path/src/trace.rs @@ -6,7 +6,7 @@ //! with "outside" so every contour closes. //! 2. **Douglas–Peucker** simplification of each closed loop to the Tolerance (pixels). //! 3. **Bezier fitting**: each simplified span is fitted with one cubic (least squares with fixed -//! end tangents, Newton reparameterisation; [`crate::fit`]). The tangent at each vertex is the +//! end tangents, Newton reparameterisation; `crate::fit`). The tangent at each vertex is the //! chord direction at Corner Roundness 0 (straight segments, sharp corners) and the smooth //! (Catmull–Rom) direction at 100 %, blended between. //! diff --git a/crates/psd/src/lib.rs b/crates/psd/src/lib.rs index 5ae32126a..60a9ab806 100644 --- a/crates/psd/src/lib.rs +++ b/crates/psd/src/lib.rs @@ -1,5 +1,5 @@ //! Photoshop document (PSD / PSB) reader, implemented from Adobe's public *Photoshop File Formats -//! Specification* (see README.md), and a minimal writer ([`write`]) for fixtures. +//! Specification* (see README.md), and a minimal writer ([`mod@write`]) for fixtures. //! //! [`Psd::parse`] reads the structure (header, layer records and their additional information, //! the layer tree) without decoding pixels; [`Psd::composite`] decodes the merged image and diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index bf3aa42ee..e4fc8e6e5 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -521,7 +521,7 @@ pub struct RenderOpts { pub motion_blur: bool, /// Render guide layers (viewer only). Only the top-level comp's own guide layers: nested /// comps (precomps, collapsed precomps, comps read by effects) never draw theirs, as in - /// After Effects (see [`Renderer::nested_opts`]). + /// After Effects (see `Renderer::nested_opts`). pub guides: bool, /// Draft quality: fewer motion-blur samples, bilinear only. pub draft: bool, diff --git a/crates/render/src/three_d/adv/shade.rs b/crates/render/src/three_d/adv/shade.rs index c7c1a40f1..9acc852fe 100644 --- a/crates/render/src/three_d/adv/shade.rs +++ b/crates/render/src/three_d/adv/shade.rs @@ -230,7 +230,7 @@ const PLANE_SLOPE_MAX: f32 = 64.0; /// Fraction of light reaching `p` through shadow map `m` (1 = lit). `ng` is the /// receiver's geometric (not normal-mapped) normal facing the viewer. /// -/// PCF over an N×N tap grid (5 ≤ N ≤ [`PCF_MAX_TAPS`]) spread over a reach derived +/// PCF over an N×N tap grid (5 ≤ N ≤ `PCF_MAX_TAPS`) spread over a reach derived /// from `radius` (the wanted penumbra in output pixels) divided by the receiver's /// texel footprint, each tap jittered by an integer hash of the receiver's /// sub-texel cell and the tap index, and each tap comparing against the four diff --git a/crates/render/src/three_d/compose.rs b/crates/render/src/three_d/compose.rs index 20061f756..929b48d45 100644 --- a/crates/render/src/three_d/compose.rs +++ b/crates/render/src/three_d/compose.rs @@ -703,7 +703,7 @@ fn occlusion(c: &Item, p: Vec3, l: &LightState) -> [f32; 3] { /// A Classic 3D run prepared for an accelerator ([`Renderer::prepare_3d_run`]): every plane /// with its geometry, material and buffer, ready for the per-pixel depth sort, lighting and -/// ray-cast shadows of [`draw_run`] on another device. +/// ray-cast shadows of `draw_run` on another device. #[derive(Clone, Debug)] pub struct Run3d { /// Drawn planes in stack order, then shadow casters from outside the run (`draw` false). @@ -971,7 +971,7 @@ pub(crate) fn environment_rotation(ctx: &EvalCtx) -> f32 { .to_radians() as f32 } -/// An Environment Light Background layer ready to draw (see [`draw_sky`]): the layer's +/// An Environment Light Background layer ready to draw (see `draw_sky`): the layer's /// buffer in the blending space, its opacity and the camera's view rays. Also handed to an /// accelerator's compositor (`Renderer::sky_draw`). #[derive(Clone)] diff --git a/crates/render/src/three_d/mod.rs b/crates/render/src/three_d/mod.rs index bccfc3e05..f2cc1c70a 100644 --- a/crates/render/src/three_d/mod.rs +++ b/crates/render/src/three_d/mod.rs @@ -2,7 +2,7 @@ //! compositor for runs of 3D layers. //! //! The 2D compositor (`Renderer::comp_frame`) walks the layer stack bottom-to-top; each run of -//! consecutive 3D layers is handed to [`compose::draw_run`] as a group (2D layers break 3D +//! consecutive 3D layers is handed to `compose::draw_run` as a group (2D layers break 3D //! groups, as in After Effects). pub mod adv; diff --git a/crates/script/src/runtime.rs b/crates/script/src/runtime.rs index 192da5740..608c83d0e 100644 --- a/crates/script/src/runtime.rs +++ b/crates/script/src/runtime.rs @@ -710,7 +710,7 @@ pub(crate) fn outcome(output: Vec, result: Result) -> Ou /// don't block. /// /// ExtendScript's `#include` / `#includepath` are inlined first, and errors point at the file -/// and line they came from ([`crate::include`]). +/// and line they came from (`crate::include`). pub fn run(session: &mut Session, req: &ScriptRequest) -> Outcome { let expanded = match crate::include::expand(session, req.code, req.name) { Ok(x) => x, diff --git a/crates/ui-egui/src/color_picker.rs b/crates/ui-egui/src/color_picker.rs index 847e1e541..acc01cc65 100644 --- a/crates/ui-egui/src/color_picker.rs +++ b/crates/ui-egui/src/color_picker.rs @@ -77,6 +77,8 @@ pub fn color_popup(ui: &mut Ui, id: egui::Id, pos: egui::Pos2, c: &mut [f32; 3]) out.changed } +/// The `egui::Id` of the pass number in which a colour picker last stayed open, which +/// [`open_recently`] uses to keep Escape for the picker rather than the dialog under it. fn open_pass_id() -> egui::Id { egui::Id::unique("color-picker-open-pass") } @@ -175,6 +177,12 @@ impl Pick { const SQUARE: f32 = 200.0; const SLIDER_W: f32 = 16.0; +/// The colour picker popup's contents: saturation/value square, channel slider, number fields for +/// H/S/B and R/G/B, a hex field and an original-colour swatch, plus OK and Cancel. `*c` is updated +/// as the UI changes and `original` is what Cancel (or the swatch) restores. The chosen channel +/// and the HSV/RGB pair are remembered in egui temp data so reopening keeps the hue when +/// saturation or value reaches zero. Returns what the picker did this frame; it never runs a +/// command itself. fn color_picker(ui: &mut Ui, id: egui::Id, c: &mut [f32; 3], original: [f32; 3]) -> Outcome { let state_id = id.with("pick"); let mut pick = match ui.data(|d| d.get_temp::<([f32; 3], [f32; 3])>(state_id)) { diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index d89f3b988..e535cc723 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -39,6 +39,8 @@ pub struct ControlRequest { } impl ControlRequest { + /// Creates a request for `method` with `params`, returning it with the channel that will + /// receive the UI thread's reply. pub fn new(method: impl Into, params: Value) -> (Self, std::sync::mpsc::Receiver) { let (tx, rx) = std::sync::mpsc::channel(); (Self { method: method.into(), params, reply: tx }, rx) @@ -170,6 +172,10 @@ fn point(app: &EffectcraftApp, p: &Value) -> Result { Ok(egui::pos2(x as f32, y as f32)) } +/// Dispatches one control request on the UI thread. Replies are `{"ok": true, "result": …}` +/// or `{"ok": false, "error": …}`; an unknown method is an error. Some methods answer with +/// [`Outcome::Retry`], [`Outcome::AfterInput`], [`Outcome::Screenshot`] or +/// [`Outcome::AwaitJobs`] instead of replying in this call. pub fn handle(app: &mut EffectcraftApp, ctx: &egui::Context, req: &ControlRequest, now: f64) -> Outcome { let p = &req.params; match req.method.as_str() { @@ -198,6 +204,10 @@ pub fn handle(app: &mut EffectcraftApp, ctx: &egui::Context, req: &ControlReques } } +/// Runs `engine.execute` or `ui.menu.invoke`. `engine.execute` with a known engine command id +/// goes through [`execute_engine`] (so a `wait: true` job can be offloaded); every other id, +/// and every `ui.menu.invoke`, goes through the menu dispatcher. A failure is copied to the +/// status line. fn execute_command(app: &mut EffectcraftApp, ctx: &egui::Context, method: &str, p: &Value) -> Outcome { let s = |k: &str| p.get(k).and_then(Value::as_str); let Some(id) = s("command").or(s("id")) else { return err("missing `command`") }; @@ -219,6 +229,7 @@ fn execute_command(app: &mut EffectcraftApp, ctx: &egui::Context, method: &str, } } +/// Replies with `command.list`'s result, forwarding `filter` and `enabledOnly`. fn engine_commands(app: &mut EffectcraftApp, p: &Value) -> Outcome { match app.session.execute("command.list", json!({"filter": p.get("filter"), "enabledOnly": p.get("enabledOnly")})) { Ok(v) => ok(v), @@ -226,6 +237,8 @@ fn engine_commands(app: &mut EffectcraftApp, p: &Value) -> Outcome { } } +/// Replies with the automation elements whose id starts with `prefix` (all when empty), as +/// `{id, label, rect}` records. fn elements(app: &EffectcraftApp, p: &Value) -> Outcome { let prefix = p.get("prefix").and_then(Value::as_str).unwrap_or(""); ok(serde_json::to_value(app.auto.query(prefix)).unwrap_or_default()) @@ -237,6 +250,9 @@ fn focus(ctx: &egui::Context) -> Outcome { ok(Value::Null) } +/// Applies `ui.set` in order: tool, workspace, theme, focused panel, viewer settings, then the +/// layout fields ([`set_ui_layout`]). An unknown tool, workspace or theme name, or an invalid +/// viewer value, replies with an error after the earlier fields were already applied. fn set_ui(app: &mut EffectcraftApp, ctx: &egui::Context, p: &Value) -> Outcome { let s = |k: &str| p.get(k).and_then(Value::as_str); if let Some(t) = s("tool") { @@ -297,6 +313,9 @@ fn set_ui_layout(app: &mut EffectcraftApp, p: &Value) -> Outcome { ok(Value::Null) } +/// Applies viewer `zoom`, the active comp's `res` (stored in the project), `pan` and the +/// `grid`, `rulers`, `safeMargins` and `transparencyGrid` toggles. An unknown resolution name +/// is an error; a resolution change without an active comp is ignored. fn apply_viewer(app: &mut EffectcraftApp, v: &Value) -> Result<(), String> { if let Some(z) = v.get("zoom") { app.ui.viewer.zoom = z.as_f64().map(|z| z as f32); @@ -328,6 +347,8 @@ fn apply_viewer(app: &mut EffectcraftApp, v: &Value) -> Result<(), String> { Ok(()) } +/// Applies timeline `pps`, `start`, `graphEditor`, `showModes`, `openLayers` and `openGroups` +/// to the UI state, leaving absent fields unchanged. fn apply_timeline(app: &mut EffectcraftApp, tl: &Value) { if let Some(v) = tl.get("pps") { app.ui.timeline.pps = v.as_f64(); @@ -349,6 +370,8 @@ fn apply_timeline(app: &mut EffectcraftApp, tl: &Value) { } } +/// Replaces the Project panel's optional columns with the names in `a`, in the canonical +/// `COLUMNS` order; unknown names are kept, sorted after the known ones. fn apply_project_columns(app: &mut EffectcraftApp, a: &[Value]) { let mut cols = vec![]; for k in a.iter().filter_map(Value::as_str) { @@ -357,6 +380,8 @@ fn apply_project_columns(app: &mut EffectcraftApp, a: &[Value]) { app.ui.project_columns = cols; } +/// Merges `m` over the current Wiggler/Smoother/Motion Sketch settings by round-tripping them +/// through JSON. A value of the wrong type is an error and leaves the settings unchanged. fn apply_anim_tools(app: &mut EffectcraftApp, m: &serde_json::Map) -> Result<(), String> { let mut cur = serde_json::to_value(&app.ui.anim_tools).unwrap_or_default(); for (k, v) in m { @@ -371,6 +396,8 @@ fn apply_anim_tools(app: &mut EffectcraftApp, m: &serde_json::Map } } +/// Shows (`ui.panel.show`) or closes (`ui.panel.close`) the named panel; an unknown panel name +/// is an error. fn panel(app: &mut EffectcraftApp, method: &str, p: &Value) -> Outcome { let s = |k: &str| p.get(k).and_then(Value::as_str); let Some(panel) = s("panel").and_then(PanelKind::from_name) else { return err("unknown `panel`") }; @@ -382,6 +409,8 @@ fn panel(app: &mut EffectcraftApp, method: &str, p: &Value) -> Outcome { ok(Value::Null) } +/// The number of synthetic events a gesture queues: `2 * count + 3` for `ui.click` (`count` +/// 1..=512), or 3 for `ui.move`. An out-of-range `count` or an overflow is an error. fn click_gesture_events(method: &str, p: &Value) -> Result { if method != "ui.click" { return Ok(3); @@ -394,6 +423,10 @@ fn click_gesture_events(method: &str, p: &Value) -> Result { usize::try_from(count).ok().and_then(|n| n.checked_mul(2)).and_then(|n| n.checked_add(3)).ok_or_else(|| "click event count overflow".to_string()) } +/// Queues a click or a plain move at `{id}`'s centre (`fx`/`fy` within its rect, default 0.5) +/// or at `{x, y}`, holding the request's modifiers across the whole gesture. An element that is +/// not on screen returns [`Outcome::Retry`]; the gesture is size-checked against the 4096-event +/// queue limit before anything is pushed. Replies [`Outcome::AfterInput`]. fn click_or_move(app: &mut EffectcraftApp, method: &str, p: &Value) -> Outcome { let s = |k: &str| p.get(k).and_then(Value::as_str); let pos = match point(app, p) { @@ -433,6 +466,10 @@ fn click_or_move(app: &mut EffectcraftApp, method: &str, p: &Value) -> Outcome { Outcome::AfterInput } +/// Queues a primary-button drag from `from` to `to` over `steps` interpolated moves (default +/// 12, at least 2, at most 2048), with the request's modifiers. Both endpoints are resolved +/// first (an unseen element returns [`Outcome::Retry`]) and the event count is checked against +/// the 4096-event queue limit. Replies [`Outcome::AfterInput`]. fn drag(app: &mut EffectcraftApp, p: &Value) -> Outcome { let (Some(from), Some(to)) = (p.get("from"), p.get("to")) else { return err("need `from` and `to`") }; let (a, b) = match (point(app, from), point(app, to)) { @@ -463,6 +500,8 @@ fn drag(app: &mut EffectcraftApp, p: &Value) -> Outcome { Outcome::AfterInput } +/// Queues a move to the target point, a wheel event of `dx`, `dy` points with the request's +/// modifiers, and a released Extra1 button. Replies [`Outcome::AfterInput`]. fn scroll(app: &mut EffectcraftApp, p: &Value) -> Outcome { let pos = match point(app, p) { Ok(p) => p, @@ -487,6 +526,9 @@ fn scroll(app: &mut EffectcraftApp, p: &Value) -> Outcome { Outcome::AfterInput } +/// Queues a press and release of `key`, whose name may carry a shortcut (`Cmd+Shift+Z`); the +/// separate `modifiers` flags are added. A `Physical+` prefix makes the key positional. An +/// unknown key name is an error. fn key(app: &mut EffectcraftApp, p: &Value) -> Outcome { let s = |k: &str| p.get(k).and_then(Value::as_str); let Some(name) = s("key") else { return err("missing `key`") }; @@ -499,6 +541,8 @@ fn key(app: &mut EffectcraftApp, p: &Value) -> Outcome { Outcome::AfterInput } +/// Queues a text event with `text`, followed by a released F35 key. Missing `text` is an +/// error. fn type_text(app: &mut EffectcraftApp, p: &Value) -> Outcome { let Some(text) = p.get("text").and_then(Value::as_str) else { return err("missing `text`") }; app.synthetic.push(egui::Event::Text(text.to_string())); @@ -506,6 +550,8 @@ fn type_text(app: &mut EffectcraftApp, p: &Value) -> Outcome { Outcome::AfterInput } +/// Converts comp pixel `x`, `y` to a screen point with the viewer's current transform, or +/// returns [`Outcome::Retry`] while the viewer is not visible. fn viewer_locate(ctx: &egui::Context, p: &Value) -> Outcome { let (Some(x), Some(y)) = (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)) else { return err("need `x`, `y` (comp pixels)"); @@ -516,6 +562,8 @@ fn viewer_locate(ctx: &egui::Context, p: &Value) -> Outcome { } } +/// Converts a point (`{id}`, or `{x, y}`) to comp pixels; replies with an error when the +/// viewer is not visible. fn viewer_hit(app: &mut EffectcraftApp, ctx: &egui::Context, p: &Value) -> Outcome { let pos = match point(app, p) { Ok(p) => p, @@ -527,6 +575,9 @@ fn viewer_hit(app: &mut EffectcraftApp, ctx: &egui::Context, p: &Value) -> Outco } } +/// Returns the screen point at `time` seconds on the ruler row of layer `layer`'s bar; +/// without a numeric `time`, returns the bar's centre. Returns [`Outcome::Retry`] when the bar +/// is unavailable, or a numeric `time` is supplied but the ruler is unavailable or malformed. fn timeline_locate(app: &EffectcraftApp, p: &Value) -> Outcome { let Some(l) = p.get("layer").and_then(Value::as_u64) else { return err("need `layer` (id)") }; match crate::panels::timeline::locate(app, l, p.get("time").and_then(Value::as_f64)) { @@ -535,6 +586,9 @@ fn timeline_locate(app: &EffectcraftApp, p: &Value) -> Outcome { } } +/// Applies `action` (`play`, `stop`, `status`, else toggle) at `now` and replies with the +/// playing state, current time in seconds, whether audio is present and the meter's level and +/// peak in dB. fn playback(app: &mut EffectcraftApp, p: &Value, now: f64) -> Outcome { let action = p.get("action").and_then(Value::as_str).unwrap_or("toggle"); match action { @@ -548,6 +602,11 @@ fn playback(app: &mut EffectcraftApp, p: &Value, now: f64) -> Outcome { ) } +/// Resolves the capture target — a named panel, an element id, or the whole window — and +/// returns [`Outcome::Retry`] while a requested panel or element is not registered, so a +/// missing target is never silently captured as the window. Requests a repaint without raising +/// or focusing the window and returns [`Outcome::Screenshot`]; the target is resolved again +/// when the capture is issued. fn screenshot(app: &mut EffectcraftApp, ctx: &egui::Context, p: &Value) -> Outcome { let s = |k: &str| p.get(k).and_then(Value::as_str); // The panel/element is an identity, not a rectangle: it is resolved when the capture @@ -574,11 +633,12 @@ fn screenshot(app: &mut EffectcraftApp, ctx: &egui::Context, p: &Value) -> Outco crate::ShotTarget::Window | crate::ShotTarget::Rect(_) => "the element is not on screen".into(), }); } - // Bring the window on screen so a frame is presented, without taking keyboard focus. + // Request a repaint without raising the window or taking keyboard focus. app.raise_for_control(ctx); Outcome::Screenshot { path: s("path").map(str::to_string), target } } +/// Asks the viewport for an inner size of `width`×`height` pixels (default 1600×1000). fn resize(ctx: &egui::Context, p: &Value) -> Outcome { let w = p.get("width").and_then(Value::as_f64).unwrap_or(1600.0) as f32; let h = p.get("height").and_then(Value::as_f64).unwrap_or(1000.0) as f32; @@ -586,6 +646,8 @@ fn resize(ctx: &egui::Context, p: &Value) -> Outcome { ok(Value::Null) } +/// Sends the viewport close request. `force: true` marks the unsaved-changes state as +/// quitting, so a modified project closes without the save prompt. fn quit(app: &mut EffectcraftApp, ctx: &egui::Context, p: &Value) -> Outcome { // A modified project asks to save first unless `force: true`. if p.get("force").and_then(Value::as_bool).unwrap_or(false) { @@ -653,6 +715,10 @@ pub fn base64(data: &[u8]) -> String { s } +/// Builds the `ui.inspect` reply: window size and pixels per point, fps, UI state with the +/// active comp's viewer resolution, playback and time, active comp, selected layer/key counts, +/// registered element count, keyboard focus (egui id, rect, automation ids and rect +/// candidates), fresh window focus, workspace storage error, open dialog and last frame time. pub fn inspect(app: &EffectcraftApp, ctx: &egui::Context) -> Value { // A logic-only tick refreshes the viewport information but not `InputState`'s screen rect. let size = ctx.input(|i| i.viewport().inner_rect).map_or_else(|| ctx.content_rect().size(), |r| r.size()); diff --git a/crates/ui-egui/src/dock_ui.rs b/crates/ui-egui/src/dock_ui.rs index 41977e667..30710392a 100644 --- a/crates/ui-egui/src/dock_ui.rs +++ b/crates/ui-egui/src/dock_ui.rs @@ -372,6 +372,8 @@ impl EffectcraftApp { v } + /// Draw panel `p`'s body in `rect`: register its automation region, put the Composition Navigator above an active + /// viewer when a nested-comp flow exists, then call the panel's `show` and (for a viewer) the sketch overlay. fn panel_body(&mut self, ui: &mut egui::Ui, p: PanelKind, rect: Rect) { self.auto.add(&format!("panel.{}", p.id()), rect, p.title()); let mut content = rect.shrink(1.0); @@ -412,6 +414,9 @@ impl EffectcraftApp { self.dialog.is_some() || panels::dialogs::modal_open(ctx) || file_dialog } + /// Draw the Home screen or workspace in `body`. Shared craft-ui docking owns the dock tree, + /// group chrome, tab dragging and floating native windows; this adapter draws panel bodies, + /// applies the returned events/actions and shows the panel menu. pub(crate) fn dock_area(&mut self, ui: &mut egui::Ui, body: Rect) { let t = self.tokens; let ctx = ui.ctx().clone(); diff --git a/crates/ui-egui/src/header.rs b/crates/ui-egui/src/header.rs index 2ad3dd20f..861d6fd91 100644 --- a/crates/ui-egui/src/header.rs +++ b/crates/ui-egui/src/header.rs @@ -15,6 +15,10 @@ use effectcraft_engine::commands::shape_tool::{PaintKind, ToolPaint}; use effectcraft_engine::project::{LayerId, LayerSource, PropGroup}; use effectcraft_engine::viewer as vw; +/// Draws the Tools bar into `rect`: brand (About), Home, the tool slots with their fly-out +/// menus and press-and-hold picks, options for the active tool (shape paint, puppet, roto), +/// Snapping, then Discord, GitHub, website, appearance and workspace controls. Clicks apply +/// tool, dialog, theme and workspace changes to `app` or run their commands. pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let t = app.tokens; let p = ui.painter().clone(); diff --git a/crates/ui-egui/src/i18n/effects.rs b/crates/ui-egui/src/i18n/effects.rs index b41430bed..162300f9c 100644 --- a/crates/ui-egui/src/i18n/effects.rs +++ b/crates/ui-egui/src/i18n/effects.rs @@ -14,7 +14,7 @@ //! the id is stable, so a translation never becomes an orphan. //! //! Each row repeats the registry's English name so that a rename upstream fails -//! [`tests::every_row_names_a_registry_effect`] instead of silently pointing a translation at +//! `tests::every_row_names_a_registry_effect` instead of silently pointing a translation at //! another effect. An effect with no row draws the registry's English name; the //! `missing_translations_report` test (run by `cargo xtask ci`) lists those per language. //! diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index a9c45bb99..1b75ff3ee 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -6,7 +6,7 @@ //! inventing an id, and a missing row falls back to English instead of to an empty label. //! //! The language in force is set once per frame, from the same resolved language the fonts are -//! installed for ([`super::language`], [`set_current`]), so a panel does not have to thread it +//! installed for (`crate::i18n::language`, [`set_current`]), so a panel does not have to thread it //! through every function. It is thread-local: the render threads never draw interface text. //! //! A row that carries values keeps one `{}` per value and is read with [`tr_args`]; the tests below diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index c21dfef1a..8e9c69fbf 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -111,6 +111,7 @@ impl AppClock { } impl Default for AppClock { + /// The real monotonic clock (`Self::real()`). fn default() -> Self { Self::real() } @@ -559,6 +560,10 @@ struct PlaybackPass<'a> { } impl EffectcraftApp { + /// Build the app around `session`. + /// Loads and applies the workspaces saved in the session's config directory (a failed load is reported in + /// `ui.status`), initializes the default UI, playback and render state, and registers the frontend's commands as + /// shortcutable `UiCommand`s on the session. pub fn new(session: Session) -> Self { let mut ui = UiState::default(); let workspace_store = workspace_state::WorkspaceStore::load(session.config.as_deref(), &mut ui); @@ -857,7 +862,7 @@ impl EffectcraftApp { } /// Show panel `p` (opening it in its usual place if it is closed), bring it to the front and - /// give it the focus: Window ▸ . + /// give it the focus: Window ▸ ``. pub fn show_panel(&mut self, p: PanelKind) { if !self.ui.dock.is_floating(p) && self.ui.maximized.is_some_and(|m| m != p) { self.ui.maximized = None; @@ -1599,6 +1604,8 @@ impl EffectcraftApp { } } + /// Advance the audio or scrub output and feed its latest peaks to the level meter; an idle scrub is closed after + /// one second. With no source but an active meter, drive the meter to silence and request a repaint. fn pump_meters(&mut self, ctx: &egui::Context, now: f64) { if let Some(a) = &mut self.audio { a.pump(); @@ -1823,6 +1830,10 @@ impl EffectcraftApp { ctx.request_repaint(); } + /// Handle the control channel for this tick: drain queued requests plus previously deferred ones, run each + /// through `control::handle`, answer `Done` immediately, retry `Retry` until its three-second deadline, hold + /// input/job/screenshot replies until their event happens and finish job waiters. Retry timeouts and refused + /// screenshots are answered as `{"ok": false, "error": ...}`; the receiver is kept for the next tick. fn drain_control(&mut self, ctx: &egui::Context, now: f64) { let Some(rx) = self.control_rx.take() else { return }; let mut reqs: Vec<(ControlRequest, f64)> = std::mem::take(&mut self.deferred); @@ -2178,6 +2189,8 @@ impl EffectcraftApp { // ---------------------------------------------------------------- frame + /// Apply the engine events drained this tick: open a comp, show a toast, open a URL, run a frontend command, + /// purge frame caches or copy an image; command and clipboard failures go to `app.ui.status`. fn handle_events(&mut self, ctx: &egui::Context) { for ev in self.session.drain_events() { match ev { @@ -2389,6 +2402,10 @@ impl EffectcraftApp { panels::viewer_text::finish_logic(self, ctx); } + /// One UI pass after the IME begin hook: close on an applied GPU failure, run `ui_setup`, skip drawing until + /// fonts are ready, draw the frame, finish pointer wrapping, pump queued wasm frames, record input activity, + /// publish the completed pass's geometry and pass sequence, acknowledge applied synthetic input and answer + /// `AfterInput` replies only after a non-discarded pass. Requests repaints while frames are in flight. fn draw_inner(&mut self, ui: &mut egui::Ui, frame: &eframe::Frame) { let ctx = ui.ctx().clone(); if self.gpu_failure_applied == Some(true) { @@ -2706,6 +2723,9 @@ impl eframe::App for EffectcraftApp { } } + /// Feed pending synthetic input into `raw_input` only once fonts are ready (so a non-drawing pass never consumes + /// a batch), then prepare pointer wrapping; ending a numeric scrub clears the history merge key so the next edit + /// starts a new undo step. fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) { raw_input.events.append(&mut self.forwarded.events); raw_input.dropped_files.append(&mut self.forwarded.files); @@ -2722,6 +2742,8 @@ impl eframe::App for EffectcraftApp { } } + /// The logic-only tick: run `tick` and close the IME pass with `finish_ime_logic`. It does not read pass-time + /// input state (events, pointer and time are stale here), only the viewport information eframe refreshed. fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) { // Never `ctx.input(|i| i.time)` here: `Context::run_logic` leaves events, pointer state and // time at the previous full pass's values (only the viewport information is refreshed). @@ -2729,6 +2751,7 @@ impl eframe::App for EffectcraftApp { self.finish_ime_logic(ctx); } + /// Draw one UI pass through `draw`. fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) { self.draw(ui, frame); } diff --git a/crates/ui-egui/src/menu_keys.rs b/crates/ui-egui/src/menu_keys.rs index 71e3b6425..49d07301c 100644 --- a/crates/ui-egui/src/menu_keys.rs +++ b/crates/ui-egui/src/menu_keys.rs @@ -60,15 +60,24 @@ struct KeyOpened { pointer: Option, } +/// The temp-data id of the [`FirstEntry`] depth to highlight after a keyboard-opened menu is +/// first drawn. fn pending_id() -> Id { Id::unique("menu-keys-first") } + +/// The temp-data id of the [`KeyOpened`] record: submenus opened from the keyboard and the +/// pointer position they were opened at. fn opened_id() -> Id { Id::unique("menu-keys-opened") } + +/// The temp-data id of the flag saying the menu bar currently holds the keyboard. fn active_id() -> Id { Id::unique("menu-keys-active") } + +/// The temp-data id of the `(armed, held)` Alt-key state used to detect an Alt tap. fn alt_id() -> Id { Id::unique("menu-keys-alt") } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 5c31f5e53..4f6bacbc3 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1552,7 +1552,7 @@ pub(crate) fn panel_shown(app: &EffectcraftApp, p: PanelKind) -> bool { } } -/// The command a menu entry runs when chosen from a menu: Window ▸ closes a panel whose +/// The command a menu entry runs when chosen from a menu: Window ▸ `` closes a panel whose /// entry is checked, as in After Effects (#1630). Shortcuts and agents calling `window.panel` /// still only show the panel. pub(crate) fn menu_choice(app: &EffectcraftApp, id: String, params: Value) -> (String, Value) { @@ -1795,6 +1795,8 @@ fn system_paste_id() -> egui::Id { egui::Id::unique("menus-system-paste") } +/// The temp-data id of the flag set by a plain Space press and taken on its release, so a tap +/// runs the Space shortcut while holding Space stays the Hand tool. fn space_tap_id() -> egui::Id { egui::Id::unique("spacebar-tap") } diff --git a/crates/ui-egui/src/panels/anim_tools.rs b/crates/ui-egui/src/panels/anim_tools.rs index 17a69b807..fe4c67895 100644 --- a/crates/ui-egui/src/panels/anim_tools.rs +++ b/crates/ui-egui/src/panels/anim_tools.rs @@ -31,6 +31,8 @@ fn choice(app: &mut EffectcraftApp, ui: &mut egui::Ui, r: Rect, auto: &str, opti widgets::popup_menu(ui, id, r.left_bottom(), &opts, Some(cur)).filter(|i| *i != cur) } +/// 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 { 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); @@ -38,6 +40,8 @@ fn number(app: &mut EffectcraftApp, ui: &mut egui::Ui, at: egui::Pos2, auto: &st nv.unwrap_or(v) } +/// A checkbox at `at` with its `text` label drawn to its right; returns the state to store, i.e. +/// `!on` when it was clicked and `on` otherwise. fn checkbox(app: &mut EffectcraftApp, ui: &mut egui::Ui, at: egui::Pos2, auto: &str, text: &str, on: bool) -> bool { let t = app.tokens; let r = Rect::from_min_size(at, vec2(16.0, 16.0)); @@ -218,6 +222,8 @@ struct Capture { points: Vec<[f64; 3]>, } +/// The `egui::Id` of the Motion Sketch capture in progress ([`Capture`]): its start time and the +/// recorded points. fn capture_id() -> egui::Id { egui::Id::unique("motion-sketch-capture") } diff --git a/crates/ui-egui/src/panels/comp_settings.rs b/crates/ui-egui/src/panels/comp_settings.rs index 6aff37823..1dc6fad37 100644 --- a/crates/ui-egui/src/panels/comp_settings.rs +++ b/crates/ui-egui/src/panels/comp_settings.rs @@ -181,6 +181,8 @@ fn timecode_field(ui: &mut egui::Ui, buf: &mut Option, secs: &mut f64, r r } +/// The temp-data id of the one-shot flag that makes the Save Preset name field take focus when +/// it is next drawn. fn name_focus_id() -> egui::Id { egui::Id::unique("comp-preset-name-focus") } diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index fd7d43f26..47de23d59 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -287,6 +287,10 @@ const ABOUT_TABS: [(&str, &str); 3] = [("About", "about"), ("Contributors", "con /// Height of the Contributors / Models tab bodies (they scroll inside it). const CREDITS_HEIGHT: f32 = 380.0; +/// The About dialog: tabs for the name/version/blurb, the contributors and the models (the last +/// two scroll in a fixed-height body), the licence and bundled-font line, and Close. The tab is +/// kept in egui temp data. A command the About body asked for is run once the modal has been +/// built; Close clears `app.dialog`. fn about(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { let mut close = false; let mut cmd: Option<&str> = None; diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index fd905b191..04f0a53a0 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -670,6 +670,11 @@ fn toggle_closed(app: &mut EffectcraftApp, g: &PropGroup, open: bool, all: bool) } } +/// Lays out one property group's rows below `*y` (which it advances), recursing into open +/// subgroups: one row per visible property with its controls, plus the dial or slider row when its +/// slider is open, and an inline editor for groups that have one. Rows scrolled out of `rect` are +/// skipped without being drawn or registered, though their height is still counted. Pick-whip +/// drops are registered for each visible property; edits go on `actions`. #[allow(clippy::too_many_arguments)] fn group_rows( app: &mut EffectcraftApp, @@ -1121,6 +1126,12 @@ pub fn start_rename(ctx: &egui::Context, uid: u64, name: &str) { ctx.memory_mut(|m| m.request_focus(id.with("field"))); } +/// 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 +/// effects or presets onto the panel applies them; a right-click on empty space opens the Effect +/// menu. The rows read from a snapshot of the project, and every edit is queued and run through +/// [`crate::menus::invoke`], with failures reported in `app.ui.status`. pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let t = app.tokens; let p = ui.painter().with_clip_rect(rect); diff --git a/crates/ui-egui/src/panels/effects_presets.rs b/crates/ui-egui/src/panels/effects_presets.rs index 3e4bd9275..fa7d2ac40 100644 --- a/crates/ui-egui/src/panels/effects_presets.rs +++ b/crates/ui-egui/src/panels/effects_presets.rs @@ -264,6 +264,13 @@ fn user_presets(ctx: &egui::Context, force: bool, scan: impl FnOnce() -> Vec
 (Actions, Actions) {
     let t = app.tokens;
     let ctx = ui.ctx().clone();
diff --git a/crates/ui-egui/src/panels/expr_bar.rs b/crates/ui-egui/src/panels/expr_bar.rs
index e40d0a2d0..ce098efa0 100644
--- a/crates/ui-egui/src/panels/expr_bar.rs
+++ b/crates/ui-egui/src/panels/expr_bar.rs
@@ -23,6 +23,8 @@ struct Cache {
     current: usize,
 }
 
+/// The `egui::Id` under which the error bar's cache for composition `cid` is kept: this pass's
+/// failing expressions and which one is shown.
 fn cache_id(cid: ItemId) -> egui::Id {
     egui::Id::unique(("expr-error-bar", cid.0))
 }
@@ -46,6 +48,11 @@ pub fn height(app: &mut EffectcraftApp, ctx: &egui::Context, cid: ItemId) -> f32
     if errors(app, ctx, cid).errors.is_empty() { 0.0 } else { HEIGHT }
 }
 
+/// Draws the expression error strip for composition `cid` in `rect`: a warning, the shown error's
+/// description and ◀ n/m ▶ buttons to step through the failing expressions. Clicking the message
+/// selects that layer and property, reveals it in the Timeline and opens its expression. Does
+/// nothing when every expression evaluates. Stepping and clicking only change UI state (the
+/// cached index, selection and Timeline state), never the document.
 pub fn draw(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect, cid: ItemId) {
     let t = app.tokens;
     let ctx = ui.ctx().clone();
diff --git a/crates/ui-egui/src/panels/footage_panel.rs b/crates/ui-egui/src/panels/footage_panel.rs
index 31d9beeda..64c2ced12 100644
--- a/crates/ui-egui/src/panels/footage_panel.rs
+++ b/crates/ui-egui/src/panels/footage_panel.rs
@@ -1,7 +1,8 @@
 //! The Footage panel (double-click footage in the Project panel): the source on its own time
 //! ruler, play / pause, In and Out marks, and the Edit Target buttons — Overlay Edit and Ripple
 //! Insert Edit — that cut the marked range into the active composition at its current time.
-//! Everything runs `footage.*` commands (state in `EditorState::footage_panel`).
+//! Time, marks and edits run `footage.*` commands (state in `EditorState::footage_panel`);
+//! the Play/Pause flag is local egui state.
 
 use crate::i18n::{tr, tr_args};
 use effectcraft_engine::project::{FootageKind, ItemKind};
@@ -13,6 +14,11 @@ use super::panel_kit as kit;
 use crate::EffectcraftApp;
 use crate::theme::Tokens;
 
+/// The Footage panel for the item opened into it: the frame at the panel's current time (cached
+/// per item, time and session revision), a ruler with the in/out span and CTI that scrubs on click
+/// or drag, Play/Pause, set In/Out, and Ripple Insert / Overlay Edit into the active comp. A solid
+/// shows its colour, footage without video says so, and an empty panel explains how to open one.
+/// Time changes run `footage.*` commands; the panel's Play/Pause flag is local egui state.
 pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
     let t = app.tokens;
     let ctx = ui.ctx().clone();
diff --git a/crates/ui-egui/src/panels/forms.rs b/crates/ui-egui/src/panels/forms.rs
index 942bd9fe0..15d9100f8 100644
--- a/crates/ui-egui/src/panels/forms.rs
+++ b/crates/ui-egui/src/panels/forms.rs
@@ -152,7 +152,7 @@ fn is_pdf(p: &Value) -> bool {
 }
 
 /// File ▸ Import ▸ File… picked numbered stills that import as image sequences: ask, as After
-/// Effects' " Sequence" checkbox in its Import dialog does (rfd's native dialog can't
+/// Effects' ` Sequence` checkbox in its Import dialog does (rfd's native dialog can't
 /// carry a checkbox), with Force Alphabetical Order and the frame rate. `p`: the import's
 /// parameters. Returns whether the dialog opened. The checkbox starts off, as in After Effects:
 /// unticked, the picked files import as stills. Dropped files and agents' `file.import` don't
diff --git a/crates/ui-egui/src/panels/fx_editors.rs b/crates/ui-egui/src/panels/fx_editors.rs
index 9171349f8..b7b09dad4 100644
--- a/crates/ui-egui/src/panels/fx_editors.rs
+++ b/crates/ui-egui/src/panels/fx_editors.rs
@@ -111,6 +111,8 @@ fn tabs(
     out
 }
 
+/// A small "Reset" link centred in `r`, registered under the automation id `id`; returns whether
+/// it was clicked.
 fn reset_link(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, r: Rect, id: &str) -> bool {
     let t = app.tokens;
     let resp = ui.interact(r, egui::Id::unique(id), Sense::click());
@@ -162,6 +164,10 @@ pub fn inline_editor(
 
 const RGB_CURVES: [(&str, &str); 4] = [("master", "Master"), ("red", "Red"), ("green", "Green"), ("blue", "Blue")];
 
+/// The Lumetri RGB Curves editor for effect group `g`: channel tabs (Master, Red, Green, Blue),
+/// the selected channel's curve graph with its Reset, and the point positions registered for
+/// automation. A curve edit or Reset is queued on `actions` as a `prop.set` of that channel's
+/// curve string; the visible channel is remembered in `app.ui.fx_curve_channel` per group.
 #[allow(clippy::too_many_arguments)]
 fn rgb_curves(
     app: &mut EffectcraftApp,
@@ -269,6 +275,11 @@ fn hue_color(h: f32) -> Color32 {
     Color32::from(c)
 }
 
+/// The Lumetri Hue/Saturation Curves editor for effect group `g`: tabs for the Hue vs Sat,
+/// Hue vs Hue, Hue vs Luma, Luma vs Sat and Sat vs Sat curves, drawn as a neutral-relative offset
+/// curve over the axis' colour band, with point drag/add/remove gestures and a Reset. Changes are
+/// queued on `actions` as a `prop.set` of the selected curve's point list; the visible tab is kept
+/// in `app.ui.fx_curve_channel` per group.
 #[allow(clippy::too_many_arguments)]
 fn huesat_curves(
     app: &mut EffectcraftApp,
@@ -441,6 +452,12 @@ fn wheel_point(c: Pos2, radius: f32, phase: f32) -> Pos2 {
     pos2(c.x + radius * a.sin(), c.y - radius * a.cos())
 }
 
+/// Colorama's Output Cycle wheel for effect group `g`: the palette drawn as a ring with its stops.
+/// Clicking the ring adds a stop with the colour there, dragging moves one, and Alt-clicking or
+/// dragging one off removes it while another stop remains; the selected stop's colour can be
+/// edited below. A change queues
+/// the rewritten palette on `actions`, and for a preset palette also switches the effect to the
+/// custom palette in the same undo step.
 #[allow(clippy::too_many_arguments)]
 fn colorama_wheel(
     app: &mut EffectcraftApp,
@@ -657,6 +674,10 @@ pub fn join_map(m: &[fw::CurvePoints; 3]) -> String {
     m.iter().map(|c| c.format()).collect::>().join(" | ")
 }
 
+/// Glow's Colour Map for effect group `g`: a strip showing the gradient the glow uses (A/B colours,
+/// or the arbitrary map sampled through the loop settings). In arbitrary-map mode it also draws the
+/// RGB curve tabs and the selected channel's graph with a Reset; edits queue a `prop.set` of
+/// `arbitraryMap` on `actions`.
 #[allow(clippy::too_many_arguments)]
 fn glow_map(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, layer: &Layer, g: &PropGroup, ectx: &EvalCtx, r: Rect, actions: &mut Actions) {
     let t = app.tokens;
@@ -769,6 +790,10 @@ pub fn add_pair(v: &[(f64, f64)]) -> (f64, f64) {
     ((a.0 + ds * 0.5).rem_euclid(1.0), (a.1 + gap * 0.5).rem_euclid(1.0))
 }
 
+/// Reshape's correspondence buttons: Add Point appends a pair at the widest destination-fraction
+/// gap (or `(0, 0)` for an empty list), Remove Point drops the last stored pair, and Clear removes
+/// all pairs. A status line says whether points are automatic. Each change
+/// queues a `prop.set` of the `correspondencePoints` string on `actions`.
 #[allow(clippy::too_many_arguments)]
 fn reshape_buttons(
     app: &mut EffectcraftApp,
diff --git a/crates/ui-egui/src/panels/gradient_editor.rs b/crates/ui-egui/src/panels/gradient_editor.rs
index 9db4d6163..fe10f4ddb 100644
--- a/crates/ui-egui/src/panels/gradient_editor.rs
+++ b/crates/ui-egui/src/panels/gradient_editor.rs
@@ -246,6 +246,12 @@ fn to_color32(c: [f32; 4]) -> Color32 {
     Color32::from_rgba_unmultiplied(b(c[0]), b(c[1]), b(c[2]), b(c[3]))
 }
 
+/// The Gradient Editor dialog for the gradient in `app.dialog_state.gradient`: a stop track for
+/// the opacity and colour tracks over a checkerboard preview, the selected stop's location,
+/// opacity or colour and a Delete button (when its track has more than one stop), the
+/// mismatched-stop-count warning, and OK/Cancel. Dragging moves stops, Delete/Backspace removes the
+/// selected one unless a widget wants keyboard input. OK runs the commit path; Cancel closes the dialog and drops the
+/// draft. With no gradient target it closes the dialog.
 pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
     if app.dialog_state.gradient.target.is_none() {
         app.dialog = None;
diff --git a/crates/ui-egui/src/panels/graph.rs b/crates/ui-egui/src/panels/graph.rs
index fa082040c..66ba31470 100644
--- a/crates/ui-egui/src/panels/graph.rs
+++ b/crates/ui-egui/src/panels/graph.rs
@@ -189,6 +189,9 @@ const MORE_POPUP: &str = "graph-more";
 impl ButtonBar {
     const MORE_W: f32 = 24.0;
 
+    /// A bar starting 8 points into `bar`, stopping short of its right end by the "»" button's
+    /// width. It takes the id picked from the "»" menu in the previous frame, so the button whose
+    /// id that was reports a click on the frame after the pick.
     fn new(ctx: &egui::Context, bar: Rect) -> Self {
         let chosen = ctx.data_mut(|d| d.remove_temp::(egui::Id::unique(MORE_POPUP).with("chosen")));
         ButtonBar { x: bar.min.x + 8.0, limit: bar.max.x - 8.0 - Self::MORE_W - 4.0, hidden: Vec::new(), chosen }
@@ -233,6 +236,10 @@ impl ButtonBar {
     }
 }
 
+/// One text button of the Graph Editor's button bar at the bar's current `x`, in the toolbar row
+/// 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,
@@ -627,6 +634,8 @@ fn fit_all(app: &mut EffectcraftApp) {
     app.ui.timeline.pps = None;
 }
 
+/// The `egui::Id` of the one-shot flag [`track_open`] sets when the Graph Editor is switched on,
+/// so [`show`] frames all graphs on its next pass, once the plot rectangle is known.
 fn fit_pending_id() -> egui::Id {
     egui::Id::unique("graph-fit-pending")
 }
@@ -739,6 +748,8 @@ struct KeyGrab {
     t: f64,
 }
 
+/// The `egui::Id` of the key drag in progress ([`KeyGrab`]): where the button went down and the
+/// grabbed key's comp time when it did.
 fn key_grab_id() -> egui::Id {
     egui::Id::unique("graph-key-grab")
 }
@@ -786,6 +797,8 @@ impl HandleGrab {
 /// A handle drag: where the button went down, and the handles that move.
 type HandleDrag = (Pos2, Vec);
 
+/// The `egui::Id` of the Bezier handle drag in progress ([`HandleDrag`]): the press position and
+/// the handles the drag moves.
 fn handle_drag_id() -> egui::Id {
     egui::Id::unique("graph-handle-drag")
 }
diff --git a/crates/ui-egui/src/panels/graph_tools.rs b/crates/ui-egui/src/panels/graph_tools.rs
index ee5d7543f..7950d4a66 100644
--- a/crates/ui-egui/src/panels/graph_tools.rs
+++ b/crates/ui-egui/src/panels/graph_tools.rs
@@ -34,6 +34,7 @@ struct BoxDrag {
     last: [f64; 2],
 }
 
+/// The `egui::Id` of the transform-box drag in progress ([`BoxDrag`]).
 fn box_id() -> egui::Id {
     egui::Id::unique("graph-transform-box")
 }
@@ -254,6 +255,7 @@ struct AltScale {
     span: Option,
 }
 
+/// The `egui::Id` of the Timeline Alt-drag group scale in progress ([`AltScale`]).
 fn alt_id() -> egui::Id {
     egui::Id::unique("timeline-alt-scale")
 }
diff --git a/crates/ui-egui/src/panels/info.rs b/crates/ui-egui/src/panels/info.rs
index a075087d3..86a1e6444 100644
--- a/crates/ui-egui/src/panels/info.rs
+++ b/crates/ui-egui/src/panels/info.rs
@@ -6,6 +6,11 @@ use egui::{Align2, Color32, Rect, pos2, vec2};
 use crate::theme::Tokens;
 use crate::{EffectcraftApp, widgets};
 
+/// The Info panel: the colour and alpha under the pointer (sampled from the shown viewer frame,
+/// read back for GPU frames) with its position and a swatch, then the selected layers, the current
+/// time, the duration/In/Out of a single selected layer, the achieved preview frame rate, the
+/// render time and queue state (when the preference asks for it) and the selected keyframe's
+/// property, time and value. Read-only: it draws and scrolls but changes no document state.
 pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
     let t = app.tokens;
     let p = ui.painter().with_clip_rect(rect);
diff --git a/crates/ui-egui/src/panels/layer_panel.rs b/crates/ui-egui/src/panels/layer_panel.rs
index 2ebb2dee0..f6157da47 100644
--- a/crates/ui-egui/src/panels/layer_panel.rs
+++ b/crates/ui-egui/src/panels/layer_panel.rs
@@ -40,10 +40,14 @@ struct Tex {
     rect: [f64; 4],
 }
 
+/// The `egui::Id` of the cached render of the layer shown in this panel ([`Tex`]), with the key
+/// the image was made from.
 fn tex_id() -> egui::Id {
     egui::Id::unique("layer-panel-tex")
 }
 
+/// The `egui::Id` under which the panel's last Fit magnification is kept, so View ▸ Zoom In/Out
+/// can start from it (see [`last_fit`]).
 fn fit_id() -> egui::Id {
     egui::Id::unique("layer-panel-fit")
 }
@@ -83,6 +87,13 @@ fn default_view(layer: &Layer, tool: Tool) -> usize {
     }
 }
 
+/// 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
+/// Space-drag pans, and the Zoom tool clicks zoom. The paint tools record a stroke's layer-space
+/// points while dragging and run `paint.stroke` on release (or on a click); Alt-click with the
+/// Clone Stamp sets its source. With a Roto Brush effect it hands the canvas to
+/// [`super::roto_tool`]. Without a current layer it shows how to open one.
 pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
     let t = app.tokens;
     let ctx = ui.ctx().clone();
diff --git a/crates/ui-egui/src/panels/layer_styles_dialog.rs b/crates/ui-egui/src/panels/layer_styles_dialog.rs
index e687dffa1..a4af4dae2 100644
--- a/crates/ui-egui/src/panels/layer_styles_dialog.rs
+++ b/crates/ui-egui/src/panels/layer_styles_dialog.rs
@@ -61,7 +61,7 @@ pub fn open(app: &mut EffectcraftApp, params: &Value) -> Result<(), String> {
     Ok(())
 }
 
-/// Menu routing: choosing one of the Layer ▸ Layer Styles ▸