mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-27 11:37:02 -06:00
Merge pull request #2437 from storytold/codex/egui-037-effect-followup-20261025
Preserve migration regressions and fix software GPU median timeouts
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 | `<input type=file>` 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.
|
||||
|
||||
|
||||
@@ -148,6 +148,10 @@ fn install_file_dialogs(app: &mut EffectcraftApp) -> Option<std::sync::mpsc::Rec
|
||||
}
|
||||
}
|
||||
|
||||
/// Run a normal desktop launch: install logging and the panic hook, parse arguments (`--version` exits before the
|
||||
/// session log is attached), scan fonts, honor the launch guard's backend choice and open the eframe window.
|
||||
/// Builds the session, loads settings, plugins, the requested project/media and the optional control server, then
|
||||
/// reports startup failure through `startup_failed`. Returns the event loop's result.
|
||||
fn main() -> 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);
|
||||
|
||||
@@ -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")));
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -359,7 +359,7 @@ pub(crate) fn selection_names(sel: &str) -> Vec<String> {
|
||||
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
|
||||
/// `<hex>` hashes / wildcards.
|
||||
pub fn selection_hashes(sel: &str, manifest: Option<&[(String, u32)]>) -> Vec<u32> {
|
||||
let mut out = vec![];
|
||||
|
||||
@@ -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<ColoramaPalette> {
|
||||
let mut stops: Vec<(f32, [f32; 4])> = s
|
||||
.split_whitespace()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Seg>, 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<PaintPlan> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<Seg>,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Lut>, shaper: Option<Arc<[Vec<(f32, f32)>; 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)?;
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<FoamDisc>)>,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -354,7 +354,7 @@ fn assign_view_shortcut(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
reassign(s, view_command(cur), &slot, "view.assign3dShortcut")
|
||||
}
|
||||
|
||||
/// Window ▸ Assign Shortcut to "<workspace>" Workspace: Shift+F10, Shift+F11 or Shift+F12.
|
||||
/// Window ▸ Assign Shortcut to `"<workspace>"` 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<Value> {
|
||||
let slot = slot_p(p, "window.assignWorkspaceShortcut", true)?;
|
||||
@@ -668,6 +668,9 @@ fn extract_cubemap(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<CommandSpec> {
|
||||
vec![
|
||||
cmd!("edit.history", "History", [], None, "{steps?: n (undo n steps), redo?: n}", has_history, history),
|
||||
|
||||
@@ -51,6 +51,17 @@ fn substitute(v: &Value, results: &[Value]) -> Result<Value> {
|
||||
})
|
||||
}
|
||||
|
||||
/// `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<Value> {
|
||||
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();
|
||||
|
||||
@@ -149,6 +149,16 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!(new.iter().map(|l| l.0).collect::<Vec<_>>()))
|
||||
}
|
||||
|
||||
/// `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<Value> {
|
||||
if s.state.text_edit.is_some() && p.get("layers").is_none() {
|
||||
return s.execute("text.delete", json!({}));
|
||||
|
||||
@@ -162,6 +162,15 @@ fn text_range_p(p: &Value) -> Option<std::ops::Range<usize>> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
let (cid, lid) = layer_p(s, p, "layer.setText")?;
|
||||
// The Character panel's recent fonts (Settings ▸ Type ▸ Number of Recent Fonts to Display).
|
||||
|
||||
@@ -234,7 +234,7 @@ fn uninstall(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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 ▸ <name>). A ScriptUI panel run
|
||||
/// Run an installed or bundled script by name (File ▸ Scripts ▸ `<name>`). 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<Value> {
|
||||
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<Value> {
|
||||
Ok(json!({"opened": opened, "failed": failed}))
|
||||
}
|
||||
|
||||
/// Window ▸ <ScriptUI panel>: run the panel script (or bring its panel forward).
|
||||
/// Window ▸ `<ScriptUI panel>`: run the panel script (or bring its panel forward).
|
||||
fn open_panel(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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();
|
||||
|
||||
@@ -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<u64> {
|
||||
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<u64> {
|
||||
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<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<Branch>) {
|
||||
let mut steps = vec![];
|
||||
let mut n = start;
|
||||
|
||||
@@ -564,6 +564,10 @@ pub type ScriptRunner = fn(&mut Session, &ScriptRequest) -> Value;
|
||||
pub type PluginLoader = fn(&[u8], &str) -> std::result::Result<Value, String>;
|
||||
|
||||
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<T>(&mut self, label: &str, merge: Option<&str>, f: impl FnOnce(&mut Project, &mut EditorState) -> Result<T>) -> Result<T> {
|
||||
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)];
|
||||
|
||||
@@ -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<Arc<dyn Fn(&str) -> Option<(u64, u64)> + Send + Sync>>) {
|
||||
self.virtual_id = id;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -20,7 +20,7 @@ use crate::{Importer, Services};
|
||||
/// How an import treats numbered stills.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct SequenceOptions {
|
||||
/// The "<format> Sequence" checkbox: picked numbered stills of one run import as a sequence.
|
||||
/// The `"<format> 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 "<format> Sequence" checkbox (off
|
||||
/// (Alt-drag of a folder). The Import dialog passes its `"<format> 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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<GpuContext, String> {
|
||||
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,
|
||||
|
||||
@@ -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<Self, String> {
|
||||
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<GBuf> {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -82,69 +82,6 @@ fn fxn_median(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
textureStore(out, p, vec4<f32>(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<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);
|
||||
var hist: array<u32, 2048>;
|
||||
for (var dy = -r; dy <= r; dy++) {
|
||||
for (var dx = -r; dx <= r; dx++) {
|
||||
let v = vec4<u32>(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<u32>(0u);
|
||||
var lt = vec4<u32>(0u);
|
||||
for (var x = x0; x < x1; x++) {
|
||||
if (x > x0) {
|
||||
for (var dy = -r; dy <= r; dy++) {
|
||||
let o = vec4<u32>(tex_get_clamped(src, x - 1 - r, y + dy));
|
||||
let n = vec4<u32>(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<i32>(x, y), vec4<f32>(data[med.x], data[med.y], data[med.z], data[med.w]));
|
||||
}
|
||||
}
|
||||
|
||||
// noise::median_px.
|
||||
fn fxn_median_px(o: vec4<f32>, m: vec4<f32>, on_alpha: bool) -> vec4<f32> {
|
||||
if (on_alpha) {
|
||||
|
||||
@@ -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<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);
|
||||
var hist: array<u32, 2048>;
|
||||
for (var dy = -r; dy <= r; dy++) {
|
||||
for (var dx = -r; dx <= r; dx++) {
|
||||
let v = vec4<u32>(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<u32>(0u);
|
||||
var lt = vec4<u32>(0u);
|
||||
for (var x = x0; x < x1; x++) {
|
||||
if (x > x0) {
|
||||
for (var dy = -r; dy <= r; dy++) {
|
||||
let o = vec4<u32>(tex_get_clamped(src, x - 1 - r, y + dy));
|
||||
let n = vec4<u32>(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<i32>(x, y), vec4<f32>(data[med.x], data[med.y], data[med.z], data[med.w]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<workgroup> median_hist: array<array<u32, 2048>, 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<u32>, @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<u32>(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<u32>(0u);
|
||||
var lt = vec4<u32>(0u);
|
||||
for (var x = x0; x < x1; x++) {
|
||||
if (x > x0) {
|
||||
for (var dy = -r; dy <= r; dy++) {
|
||||
let o = vec4<u32>(tex_get_clamped(src, x - 1 - r, y + dy));
|
||||
let n = vec4<u32>(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<i32>(x, y), vec4<f32>(data[med.x], data[med.y], data[med.z], data[med.w]));
|
||||
}
|
||||
}
|
||||
|
||||
+31
-2
@@ -52,9 +52,29 @@ pub(crate) fn gpu() -> Option<&'static Gpu> {
|
||||
static G: OnceLock<Result<Gpu, String>> = 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");
|
||||
}
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<image::DynamicImage> {
|
||||
|
||||
@@ -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<std::path::PathBuf> {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let folder = lock(&self.inner.conform).clone()?;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
//!
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -710,7 +710,7 @@ pub(crate) fn outcome(output: Vec<String>, result: Result<J, ScriptError>) -> 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,
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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<String>, params: Value) -> (Self, std::sync::mpsc::Receiver<Value>) {
|
||||
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<egui::Pos2, String> {
|
||||
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<String, Value>) -> 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<String, Value>
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<usize, String> {
|
||||
if method != "ui.click" {
|
||||
return Ok(3);
|
||||
@@ -394,6 +423,10 @@ fn click_gesture_events(method: &str, p: &Value) -> Result<usize, String> {
|
||||
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());
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
//!
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ▸ <panel>.
|
||||
/// give it the focus: Window ▸ `<panel>`.
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -60,15 +60,24 @@ struct KeyOpened {
|
||||
pointer: Option<Pos2>,
|
||||
}
|
||||
|
||||
/// 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")
|
||||
}
|
||||
|
||||
@@ -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 ▸ <panel> closes a panel whose
|
||||
/// The command a menu entry runs when chosen from a menu: Window ▸ `<panel>` 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")
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -181,6 +181,8 @@ fn timecode_field(ui: &mut egui::Ui, buf: &mut Option<String>, 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")
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -264,6 +264,13 @@ fn user_presets(ctx: &egui::Context, force: bool, scan: impl FnOnce() -> Vec<Pre
|
||||
v
|
||||
}
|
||||
|
||||
/// The Effects & Presets panel: search, a contents menu (colour-depth and GPU filters, whether
|
||||
/// presets show, categories or one alphabetical list, refresh and clear of the recently used
|
||||
/// list), then Favourites, Recently Used, Animation Presets and the effects by category or
|
||||
/// alphabetically, filtered by the query. Picking an effect or preset applies it to the selection
|
||||
/// (`effect.apply` or the preset's own command); applying an effect also records it as recently
|
||||
/// used and opens Effect Controls. Without a selected layer it only reports that in
|
||||
/// `app.ui.status`.
|
||||
pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let t = app.tokens;
|
||||
let ctx = ui.ctx().clone();
|
||||
|
||||
@@ -56,6 +56,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The Essential Graphics panel's contents for the Primary (else active) comp: its name and Solo
|
||||
/// toggle, the template name, the controls list with its drop target for Timeline properties, the
|
||||
/// Add Formatting / Add Selected As / Media Replacement / Export Template buttons and the selected
|
||||
/// instance's properties. Returns the `essential.*` commands to run directly and the ones that go
|
||||
/// through the menu/frontend layer; with no comp it shows a hint and returns empty lists.
|
||||
fn body(app: &mut EffectcraftApp, ui: &mut egui::Ui) -> (Actions, Actions) {
|
||||
let t = app.tokens;
|
||||
let ctx = ui.ctx().clone();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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' "<format> Sequence" checkbox in its Import dialog does (rfd's native dialog can't
|
||||
/// Effects' `<format> 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
|
||||
|
||||
@@ -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::<Vec<_>>().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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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::<String>(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<HandleGrab>);
|
||||
|
||||
/// 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")
|
||||
}
|
||||
|
||||
@@ -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<f64>,
|
||||
}
|
||||
|
||||
/// The `egui::Id` of the Timeline Alt-drag group scale in progress ([`AltScale`]).
|
||||
fn alt_id() -> egui::Id {
|
||||
egui::Id::unique("timeline-alt-scale")
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -61,7 +61,7 @@ pub fn open(app: &mut EffectcraftApp, params: &Value) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Menu routing: choosing one of the Layer ▸ Layer Styles ▸ <style> items (no parameters) adds
|
||||
/// Menu routing: choosing one of the Layer ▸ Layer Styles ▸ `<style>` items (no parameters) adds
|
||||
/// the style as usual and opens the dialog on it.
|
||||
pub fn route(app: &mut EffectcraftApp, id: &str, params: &Value) -> Result<bool, String> {
|
||||
let Some(style) = id.strip_prefix("layer.style.").filter(|s| STYLES.iter().any(|x| x.0 == *s)) else { return Ok(false) };
|
||||
|
||||
@@ -243,6 +243,8 @@ pub fn dialog(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
|
||||
|
||||
// ---------------------------------------------------------------- timeline drawing + gestures
|
||||
|
||||
/// The `egui::Id` of a marker drag in progress: the dragged marker's layer and index (updated as
|
||||
/// the marker moves) and whether Alt was held when the drag started.
|
||||
fn drag_state_id() -> egui::Id {
|
||||
egui::Id::unique("marker-drag")
|
||||
}
|
||||
|
||||
@@ -63,6 +63,12 @@ fn thumbnail(app: &EffectcraftApp, ctx: &egui::Context, path: &str, budget: &mut
|
||||
thumb
|
||||
}
|
||||
|
||||
/// The Media Browser panel: a toolbar (up, home, favourite, "Media only" filter and the browser's
|
||||
/// own actions), the current path, the favourite places and a tile grid of the folder's entries
|
||||
/// with thumbnails for images and videos. Double-click opens a folder or imports a file; files
|
||||
/// drag out as [`DragPayload::Files`] and a context menu copies paths, imports (optionally into the
|
||||
/// comp) or adds a favourite. The listing is cached per folder and filter for a second, and every
|
||||
/// change runs a `mediaBrowser.*` command.
|
||||
pub fn media_browser(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let t = app.tokens;
|
||||
let ctx = ui.ctx().clone();
|
||||
|
||||
@@ -362,6 +362,10 @@ pub fn audio(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The History panel: every state of the session's branching history, indented under the state it
|
||||
/// grew from, with the current state highlighted and future/abandoned branches dimmed. Only the
|
||||
/// rows in view are drawn; the current state is scrolled into view when it changes. Clicking a
|
||||
/// state runs `edit.history.goto`, reporting a failure in `app.ui.status`.
|
||||
pub fn history(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let t = app.tokens;
|
||||
let p = ui.painter().with_clip_rect(rect);
|
||||
@@ -419,6 +423,9 @@ pub fn history(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The Markers panel: the active comp's markers in time order, each with its timecode and comment;
|
||||
/// clicking a row moves the current time to that marker. Shows a hint when the comp has none and
|
||||
/// draws nothing without an active comp.
|
||||
pub fn markers(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let t = app.tokens;
|
||||
let p = ui.painter().with_clip_rect(rect);
|
||||
|
||||
@@ -41,6 +41,9 @@ fn number(
|
||||
nv.filter(|_| enabled)
|
||||
}
|
||||
|
||||
/// A label at `x`, `y` and, to its right, a dropdown over `options` with `cur` selected. The
|
||||
/// dropdown only opens while `enabled`; otherwise only its label is dimmed. Returns the index
|
||||
/// picked from the open menu, if any.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn popup_row(
|
||||
app: &mut EffectcraftApp,
|
||||
@@ -66,6 +69,7 @@ fn popup_row(
|
||||
widgets::popup_menu(ui, pop, r.left_bottom(), options, Some(cur))
|
||||
}
|
||||
|
||||
/// The RGB components of an RGBA paint colour as `f32`, for the swatches and the colour picker.
|
||||
fn rgb(c: [f64; 4]) -> [f32; 3] {
|
||||
[c[0] as f32, c[1] as f32, c[2] as f32]
|
||||
}
|
||||
@@ -79,6 +83,10 @@ fn paint_swap_icon(p: &egui::Painter, rect: Rect, color: Color32) {
|
||||
p.arrow(pos2(right, bottom), vec2(left - right, 0.0), stroke);
|
||||
}
|
||||
|
||||
/// The Paint panel: opacity and flow, foreground/background colours with swap and reset, mode,
|
||||
/// channels, duration, erase mode and the Clone Stamp options. Every change is sent as a
|
||||
/// `paint.options` command. Mode and Channels are disabled for the Eraser; Erase is enabled
|
||||
/// only for it. The Clone Options heading is dimmed for other tools, but its controls stay enabled.
|
||||
pub fn paint(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let t = app.tokens;
|
||||
let p = ui.painter().with_clip_rect(rect);
|
||||
@@ -286,6 +294,9 @@ fn tip_preview(p: &egui::Painter, r: Rect, diameter: f64, angle: f64, roundness:
|
||||
}
|
||||
}
|
||||
|
||||
/// The Brushes panel: a grid of built-in brush presets (each runs `paint.brushPreset`), a tip
|
||||
/// preview, diameter/angle/roundness/hardness/spacing fields and the size/opacity/flow pressure
|
||||
/// options. Values are sent as `paint.options` commands and failures land in `app.ui.status`.
|
||||
pub fn brushes(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let t = app.tokens;
|
||||
let p = ui.painter().with_clip_rect(rect);
|
||||
|
||||
@@ -191,6 +191,8 @@ fn fit(r: Rect, w: f32, h: f32) -> Rect {
|
||||
|
||||
/// An inline text edit in progress: (item, field `name`|`comment`, text).
|
||||
type Editing = (u64, String, String);
|
||||
/// The `egui::Id` of the Project panel's inline edit in progress ([`Editing`]): the item, which
|
||||
/// field (`name` or `comment`) and the text being typed.
|
||||
fn edit_id() -> egui::Id {
|
||||
egui::Id::unique("proj-inline-edit")
|
||||
}
|
||||
@@ -379,6 +381,11 @@ pub(crate) fn reveal(app: &mut EffectcraftApp, ctx: &egui::Context, items: &[Ite
|
||||
app.show_panel(crate::dock::PanelKind::Project);
|
||||
}
|
||||
|
||||
/// The Project panel: the selected item's thumbnail and details (with the comps that use it), the
|
||||
/// search field, sortable column headers, and the item rows (folders, comps, footage and solids)
|
||||
/// with selection, Shift/Cmd ranges, keyboard navigation, inline rename, drag-and-drop move,
|
||||
/// marquee selection and context menus. Only the rows in view are drawn. File commands are queued
|
||||
/// and run through [`crate::menus::invoke`] at the end, with failures shown in `app.ui.status`.
|
||||
pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let t = app.tokens;
|
||||
let p = ui.painter().clone();
|
||||
|
||||
@@ -51,6 +51,12 @@ fn decimals(v: f64) -> usize {
|
||||
if (v - v.round()).abs() < 1e-6 { 0 } else { 1 }
|
||||
}
|
||||
|
||||
/// The Properties panel for the first selected layer: "N Selected Objects" when several are
|
||||
/// selected, the Layer Transform section (with Reset) and, for a text layer, the text, paragraph
|
||||
/// and Text Animation sections that edit the range being edited in the viewer. Rows are evaluated
|
||||
/// against a snapshot of the project; every row edit is queued and run through
|
||||
/// [`crate::menus::invoke`] at the end (range-scoped for `layer.setText`), with failures reported
|
||||
/// in `app.ui.status`. Without a selected layer it shows a hint.
|
||||
pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let t = app.tokens;
|
||||
let p = ui.painter().with_clip_rect(rect);
|
||||
|
||||
@@ -415,6 +415,11 @@ fn pick_output(app: &EffectcraftApp, it: &RenderQueueItem) -> Option<Option<Stri
|
||||
app.hooks.save_dialog(&app.session.resolve_output(it).unwrap_or_default())
|
||||
}
|
||||
|
||||
/// The Render Queue panel: the Current Render strip (elapsed and remaining time, Render/Stop and
|
||||
/// Notify, plus the progress bar), the column header, the queued items with their output template,
|
||||
/// status and timings, and the footer totals. Comps dragged from the Project are queued on
|
||||
/// release. The open items and the selected item are kept in egui temp data; output-template forms
|
||||
/// open through a small form, and every change runs a `renderQueue.*` command.
|
||||
pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let t = app.tokens;
|
||||
let p = ui.painter().with_clip_rect(rect);
|
||||
|
||||
@@ -21,6 +21,8 @@ struct State {
|
||||
new_name: String,
|
||||
}
|
||||
|
||||
/// The `egui::Id` of the render-settings / output-module template dialog's state ([`State`]):
|
||||
/// which kind it edits, the selected template and the name being typed.
|
||||
fn state_id() -> egui::Id {
|
||||
egui::Id::unique("rq-templates-dialog")
|
||||
}
|
||||
|
||||
@@ -38,6 +38,10 @@ struct Cached {
|
||||
tex: Option<egui::TextureHandle>,
|
||||
}
|
||||
|
||||
/// The Scopes panel: the scope kind, colour standard, 8-bit/Float scale and Clamp Signal controls,
|
||||
/// then the chosen scope for the active comp's frame at the current time, cached per revision,
|
||||
/// comp, time, settings and plot size (drawn as a texture, or bars for the histogram) over its
|
||||
/// graticule. The controls update `app.ui.scopes`; the scope itself is only computed and drawn.
|
||||
pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let t = app.tokens;
|
||||
let p = ui.painter().with_clip_rect(rect);
|
||||
|
||||
@@ -74,6 +74,9 @@ pub fn run(app: &mut EffectcraftApp, ctx: &egui::Context) {
|
||||
con.input.clear();
|
||||
}
|
||||
|
||||
/// The Script Console panel: the output log (newest lines at the bottom, coloured by kind), the
|
||||
/// multi-line script input and Run/Clear. Run — the button or ⌘/Ctrl+Enter while typing — hands the
|
||||
/// input to the script runner; Clear only empties the log. The input text and log are UI state.
|
||||
pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let t = app.tokens;
|
||||
let input_h = 86.0;
|
||||
|
||||
@@ -81,6 +81,11 @@ fn draw_children(app: &mut EffectcraftApp, ui: &mut egui::Ui, origin: egui::Pos2
|
||||
}
|
||||
}
|
||||
|
||||
/// Draws ScriptUI widget `w` of window `win` at its bounds offset by `origin`, registering it for
|
||||
/// automation, and recurses into containers. Widgets with an `onDraw` handler are painted by the
|
||||
/// script (replacing the default look, or on top of it when it called `drawOSControl()`) while
|
||||
/// buttons, checkboxes and radios still take clicks. Edits and clicks are appended to `acts` for
|
||||
/// [`perform`]; while a field or slider is in use its live value lives in egui temp data.
|
||||
fn draw_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, origin: egui::Pos2, win: u32, w: &Widget, acts: &mut Vec<Act>) {
|
||||
let t = app.tokens;
|
||||
let services = app.session.services.clone();
|
||||
@@ -471,6 +476,9 @@ fn snug(r: Rect, text: &str) -> Rect {
|
||||
Rect::from_min_size(r.min, vec2(w, r.height()))
|
||||
}
|
||||
|
||||
/// Runs the actions collected while drawing a ScriptUI window as `scriptui.*` commands, in order.
|
||||
/// A reply carrying an `error` becomes a toast with the script's message; otherwise a non-empty
|
||||
/// `output` becomes the status line (its last line). Command failures also go to `app.ui.status`.
|
||||
fn perform(app: &mut EffectcraftApp, ctx: &egui::Context, acts: Vec<Act>) {
|
||||
for a in acts {
|
||||
let (cmd, p) = match a {
|
||||
@@ -546,7 +554,7 @@ pub fn show_windows(app: &mut EffectcraftApp, ctx: &egui::Context) {
|
||||
perform(app, ctx, acts);
|
||||
}
|
||||
|
||||
/// A dockable ScriptUI panel's content (Window ▸ <panel>.jsx).
|
||||
/// A dockable ScriptUI panel's content (Window ▸ `<panel>.jsx`).
|
||||
pub fn panel(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect, id: u32) {
|
||||
let t = app.tokens;
|
||||
let Some(mut w) = app.session.script_ui.window(id).cloned() else {
|
||||
|
||||
@@ -200,6 +200,12 @@ const SIDE_KEYS: &[(&str, &str, f32, f32)] = &[
|
||||
("→", "ArrowRight", 2.0, 5.0),
|
||||
];
|
||||
|
||||
/// The Keyboard Shortcuts dialog: while a shortcut field listens it records the next key
|
||||
/// combination (dropping those key events so the field does not type them), the preset bar switches
|
||||
/// keymaps, the drawn keyboard lists what the selected key runs, and the command list and editor
|
||||
/// remove, assign and reset bindings, warning about conflicts. Enter is OK unless a rename field, a
|
||||
/// preset menu or the delete confirmation is open. The collected `shortcuts.*` commands are run
|
||||
/// with the session when the frame ends; OK closes the dialog.
|
||||
pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
|
||||
let mut st = std::mem::take(&mut app.dialog_state.shortcuts);
|
||||
let mut run: Vec<(String, Value)> = vec![];
|
||||
|
||||
@@ -61,6 +61,9 @@ pub fn text_target(app: &EffectcraftApp) -> Option<TextTarget> {
|
||||
effectcraft_engine::render::text::source_text(&ectx, layer).map(|d| TextTarget { layer: layer.id.0, doc: d, range: None })
|
||||
}
|
||||
|
||||
/// A small toggle button at `r` showing `label` (semibold 13 when `bold`, else 12), filled with
|
||||
/// the accent while `on`; returns whether it was clicked. It only reports the click: the caller
|
||||
/// decides the new state.
|
||||
fn toggle(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, r: Rect, id: &str, label: &str, on: bool, bold: bool) -> bool {
|
||||
let t = app.tokens;
|
||||
let resp = ui.interact(r, egui::Id::unique(("text-toggle", id)), Sense::click());
|
||||
@@ -81,6 +84,12 @@ fn toggle(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, r: Rec
|
||||
resp.clicked()
|
||||
}
|
||||
|
||||
/// The Character panel for the text being edited (or the selected text layer): font family and
|
||||
/// style, size, leading, tracking, stroke, scale, baseline shift and other numeric fields, kerning,
|
||||
/// fill/stroke colours with eyedroppers, style toggles, OpenType features and variable-font axes.
|
||||
/// Without a text target the panel is inert — no edit is collected and it asks for a text layer.
|
||||
/// Edits are collected and, when a target exists, sent as `layer.setText` commands scoped to the
|
||||
/// edited range; failures show in `app.ui.status`.
|
||||
pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let t = app.tokens;
|
||||
let p = ui.painter().with_clip_rect(rect);
|
||||
@@ -452,6 +461,10 @@ pub fn paint_justify_glyph(p: &egui::Painter, r: Rect, j: Justify, c: egui::Colo
|
||||
}
|
||||
}
|
||||
|
||||
/// The Paragraph panel: alignment, left/right/first-line indents, space before and after,
|
||||
/// paragraph direction (when the text engine allows it), the composer, and Roman hanging
|
||||
/// punctuation. Edits are queued and sent as `layer.setText` for the edited range when a text
|
||||
/// target exists; failures show in `app.ui.status`.
|
||||
pub fn paragraph(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let t = app.tokens;
|
||||
let p = ui.painter().with_clip_rect(rect);
|
||||
|
||||
@@ -163,6 +163,8 @@ const WHIP_PARENT: u8 = 0;
|
||||
const WHIP_EXPR: u8 = 1;
|
||||
const WHIP_PROP: u8 = 2;
|
||||
|
||||
/// The `egui::Id` under which a pick-whip drag in progress ([`PickWhip`]) is kept during the
|
||||
/// drag.
|
||||
fn pick_whip_id() -> egui::Id {
|
||||
egui::Id::unique("tl-pickwhip")
|
||||
}
|
||||
@@ -194,6 +196,8 @@ fn pick_whip(
|
||||
/// (row, layer, prop uid, its value fields per dimension), and the pass they were shown in.
|
||||
type WhipTargets = (u64, Vec<(Rect, u64, u64, Vec<Rect>)>);
|
||||
|
||||
/// The `egui::Id` of the drop targets other panels register for a pick-whip drag
|
||||
/// ([`whip_target`]); [`show`] clears it when the whip is released or dropped.
|
||||
fn whip_targets_id() -> egui::Id {
|
||||
egui::Id::unique("tl-pickwhip-targets")
|
||||
}
|
||||
@@ -263,6 +267,8 @@ fn centered_on(span: f64, duration: f64, cti: f64) -> f64 {
|
||||
/// it (characters): the pick whip's reference goes in there.
|
||||
type WhipInto = (String, [usize; 2]);
|
||||
|
||||
/// The `egui::Id` under which the expression text captured by an expression pick whip
|
||||
/// ([`WhipInto`]) is held for property `prop` until that editor is drawn again.
|
||||
fn whip_into_id(prop: u64) -> egui::Id {
|
||||
egui::Id::unique(("tl-pickwhip-into", prop))
|
||||
}
|
||||
@@ -279,6 +285,8 @@ struct ExprFocus {
|
||||
pass: u64,
|
||||
}
|
||||
|
||||
/// The `egui::Id` of the pending [`ExprFocus`]: which expression editor takes the keyboard on
|
||||
/// its next draw, and what to select in it.
|
||||
fn expr_focus_id() -> egui::Id {
|
||||
egui::Id::unique("tl-expr-focus")
|
||||
}
|
||||
@@ -560,6 +568,8 @@ fn ruler_label(fno: i64, fps_i: i64, settings: &effectcraft_engine::project::Pro
|
||||
}
|
||||
}
|
||||
|
||||
/// The `egui::Id` under which [`show`] records this frame's time mapping as `(x0, start, pps)`,
|
||||
/// i.e. the [`TMap`] fields it lays the ruler and graph out with.
|
||||
fn tl_map_id() -> egui::Id {
|
||||
egui::Id::unique("timeline-map")
|
||||
}
|
||||
@@ -713,6 +723,8 @@ fn group_rename_id() -> egui::Id {
|
||||
egui::Id::unique("tl-group-rename")
|
||||
}
|
||||
|
||||
/// The `egui::Id` of the one-shot flag that makes the open layer rename field take the keyboard
|
||||
/// on its first frame (see [`start_rename`]).
|
||||
fn rename_focus_id() -> egui::Id {
|
||||
egui::Id::unique("tl-rename-focus")
|
||||
}
|
||||
@@ -1290,6 +1302,8 @@ struct KeyDrag {
|
||||
quick: Option<usize>,
|
||||
}
|
||||
|
||||
/// The `egui::Id` of the keyframe drag in progress ([`KeyDrag`]): its start, the grabbed key's
|
||||
/// time and how far the selection has moved so far.
|
||||
fn key_drag_id() -> egui::Id {
|
||||
egui::Id::unique("tl-key-drag")
|
||||
}
|
||||
@@ -1304,6 +1318,7 @@ struct QuickBar {
|
||||
applied: i64,
|
||||
}
|
||||
|
||||
/// The `egui::Id` of a Quick Offset drag of layer bars in progress ([`QuickBar`]).
|
||||
fn quick_bar_id() -> egui::Id {
|
||||
egui::Id::unique("tl-quick-bar")
|
||||
}
|
||||
@@ -1345,6 +1360,8 @@ struct SwitchDrag {
|
||||
merge: String,
|
||||
}
|
||||
|
||||
/// The `egui::Id` of the switch press-and-drag in progress ([`SwitchDrag`]): the switch, the
|
||||
/// state the press gave it and the undo step the drag folds into.
|
||||
fn switch_drag_id() -> egui::Id {
|
||||
egui::Id::unique("tl-switch-drag")
|
||||
}
|
||||
@@ -1438,6 +1455,8 @@ fn layer_switch(
|
||||
}
|
||||
}
|
||||
|
||||
/// The `egui::Id` under which composition `comp` remembers the layer the last click selected, so
|
||||
/// a following Shift-click can select the range from there ([`shift_range`]).
|
||||
fn anchor_id(comp: ItemId) -> egui::Id {
|
||||
egui::Id::unique(("timeline-selection-anchor", comp.0))
|
||||
}
|
||||
@@ -1471,6 +1490,8 @@ fn insertion(layer_rows: &[(Rect, LayerId)], py: f32, top: f32) -> (Option<usize
|
||||
(at, y)
|
||||
}
|
||||
|
||||
/// The `egui::Id` of the flag the row drag's release sets, telling the same frame's reorder
|
||||
/// drawing code to apply the drop.
|
||||
fn layer_drop_id() -> egui::Id {
|
||||
egui::Id::unique("tl-layer-drop")
|
||||
}
|
||||
@@ -1495,6 +1516,14 @@ fn replace_source_target(app: &EffectcraftApp, cid: ItemId, item: ItemId, lid: L
|
||||
.is_some_and(|l| !l.switches.locked && matches!(l.source, LayerSource::Footage { .. } | LayerSource::Comp { .. } | LayerSource::Solid { .. }))
|
||||
}
|
||||
|
||||
/// The Timeline panel for the active composition inside `rect`: draws the header, ruler, work
|
||||
/// area and cache bars, the layer rows with their switch and value cells, the time graph and its
|
||||
/// keys, and the footer; and turns this frame's clicks, drags and drops into commands. Without an
|
||||
/// active comp it shows a hint, and dropping footage on it creates a composition. Most edits
|
||||
/// use [`crate::menus::invoke`]; time, switch and work-area gestures call `Session::execute`
|
||||
/// directly and ignore errors. Reported failures go to `app.ui.status`. UI state (scroll, zoom,
|
||||
/// open rows and the drags in progress) lives in
|
||||
/// `app.ui.timeline` and egui's temp data.
|
||||
pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let t = app.tokens;
|
||||
let ctx = ui.ctx().clone();
|
||||
|
||||
@@ -51,6 +51,8 @@ fn current(app: &EffectcraftApp) -> Option<(LayerId, Uid)> {
|
||||
l.trackers().next().map(|(g, _)| (l.id, g.uid))
|
||||
}
|
||||
|
||||
/// The `egui::Id` of the Motion Source dropdown's transient state; picking a new source removes
|
||||
/// whatever is stored under it, so the choice starts fresh.
|
||||
fn source_id() -> egui::Id {
|
||||
egui::Id::unique("tracker-motion-source")
|
||||
}
|
||||
@@ -113,6 +115,11 @@ fn analyze_glyph(p: &egui::Painter, r: Rect, forward: bool, frame: bool, stop: b
|
||||
}
|
||||
}
|
||||
|
||||
/// The Tracker panel: the camera/warp and motion/stabilize buttons, Motion Source and Current
|
||||
/// Track pickers, the track type and its channels, Motion Target with Edit Target and Options, the
|
||||
/// four Analyze buttons (the one whose direction is running offers Stop), Reset and Apply, and the
|
||||
/// analysis progress. With a mask selected it switches to its mask-tracking mode. Every change runs a
|
||||
/// `track.*` command; while an analysis is running it polls the job and asks for repaints.
|
||||
pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let t = app.tokens;
|
||||
let ctx = ui.ctx().clone();
|
||||
|
||||
@@ -37,6 +37,7 @@ impl ViewerMap {
|
||||
}
|
||||
}
|
||||
|
||||
/// The `egui::Id` under which the last pass's `ViewerMap` is published.
|
||||
fn map_id() -> egui::Id {
|
||||
egui::Id::unique("viewer-map")
|
||||
}
|
||||
@@ -769,6 +770,13 @@ pub(crate) fn checker(p: &egui::Painter, r: Rect) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw the Composition viewer in `rect` and handle its input for one pass.
|
||||
/// Publishes the fit magnification and `ViewerMap`, requests and draws the frame, then paints the nav breadcrumbs,
|
||||
/// view layout, guides/grid, layer boxes, handles, anchors, 3D gizmos, motion paths, masks, puppet pins and tracker
|
||||
/// overlays and records their automation regions. Pointer and drop gestures run engine commands
|
||||
/// (move/scale/rotate/anchor/camera/shape/mask/guide edits, picks, marquees); failures worth showing go to
|
||||
/// `app.ui.status`, while per-frame drag updates ignore errors so a gesture is not aborted mid-drag. With no active
|
||||
/// composition it draws `empty_state` and returns.
|
||||
pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let t = app.tokens;
|
||||
let ctx = ui.ctx().clone();
|
||||
@@ -2269,6 +2277,10 @@ fn pen_cursor(ctx: &egui::Context, at: Pos2, icon: crate::icons::Icon) {
|
||||
crate::icons::paint(&painter, rect, icon, Color32::WHITE);
|
||||
}
|
||||
|
||||
/// Handle a Pen-tool press at `pos`: close the current path when its first vertex is hit; otherwise append a snapped
|
||||
/// (Shift: 45-degree) vertex, resume an open path's end, add a motion-path keyframe, insert a vertex on a visible
|
||||
/// path segment, or start a new mask/shape path on the selected layer or a new shape layer. Records the resulting
|
||||
/// path id and tangent gesture in context data for the drag that follows.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn pen_press(
|
||||
app: &mut EffectcraftApp,
|
||||
@@ -2600,6 +2612,8 @@ fn create_shape(app: &mut EffectcraftApp, tool: Tool, a: [f64; 2], b: [f64; 2],
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw the no-composition viewer with New Composition and New Composition From Footage buttons, accepting Project
|
||||
/// items dropped on it to create the first composition.
|
||||
fn empty_state(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let t = app.tokens;
|
||||
let p = ui.painter().clone();
|
||||
|
||||
@@ -393,6 +393,7 @@ pub(crate) fn motion_path(
|
||||
|
||||
// ---------------------------------------------------------------- free transform
|
||||
|
||||
/// The `egui::Id` under which the free-transform target (layer and mask or path id) is stored.
|
||||
fn ft_id() -> egui::Id {
|
||||
egui::Id::unique("viewer-free-transform")
|
||||
}
|
||||
|
||||
@@ -40,9 +40,12 @@ enum Drag {
|
||||
Resize { layer: LayerId, handle: usize, start_box: [f64; 4] },
|
||||
}
|
||||
|
||||
/// The `egui::Id` under which the in-progress text drag (selection, creation or box resize) is kept between frames.
|
||||
fn drag_id() -> egui::Id {
|
||||
egui::Id::unique("viewer-text-drag")
|
||||
}
|
||||
/// The `egui::Id` of the invisible focusable widget that owns keyboard and IME focus while on-canvas text editing is
|
||||
/// active.
|
||||
fn focus_id() -> egui::Id {
|
||||
egui::Id::unique("viewer-text-focus")
|
||||
}
|
||||
@@ -97,6 +100,8 @@ enum ImeComposition {
|
||||
struct OutputContext(egui::Id, u64, egui::IMEPurpose);
|
||||
|
||||
impl From<&egui::output::IMEOutput> for OutputContext {
|
||||
/// Copy the owner, context generation and purpose out of egui's IME output so they can be cached and compared
|
||||
/// cheaply.
|
||||
fn from(output: &egui::output::IMEOutput) -> Self {
|
||||
Self(output.owner, output.context_generation, output.purpose)
|
||||
}
|
||||
@@ -111,33 +116,43 @@ struct ResetGuard {
|
||||
suspended: bool,
|
||||
}
|
||||
|
||||
/// The `egui::Id` under which this viewport's `ResetGuard` is stored.
|
||||
fn reset_id(viewport: egui::ViewportId) -> egui::Id {
|
||||
egui::Id::unique(("effectcraft.ime.reset", viewport))
|
||||
}
|
||||
/// The `egui::Id` holding whether this viewport is currently inside a full UI pass (set by the pass hooks).
|
||||
fn mode_id(viewport: egui::ViewportId) -> egui::Id {
|
||||
egui::Id::unique(("effectcraft.ime.ui-pass", viewport))
|
||||
}
|
||||
/// The `egui::Id` holding the last committed IME mutation stamp accepted for this viewport.
|
||||
fn committed_id(viewport: egui::ViewportId) -> egui::Id {
|
||||
egui::Id::unique(("effectcraft.ime.committed", viewport))
|
||||
}
|
||||
/// The `egui::Id` holding the frame number whose text-editor keyboard events were already consumed.
|
||||
fn input_id(viewport: egui::ViewportId) -> egui::Id {
|
||||
egui::Id::unique(("effectcraft.ime.input-frame", viewport))
|
||||
}
|
||||
/// The `egui::Id` holding whether this viewport may request an IME reset in the current pass.
|
||||
fn admission_id(viewport: egui::ViewportId) -> egui::Id {
|
||||
egui::Id::unique(("effectcraft.ime.can-request", viewport))
|
||||
}
|
||||
|
||||
/// Allow or forbid IME reset requests for the current viewport's pass by storing `allowed` in context data.
|
||||
fn set_admission(ctx: &egui::Context, allowed: bool) {
|
||||
let id = admission_id(ctx.viewport_id());
|
||||
ctx.data_mut(|data| data.insert_temp(id, allowed));
|
||||
}
|
||||
|
||||
/// Whether the window can receive input: focused, not minimized and not occluded. Unknown viewport flags count as
|
||||
/// available.
|
||||
fn window_available(ctx: &egui::Context) -> bool {
|
||||
ctx.input(|input| {
|
||||
input.focused && input.viewport().focused.unwrap_or(true) && input.viewport().minimized != Some(true) && input.viewport().occluded != Some(true)
|
||||
})
|
||||
}
|
||||
|
||||
/// Install the per-pass IME hooks on `ctx` once: the begin hook marks a full UI pass and the end hook clears it.
|
||||
/// Later calls do nothing.
|
||||
pub(crate) fn install_hooks(ctx: &egui::Context) {
|
||||
let id = egui::Id::unique("effectcraft.ime.hooks-installed");
|
||||
let first = ctx.data_mut(|data| {
|
||||
@@ -167,11 +182,15 @@ pub(crate) fn install_hooks(ctx: &egui::Context) {
|
||||
);
|
||||
}
|
||||
|
||||
/// Read the current viewport's pending reset guard, if any.
|
||||
fn read_reset(ctx: &egui::Context) -> Option<ResetGuard> {
|
||||
let id = reset_id(ctx.viewport_id());
|
||||
ctx.data(|data| data.get_temp(id))
|
||||
}
|
||||
|
||||
/// Record a reset for the current viewport: request a fresh IME fence when this pass is admitted, then store the
|
||||
/// destination selection, committed stamp and output identity and mark the composition stale. An unadmitted pass
|
||||
/// stores a suspended guard instead.
|
||||
fn request_reset_with_output(app: &EffectcraftApp, ctx: &egui::Context, output: Option<OutputContext>) {
|
||||
let viewport = ctx.viewport_id();
|
||||
let allowed = ctx.data(|data| data.get_temp::<bool>(admission_id(viewport)).unwrap_or(false));
|
||||
@@ -183,11 +202,14 @@ fn request_reset_with_output(app: &EffectcraftApp, ctx: &egui::Context, output:
|
||||
});
|
||||
}
|
||||
|
||||
/// Re-request a reset for the current viewport, keeping the output identity published earlier in the pass.
|
||||
fn request_reset(app: &EffectcraftApp, ctx: &egui::Context) {
|
||||
let output = read_reset(ctx).and_then(|guard| guard.output);
|
||||
request_reset_with_output(app, ctx, output);
|
||||
}
|
||||
|
||||
/// Re-request the reset when an unsuspended guard's destination selection or committed stamp no longer matches the
|
||||
/// app.
|
||||
fn sync_reset(app: &EffectcraftApp, ctx: &egui::Context) {
|
||||
if let Some(guard) = read_reset(ctx)
|
||||
&& !guard.suspended
|
||||
@@ -197,6 +219,7 @@ fn sync_reset(app: &EffectcraftApp, ctx: &egui::Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Retire the guard's request id locally and suspend it, so only a later eligible pass can request a new fence.
|
||||
fn suspend_reset(ctx: &egui::Context) {
|
||||
if let Some(mut guard) = read_reset(ctx) {
|
||||
guard.request = None;
|
||||
@@ -206,6 +229,8 @@ fn suspend_reset(ctx: &egui::Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Accept `request` only when it is this viewport's current, unsuspended request and the destination and stamp still
|
||||
/// match; then sync target and selection, clear the composition and preview and remember the selection.
|
||||
fn complete_reset(app: &EffectcraftApp, ui: &egui::Ui, request: egui::ImeResetId) {
|
||||
if !read_reset(ui.ctx()).is_some_and(|guard| !guard.suspended && guard.request == Some(request)) {
|
||||
return;
|
||||
@@ -228,6 +253,8 @@ fn complete_reset(app: &EffectcraftApp, ui: &egui::Ui, request: egui::ImeResetId
|
||||
}
|
||||
}
|
||||
|
||||
/// Withhold custom IME controls for this pass: suspend the reset guard and drop every `ImeResetCompleted` event.
|
||||
/// No custom mutation route can be waiting for completion here; stock text widgets keep their keys and text events.
|
||||
fn unavailable_controls(ui: &egui::Ui) {
|
||||
suspend_reset(ui.ctx());
|
||||
// No custom mutation route runs here, so controls can only be obsolete. Stock text/keys
|
||||
@@ -235,6 +262,7 @@ fn unavailable_controls(ui: &egui::Ui) {
|
||||
ui.ctx().input_mut(|input| input.events.retain(|event| !matches!(event, egui::Event::ImeResetCompleted { .. })));
|
||||
}
|
||||
|
||||
/// Start a UI pass: revoke IME reset admission and clear the geometry published by the previous pass.
|
||||
pub(crate) fn begin_ui(ctx: &egui::Context) {
|
||||
set_admission(ctx, false);
|
||||
let id = ime_geometry_id(ctx.viewport_id());
|
||||
@@ -248,6 +276,8 @@ pub(crate) fn ime_recorded(ctx: &egui::Context) -> bool {
|
||||
ctx.data(|d| d.get_temp::<ImeGeometry>(ime_geometry_id(viewport))).is_some_and(|(p, _, _)| p == pass)
|
||||
}
|
||||
|
||||
/// Close a logic-only tick: when no full UI pass ran, revoke admission and resync and suspend the reset guard so a
|
||||
/// hidden tick never publishes IME state.
|
||||
pub(crate) fn finish_logic(app: &EffectcraftApp, ctx: &egui::Context) {
|
||||
let id = mode_id(ctx.viewport_id());
|
||||
if !ctx.data(|data| data.get_temp::<bool>(id).unwrap_or(false)) {
|
||||
@@ -266,30 +296,40 @@ struct SurroundingCache {
|
||||
value: Option<ImeSurrounding>,
|
||||
}
|
||||
|
||||
/// The `egui::Id` under which this viewport's visible IME pre-edit preview is stored.
|
||||
fn ime_preedit_id(viewport: egui::ViewportId) -> egui::Id {
|
||||
egui::Id::unique(("viewer-text-preedit", viewport))
|
||||
}
|
||||
/// The `egui::Id` under which this viewport's in-flight IME composition state is stored.
|
||||
fn ime_composition_id(viewport: egui::ViewportId) -> egui::Id {
|
||||
egui::Id::unique(("viewer-text-ime-composition", viewport))
|
||||
}
|
||||
/// The `egui::Id` under which the current pass number, text region and caret rectangle are stored for the
|
||||
/// viewport IME publisher.
|
||||
fn ime_geometry_id(viewport: egui::ViewportId) -> egui::Id {
|
||||
egui::Id::unique(("viewer-text-ime-geometry", viewport))
|
||||
}
|
||||
/// The `egui::Id` under which the last text-edit target given keyboard focus is stored.
|
||||
fn ime_seen_id(viewport: egui::ViewportId) -> egui::Id {
|
||||
egui::Id::unique(("viewer-text-seen", viewport))
|
||||
}
|
||||
/// The `egui::Id` under which the last `ImeOwner` observed by `sync_ime_target` is stored.
|
||||
fn ime_target_seen_id(viewport: egui::ViewportId) -> egui::Id {
|
||||
egui::Id::unique(("viewer-text-ime-target", viewport))
|
||||
}
|
||||
/// The `egui::Id` under which the last accepted IME selection is stored.
|
||||
fn ime_selection_id(viewport: egui::ViewportId) -> egui::Id {
|
||||
egui::Id::unique(("viewer-text-ime-selection", viewport))
|
||||
}
|
||||
/// The `egui::Id` under which the last published IME owner and context generation are stored.
|
||||
fn ime_last_context_id(viewport: egui::ViewportId) -> egui::Id {
|
||||
egui::Id::unique(("viewer-text-ime-last-context", viewport))
|
||||
}
|
||||
/// The `egui::Id` under which the bounded IME surrounding-text snapshot and its cache key are stored.
|
||||
fn ime_surrounding_cache_id(viewport: egui::ViewportId) -> egui::Id {
|
||||
egui::Id::unique(("viewer-text-ime-surrounding", viewport))
|
||||
}
|
||||
/// The `egui::Id` under which the typing merge counter is stored.
|
||||
fn typing_key_id(viewport: egui::ViewportId) -> egui::Id {
|
||||
egui::Id::unique(("viewer-text-typing", viewport))
|
||||
}
|
||||
@@ -306,6 +346,8 @@ fn ime_target(app: &EffectcraftApp) -> Option<egui::ImeTarget> {
|
||||
Some(egui::ImeTarget::new(egui::Id::unique(("effectcraft-ime-text", comp.0, edit.layer.0)), edit.session))
|
||||
}
|
||||
|
||||
/// The layer and edit-session token of the active text edit, or `None` when no editor is open. Caret, selection and
|
||||
/// text are deliberately excluded.
|
||||
fn ime_owner(app: &EffectcraftApp) -> Option<ImeOwner> {
|
||||
let edit = app.session.state.text_edit.as_ref()?;
|
||||
Some(ImeOwner { layer: edit.layer, session: edit.session })
|
||||
@@ -338,6 +380,9 @@ fn ime_surrounding_cached(app: &EffectcraftApp, ctx: &egui::Context) -> Option<I
|
||||
value
|
||||
}
|
||||
|
||||
/// The committed-text stamp an IME receipt compares against: the bounded snapshot when it is available, else the
|
||||
/// editor revision marked unavailable. It deliberately does not use the global project revision, so an unrelated edit
|
||||
/// leaves a wide selection's stamp unchanged.
|
||||
fn ime_stamp(app: &EffectcraftApp, ctx: &egui::Context) -> egui::ImeMutationStamp {
|
||||
match ime_surrounding_cached(app, ctx) {
|
||||
Some(w) => egui::ImeMutationStamp::from_committed(&w.text, w.cursor, w.anchor, None),
|
||||
@@ -379,16 +424,19 @@ fn note_ime_application_change(app: &mut EffectcraftApp, ctx: &egui::Context) {
|
||||
app.ime_tracker.note_application_change(viewport, frame, target);
|
||||
}
|
||||
|
||||
/// Drop this viewport's visible pre-edit preview.
|
||||
fn clear_preedit(ui: &egui::Ui) {
|
||||
let viewport = ui.ctx().viewport_id();
|
||||
ui.data_mut(|d| d.remove::<Preedit>(ime_preedit_id(viewport)));
|
||||
}
|
||||
|
||||
/// Read this viewport's in-flight IME composition state.
|
||||
fn read_ime_composition(ui: &egui::Ui) -> Option<ImeComposition> {
|
||||
let viewport = ui.ctx().viewport_id();
|
||||
ui.data(|d| d.get_temp::<ImeComposition>(ime_composition_id(viewport)))
|
||||
}
|
||||
|
||||
/// Store this viewport's composition state, or remove it with `None`.
|
||||
fn set_ime_composition(ui: &egui::Ui, composition: Option<ImeComposition>) {
|
||||
let viewport = ui.ctx().viewport_id();
|
||||
ui.data_mut(|d| match composition {
|
||||
@@ -434,6 +482,7 @@ fn sync_ime_target(ui: &egui::Ui, app: &EffectcraftApp) {
|
||||
ui.data_mut(|d| d.insert_temp(ime_target_seen_id(viewport), current));
|
||||
}
|
||||
|
||||
/// The active text edit's owner, anchor and caret, or `None` when no editor is open.
|
||||
fn ime_selection(app: &EffectcraftApp) -> Option<ImeSelection> {
|
||||
let edit = app.session.state.text_edit.as_ref()?;
|
||||
Some(ImeSelection { owner: ime_owner(app)?, anchor: edit.anchor, caret: edit.caret })
|
||||
@@ -465,6 +514,7 @@ fn sync_ime_selection(ui: &egui::Ui, app: &EffectcraftApp) {
|
||||
remember_ime_selection(ui, app);
|
||||
}
|
||||
|
||||
/// Truncate a pre-edit preview to `MAX_IME_TEXT_BYTES` on a character boundary, so no character is split.
|
||||
fn bounded_preedit(mut text: String) -> String {
|
||||
if text.len() > MAX_IME_TEXT_BYTES {
|
||||
let mut end = MAX_IME_TEXT_BYTES;
|
||||
@@ -476,6 +526,7 @@ fn bounded_preedit(mut text: String) -> String {
|
||||
text
|
||||
}
|
||||
|
||||
/// Read this viewport's visible pre-edit preview, whatever owner it belongs to.
|
||||
fn read_preedit(ui: &egui::Ui) -> Option<Preedit> {
|
||||
let viewport = ui.ctx().viewport_id();
|
||||
ui.data(|d| d.get_temp::<Preedit>(ime_preedit_id(viewport)))
|
||||
@@ -502,17 +553,19 @@ fn ime_event_is_stale(ui: &egui::Ui, owner: ImeOwner) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// The pre-edit text to draw, only when it belongs to the current edit owner and is non-empty.
|
||||
fn visible_preedit(ui: &egui::Ui, app: &EffectcraftApp) -> Option<String> {
|
||||
let owner = ime_owner(app)?;
|
||||
let p = read_preedit(ui)?;
|
||||
(p.owner == owner && !p.text.is_empty()).then_some(p.text)
|
||||
}
|
||||
|
||||
/// Publish the settled descriptor for one completed viewport pass.
|
||||
/// Publish the descriptor from this viewport's current text state and recorded geometry.
|
||||
///
|
||||
/// This runs at the very end of a root pass (after dock actions, ScriptUI, learn and dialogs)
|
||||
/// and at the end of each panel-window pass after that window's actions. The overlay only
|
||||
/// records geometry; it never publishes early with a snapshot that a later action can replace.
|
||||
/// The root viewport publishes after dock actions, ScriptUI, learn and dialogs. Native panel
|
||||
/// windows publish after the IME-owning body records geometry and again after their last body;
|
||||
/// publication can therefore precede later bodies in that window. The overlay only records
|
||||
/// geometry; this function resolves the current owner and availability when it publishes.
|
||||
pub(crate) fn finish_ime(app: &mut EffectcraftApp, ui: &egui::Ui) {
|
||||
let ctx = ui.ctx();
|
||||
let viewport = ctx.viewport_id();
|
||||
@@ -1305,6 +1358,9 @@ fn ime_delete(app: &mut EffectcraftApp, ui: &egui::Ui, before: usize, after: usi
|
||||
exec(app, "text.deleteSurrounding", json!({"before": before, "after": after, "bytes": bytes, "merge": k})).is_some()
|
||||
}
|
||||
|
||||
/// Paint the paragraph box and handles, selection, caret or pre-edit for `e`; register the editor's and box handles'
|
||||
/// automation regions; request the caret-blink repaint; and record the pass's text and caret rectangles for the IME
|
||||
/// finalizer.
|
||||
fn draw(app: &mut EffectcraftApp, ui: &mut egui::Ui, painter: &egui::Painter, map: &ViewerMap, e: &Edited, sel: std::ops::Range<usize>, caret: usize) {
|
||||
let t = app.tokens;
|
||||
let to_s = |p: Point| e.to_screen(map, (p.x, p.y));
|
||||
@@ -1359,7 +1415,7 @@ fn draw(app: &mut EffectcraftApp, ui: &mut egui::Ui, painter: &egui::Painter, ma
|
||||
let rect = Rect::from_points(&quad);
|
||||
app.auto.add("viewer.textEdit", rect, &format!("Editing {}", e.layer.name));
|
||||
let cursor_rect = Rect::from_two_pos(sa, sb).expand2(vec2(1.0, 0.0));
|
||||
// The outgoing IME descriptor is published by the pass-end finalizer, with this actual
|
||||
// The viewport IME publisher uses this actual
|
||||
// caret geometry and the edited layer's committed `TextDoc` (never the bidi or pre-edit
|
||||
// display text). An available-empty document stays distinct from unavailable.
|
||||
let viewport = ui.ctx().viewport_id();
|
||||
|
||||
@@ -24,6 +24,7 @@ use crate::{EffectcraftApp, widgets};
|
||||
pub(crate) const SNAP_PX: f64 = 8.0;
|
||||
const SNAP_COLOR: Color32 = Color32::from_rgb(0xff, 0xd2, 0x3c);
|
||||
|
||||
/// The `egui::Id` under which this frame's snap feedback is stored until `draw_snap` consumes it.
|
||||
fn snap_id() -> egui::Id {
|
||||
egui::Id::unique("viewer-snap")
|
||||
}
|
||||
@@ -318,11 +319,13 @@ pub(crate) fn proportional_grid(app: &EffectcraftApp, painter: &egui::Painter, c
|
||||
|
||||
// ---------------------------------------------------------------- the displayed frame
|
||||
|
||||
/// Remember the region of interest the displayed texture was rendered with.
|
||||
/// Store the displayed texture's Region of Interest or Extended Viewer area unchanged.
|
||||
pub(crate) fn set_texture_roi(ctx: &egui::Context, roi: Option<[f64; 4]>) {
|
||||
ctx.data_mut(|d| d.insert_temp(egui::Id::unique("viewer-tex-roi"), roi));
|
||||
}
|
||||
|
||||
/// The comp region (`[x, y, w, h]` in pixels) used for the texture: Region of Interest or Extended Viewer area,
|
||||
/// or `None` when no explicit region is stored, meaning the whole comp.
|
||||
fn texture_roi(ctx: &egui::Context) -> Option<[f64; 4]> {
|
||||
ctx.data(|d| d.get_temp::<Option<[f64; 4]>>(egui::Id::unique("viewer-tex-roi"))).flatten()
|
||||
}
|
||||
@@ -505,6 +508,7 @@ fn popup(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: &str, anchor: Rect, it
|
||||
})
|
||||
}
|
||||
|
||||
/// Flip the open flag of the popup with `id`; the caller that owns the popup reads the same flag.
|
||||
fn toggle_popup(ui: &egui::Ui, id: &str) {
|
||||
let pid = egui::Id::unique(id).with("open");
|
||||
let open: bool = ui.data(|d| d.get_temp(pid).unwrap_or(false));
|
||||
@@ -616,6 +620,10 @@ pub(crate) fn bottom_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui, bar: Rect,
|
||||
});
|
||||
}
|
||||
|
||||
/// Lay out the viewer's control row from `bar.min.x`: magnification, resolution, transparency, mask, ROI, grid/guide,
|
||||
/// channel/colour management, exposure, snapshot, fast previews, 3D renderer/view and Extended Viewer, timecode and render-time
|
||||
/// indicators, running commands or toggling viewer UI state. Returns the width the row
|
||||
/// occupies, at least `bar.width()`, for the horizontal scroll area.
|
||||
fn bottom_bar_contents(app: &mut EffectcraftApp, ui: &mut egui::Ui, bar: Rect, zoom: f32, time: Tick, comp: &Comp) -> f32 {
|
||||
let t = app.tokens;
|
||||
let p = ui.painter().clone();
|
||||
|
||||
@@ -12,6 +12,7 @@ struct State {
|
||||
disabled: bool,
|
||||
}
|
||||
|
||||
/// The temp-data id of the [`State`] that tracks a numeric scrub across monitor edges.
|
||||
fn state_id() -> Id {
|
||||
Id::unique("numeric-pointer-wrap")
|
||||
}
|
||||
|
||||
@@ -579,7 +579,7 @@ pub struct UiState {
|
||||
pub maximized: Option<PanelKind>,
|
||||
/// Floating windows of [`Self::dock`] whose native rect holds the OS window's outer
|
||||
/// position, from a workspace file of an earlier version (see
|
||||
/// [`crate::dock_ui::native_outer_to_content`]). Workspaces are not saved while any remain.
|
||||
/// `crate::dock_ui::native_outer_to_content`). Workspaces are not saved while any remain.
|
||||
#[serde(skip)]
|
||||
pub native_outer: Vec<u64>,
|
||||
/// Locked Composition / Timeline tabs (panel ids): opening another composition does not
|
||||
|
||||
@@ -311,6 +311,8 @@ impl Tokens {
|
||||
}
|
||||
}
|
||||
|
||||
/// The default label colours, one RGB colour per [`effectcraft_color::Label`] in
|
||||
/// `Label::ALL` order.
|
||||
fn default_labels() -> [Color32; 17] {
|
||||
effectcraft_color::Label::ALL.map(|l| {
|
||||
let [r, g, b] = l.rgb();
|
||||
@@ -328,7 +330,7 @@ static JETBRAINS_MONO: &[u8] = include_bytes!("../../../assets/fonts/JetBrainsMo
|
||||
static NOTO_SYMBOLS2: &[u8] = include_bytes!("../../../assets/fonts/NotoSansSymbols2-Regular.ttf");
|
||||
|
||||
/// Install fonts (Inter, Inter Medium/SemiBold, JetBrains Mono) and egui visuals. `language` is the
|
||||
/// resolved interface language from [`crate::i18n::language`] (`en`, `ja`, `zh-hans` or `zh-hant`),
|
||||
/// resolved interface language from `crate::i18n::language` (`en`, `ja`, `zh-hans` or `zh-hant`),
|
||||
/// not the stored `general.language` preference: `system` is already resolved there, so a Chinese
|
||||
/// system draws Chinese with the default setting. The CJK fallback that draws the interface's own
|
||||
/// script is registered first, so a Chinese interface is drawn by a Chinese face and a Japanese one
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user